| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>Gallery Pro: Grain & Notes</title> |
| |
| <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.5.13/cropper.min.css"> |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.5.13/cropper.min.js"></script> |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script> |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/FileSaver.js/2.0.5/FileSaver.min.js"></script> |
|
|
| <style> |
| :root { |
| --bg: #121212; |
| --surface: #1e1e1e; |
| --primary: #bb86fc; |
| --secondary: #03dac6; |
| --text: #e0e0e0; |
| --border: #333; |
| } |
| |
| body { |
| font-family: 'Segoe UI', sans-serif; |
| background-color: var(--bg); |
| color: var(--text); |
| margin: 0; |
| display: flex; |
| height: 100vh; |
| overflow: hidden; |
| } |
| |
| |
| .sidebar { |
| width: 260px; |
| background-color: var(--surface); |
| padding: 20px; |
| display: flex; |
| flex-direction: column; |
| border-right: 1px solid var(--border); |
| flex-shrink: 0; |
| } |
| |
| .sidebar h2 { color: var(--primary); margin-top: 0; } |
| |
| .album-list { |
| list-style: none; |
| padding: 0; |
| margin-top: 20px; |
| flex-grow: 1; |
| overflow-y: auto; |
| } |
| |
| .album-item { |
| padding: 12px; |
| cursor: pointer; |
| border-radius: 6px; |
| margin-bottom: 8px; |
| display: flex; |
| justify-content: space-between; |
| align-items: center; |
| background: #252525; |
| transition: 0.2s; |
| } |
| |
| .album-item:hover { background-color: #333; } |
| .album-item.active { background-color: var(--primary); color: #000; } |
| |
| input, select, textarea { |
| background: #2b2b2b; |
| border: 1px solid #444; |
| color: white; |
| padding: 8px; |
| border-radius: 4px; |
| width: 100%; |
| box-sizing: border-box; |
| margin-bottom: 10px; |
| } |
| |
| button { |
| background-color: var(--primary); |
| border: none; |
| color: #000; |
| padding: 10px 15px; |
| border-radius: 4px; |
| cursor: pointer; |
| font-weight: bold; |
| transition: 0.2s; |
| } |
| button:hover { opacity: 0.9; } |
| button.secondary { background-color: #444; color: white; } |
| button.action-btn { background-color: var(--secondary); color: #000; margin-right: 5px; } |
| |
| |
| .main { |
| flex-grow: 1; |
| display: flex; |
| flex-direction: column; |
| overflow: hidden; |
| } |
| |
| .header { |
| padding: 20px; |
| border-bottom: 1px solid var(--border); |
| background: var(--bg); |
| } |
| |
| .top-bar { display: flex; justify-content: space-between; align-items: center; } |
| .action-bar { margin-top: 15px; display: flex; gap: 10px; flex-wrap: wrap; } |
| |
| .notes-area { |
| width: 100%; |
| height: 60px; |
| margin-top: 10px; |
| resize: none; |
| font-family: monospace; |
| font-size: 0.9rem; |
| } |
| |
| .gallery-container { |
| flex-grow: 1; |
| padding: 20px; |
| overflow-y: auto; |
| } |
| |
| .gallery-grid { |
| display: grid; |
| grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); |
| gap: 20px; |
| } |
| |
| .img-card { |
| background-color: var(--surface); |
| border-radius: 8px; |
| overflow: hidden; |
| position: relative; |
| group: hover; |
| box-shadow: 0 4px 6px rgba(0,0,0,0.3); |
| } |
| |
| .img-card img { |
| width: 100%; |
| height: 200px; |
| object-fit: cover; |
| display: block; |
| } |
| |
| .img-actions { |
| position: absolute; |
| bottom: 0; left: 0; right: 0; |
| background: rgba(0,0,0,0.8); |
| padding: 5px; |
| display: flex; |
| justify-content: space-around; |
| transform: translateY(100%); |
| transition: transform 0.2s; |
| } |
| |
| .img-card:hover .img-actions { transform: translateY(0); } |
| |
| .img-btn { |
| background: none; border: none; color: white; font-size: 1.2rem; cursor: pointer; padding: 5px; |
| } |
| .img-btn:hover { color: var(--primary); } |
| |
| |
| .modal { |
| position: fixed; |
| top: 0; left: 0; right: 0; bottom: 0; |
| background: rgba(0,0,0,0.95); |
| display: none; |
| flex-direction: column; |
| z-index: 2000; |
| padding: 20px; |
| } |
| .modal.open { display: flex; } |
| |
| .editor-workspace { |
| flex-grow: 1; |
| display: flex; |
| justify-content: center; |
| align-items: center; |
| overflow: hidden; |
| background: #000; |
| border: 1px solid #333; |
| margin-bottom: 10px; |
| } |
| |
| .editor-workspace img { max-width: 100%; max-height: 80vh; display: block; } |
| |
| .editor-toolbar { |
| background: var(--surface); |
| padding: 15px; |
| border-radius: 8px; |
| display: flex; |
| gap: 20px; |
| align-items: center; |
| justify-content: center; |
| flex-wrap: wrap; |
| } |
| |
| .tool-group { |
| display: flex; |
| align-items: center; |
| gap: 10px; |
| border-right: 1px solid #444; |
| padding-right: 20px; |
| } |
| .tool-group:last-child { border: none; } |
| |
| label { font-size: 0.8rem; text-transform: uppercase; letter-spacing: 1px; } |
| |
| </style> |
| </head> |
| <body> |
|
|
| <div class="sidebar"> |
| <h2>Gallery Pro</h2> |
| |
| <div style="background: #252525; padding: 10px; border-radius: 6px;"> |
| <input type="text" id="newAlbumName" placeholder="New Album Name"> |
| <select id="newAlbumAccess"> |
| <option value="public">🔓 Allow All</option> |
| <option value="private">🔒 Limited Access</option> |
| </select> |
| <button onclick="createAlbum()" style="width:100%">+ Create</button> |
| </div> |
|
|
| <div class="album-list" id="albumList"> |
| </div> |
| </div> |
|
|
| <div class="main"> |
| <div class="header"> |
| <div class="top-bar"> |
| <h1 id="currentAlbumTitle" style="margin:0">Select Album</h1> |
| <span id="accessBadge" style="padding: 5px 10px; border-radius: 4px; background:#333; font-size: 0.8rem;"></span> |
| </div> |
| |
| <textarea id="albumNotes" class="notes-area" placeholder="Write notes about this album..." oninput="saveNotes()"></textarea> |
|
|
| <div class="action-bar" id="actionBar" style="display:none;"> |
| <input type="file" id="fileInput" accept="image/*" style="display:none" onchange="handleFileUpload(this)"> |
| <button onclick="document.getElementById('fileInput').click()">📁 Upload File</button> |
| |
| <button class="secondary" onclick="addFromUrl()">🌐 Add from URL</button> |
| |
| <button class="action-btn" onclick="downloadAlbumZip()">💾 Download Album (.zip)</button> |
| </div> |
| </div> |
|
|
| <div class="gallery-container"> |
| <div class="gallery-grid" id="galleryGrid"> |
| <p style="opacity:0.5; margin: 20px;">Select an album to start.</p> |
| </div> |
| </div> |
| </div> |
|
|
| <div class="modal" id="editorModal"> |
| <div class="editor-workspace"> |
| <img id="editTarget" src="" alt="Editing..."> |
| </div> |
| |
| <div class="editor-toolbar"> |
| <div class="tool-group"> |
| <label>Grain</label> |
| <input type="range" id="grainRange" min="0" max="100" value="0"> |
| <button class="secondary" onclick="applyGrain()">Apply</button> |
| </div> |
|
|
| <div class="tool-group"> |
| <button class="secondary" onclick="rotateImage()">↻ Rotate 90°</button> |
| <button class="secondary" id="btnCrop" onclick="toggleCrop()">✂ Crop</button> |
| </div> |
|
|
| <div class="tool-group" style="border:none"> |
| <button class="secondary" onclick="restoreOriginal()" style="background:#cf6679; color:white">Restore</button> |
| <button onclick="saveEdits()">✅ Save Changes</button> |
| <button class="secondary" onclick="closeModal()">Cancel</button> |
| </div> |
| </div> |
| </div> |
|
|
| <script> |
| |
| let albums = JSON.parse(localStorage.getItem('galleryPro_albums')) || []; |
| let currentAlbumId = null; |
| let cropper = null; |
| let currentEditIndex = -1; |
| let originalImgData = null; |
| |
| |
| function renderAlbums() { |
| const list = document.getElementById('albumList'); |
| list.innerHTML = ''; |
| albums.forEach(album => { |
| const li = document.createElement('li'); |
| li.className = `album-item ${currentAlbumId === album.id ? 'active' : ''}`; |
| li.innerHTML = `<span>${album.name} <small>(${album.images.length})</small></span> ${album.access === 'private' ? '🔒' : '🔓'}`; |
| li.onclick = () => selectAlbum(album.id); |
| list.appendChild(li); |
| }); |
| } |
| |
| function createAlbum() { |
| const name = document.getElementById('newAlbumName').value; |
| const access = document.getElementById('newAlbumAccess').value; |
| if (!name) return alert("Enter a name"); |
| |
| const newAlbum = { id: Date.now(), name, access, notes: "", images: [] }; |
| albums.push(newAlbum); |
| saveData(); |
| document.getElementById('newAlbumName').value = ''; |
| renderAlbums(); |
| selectAlbum(newAlbum.id); |
| } |
| |
| function selectAlbum(id) { |
| currentAlbumId = id; |
| const album = albums.find(a => a.id === id); |
| |
| document.getElementById('currentAlbumTitle').innerText = album.name; |
| document.getElementById('albumNotes').value = album.notes || ""; |
| document.getElementById('accessBadge').innerText = album.access === 'public' ? "Public Access" : "Restricted Access"; |
| document.getElementById('accessBadge').style.background = album.access === 'public' ? "#2e7d32" : "#c62828"; |
| document.getElementById('actionBar').style.display = 'flex'; |
| |
| renderGallery(); |
| renderAlbums(); |
| } |
| |
| function saveNotes() { |
| if (!currentAlbumId) return; |
| const album = albums.find(a => a.id === currentAlbumId); |
| album.notes = document.getElementById('albumNotes').value; |
| saveData(); |
| } |
| |
| |
| function renderGallery() { |
| const album = albums.find(a => a.id === currentAlbumId); |
| const grid = document.getElementById('galleryGrid'); |
| grid.innerHTML = ''; |
| |
| if (album.images.length === 0) { |
| grid.innerHTML = '<p style="opacity:0.5; width:100%">No images. Upload or paste a URL.</p>'; |
| return; |
| } |
| |
| album.images.forEach((img, index) => { |
| const card = document.createElement('div'); |
| card.className = 'img-card'; |
| card.innerHTML = ` |
| <img src="${img.data}" alt="Gallery Image"> |
| <div class="img-actions"> |
| <button class="img-btn" onclick="openEditor(${index})" title="Edit">✏️</button> |
| <button class="img-btn" onclick="downloadSingle(${index})" title="Download">⬇️</button> |
| <button class="img-btn" onclick="deleteImage(${index})" title="Delete" style="color:#cf6679">🗑️</button> |
| </div> |
| `; |
| grid.appendChild(card); |
| }); |
| } |
| |
| |
| function handleFileUpload(input) { |
| if (input.files && input.files[0]) { |
| const reader = new FileReader(); |
| reader.onload = (e) => addToAlbum(e.target.result); |
| reader.readAsDataURL(input.files[0]); |
| } |
| } |
| |
| function addFromUrl() { |
| const url = prompt("Paste Image URL:"); |
| if (url) { |
| |
| |
| const img = new Image(); |
| img.crossOrigin = "Anonymous"; |
| img.onload = function() { |
| const canvas = document.createElement('canvas'); |
| canvas.width = img.width; |
| canvas.height = img.height; |
| const ctx = canvas.getContext('2d'); |
| ctx.drawImage(img, 0, 0); |
| try { |
| addToAlbum(canvas.toDataURL("image/jpeg")); |
| } catch (e) { |
| |
| addToAlbum(url); |
| } |
| }; |
| img.onerror = () => addToAlbum(url); |
| img.src = url; |
| } |
| } |
| |
| function addToAlbum(dataString) { |
| const album = albums.find(a => a.id === currentAlbumId); |
| album.images.push({ data: dataString, date: new Date().toISOString() }); |
| saveData(); |
| renderGallery(); |
| renderAlbums(); |
| } |
| |
| |
| function openEditor(index) { |
| const album = albums.find(a => a.id === currentAlbumId); |
| currentEditIndex = index; |
| const imgData = album.images[index].data; |
| originalImgData = imgData; |
| |
| const imgEl = document.getElementById('editTarget'); |
| imgEl.src = imgData; |
| |
| document.getElementById('editorModal').classList.add('open'); |
| |
| |
| imgEl.onload = () => { |
| if (cropper) cropper.destroy(); |
| }; |
| } |
| |
| function toggleCrop() { |
| const imgEl = document.getElementById('editTarget'); |
| if (cropper) { |
| |
| const canvas = cropper.getCroppedCanvas(); |
| imgEl.src = canvas.toDataURL(); |
| cropper.destroy(); |
| cropper = null; |
| document.getElementById('btnCrop').innerText = "✂ Crop"; |
| document.getElementById('btnCrop').classList.add('secondary'); |
| } else { |
| |
| cropper = new Cropper(imgEl, { viewMode: 1 }); |
| document.getElementById('btnCrop').innerText = "Apply Crop"; |
| document.getElementById('btnCrop').classList.remove('secondary'); |
| } |
| } |
| |
| function rotateImage() { |
| if (cropper) cropper.rotate(90); |
| else { |
| |
| const img = document.getElementById('editTarget'); |
| const canvas = document.createElement('canvas'); |
| canvas.width = img.naturalHeight; |
| canvas.height = img.naturalWidth; |
| const ctx = canvas.getContext('2d'); |
| ctx.translate(canvas.width/2, canvas.height/2); |
| ctx.rotate(90 * Math.PI / 180); |
| ctx.drawImage(img, -img.naturalWidth/2, -img.naturalHeight/2); |
| img.src = canvas.toDataURL(); |
| } |
| } |
| |
| function applyGrain() { |
| const amount = parseInt(document.getElementById('grainRange').value); |
| if(amount === 0) return; |
| |
| const img = document.getElementById('editTarget'); |
| const canvas = document.createElement('canvas'); |
| canvas.width = img.naturalWidth || img.width; |
| canvas.height = img.naturalHeight || img.height; |
| const ctx = canvas.getContext('2d'); |
| |
| ctx.drawImage(img, 0, 0, canvas.width, canvas.height); |
| |
| const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); |
| const data = imageData.data; |
| |
| for (let i = 0; i < data.length; i += 4) { |
| const noise = (Math.random() - 0.5) * amount * 2; |
| data[i] += noise; |
| data[i+1] += noise; |
| data[i+2] += noise; |
| } |
| |
| ctx.putImageData(imageData, 0, 0); |
| img.src = canvas.toDataURL(); |
| document.getElementById('grainRange').value = 0; |
| } |
| |
| function restoreOriginal() { |
| if (cropper) cropper.destroy(); |
| cropper = null; |
| document.getElementById('editTarget').src = originalImgData; |
| } |
| |
| function saveEdits() { |
| if (cropper) { |
| const canvas = cropper.getCroppedCanvas(); |
| document.getElementById('editTarget').src = canvas.toDataURL(); |
| cropper.destroy(); |
| cropper = null; |
| } |
| |
| const album = albums.find(a => a.id === currentAlbumId); |
| album.images[currentEditIndex].data = document.getElementById('editTarget').src; |
| saveData(); |
| renderGallery(); |
| closeModal(); |
| } |
| |
| function closeModal() { |
| document.getElementById('editorModal').classList.remove('open'); |
| if (cropper) { |
| cropper.destroy(); |
| cropper = null; |
| } |
| } |
| |
| |
| function downloadSingle(index) { |
| const album = albums.find(a => a.id === currentAlbumId); |
| const link = document.createElement('a'); |
| link.href = album.images[index].data; |
| link.download = `image_${Date.now()}.jpg`; |
| link.click(); |
| } |
| |
| function deleteImage(index) { |
| if(confirm("Delete this image?")) { |
| const album = albums.find(a => a.id === currentAlbumId); |
| album.images.splice(index, 1); |
| saveData(); |
| renderGallery(); |
| renderAlbums(); |
| } |
| } |
| |
| function downloadAlbumZip() { |
| const album = albums.find(a => a.id === currentAlbumId); |
| if (!album || album.images.length === 0) return alert("Album is empty!"); |
| |
| const zip = new JSZip(); |
| const folder = zip.folder(album.name); |
| |
| |
| if (album.notes) folder.file("notes.txt", album.notes); |
| |
| |
| album.images.forEach((img, i) => { |
| |
| const base64Data = img.data.split(',')[1]; |
| if (base64Data) { |
| folder.file(`image_${i + 1}.jpg`, base64Data, {base64: true}); |
| } |
| }); |
| |
| zip.generateAsync({type:"blob"}).then(function(content) { |
| saveAs(content, `${album.name}.zip`); |
| }); |
| } |
| |
| function saveData() { |
| try { |
| localStorage.setItem('galleryPro_albums', JSON.stringify(albums)); |
| } catch (e) { |
| alert("Storage full! Delete some old images."); |
| } |
| } |
| |
| |
| renderAlbums(); |
| </script> |
| </body> |
| </html> |