|
|
<!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> |