const state = { authenticated: false, folders: [], photos: [], selectedFolderId: 1, }; const pages = document.querySelectorAll("[data-page]"); const routeLinks = document.querySelectorAll("[data-route]"); const loginOpenButton = document.querySelector("#loginOpenButton"); const logoutButton = document.querySelector("#logoutButton"); const loginDialog = document.querySelector("#loginDialog"); const loginForm = document.querySelector("#loginForm"); const loginCancelButton = document.querySelector("#loginCancelButton"); const passwordInput = document.querySelector("#passwordInput"); const loginMessage = document.querySelector("#loginMessage"); const ownerPanels = document.querySelectorAll(".owner-panel"); const profileForm = document.querySelector("#profileForm"); const nameInput = document.querySelector("#nameInput"); const bioInput = document.querySelector("#bioInput"); const avatarInput = document.querySelector("#avatarInput"); const profileName = document.querySelector("#profileName"); const profileBio = document.querySelector("#profileBio"); const avatarPreview = document.querySelector("#avatarPreview"); const folderForm = document.querySelector("#folderForm"); const folderNameInput = document.querySelector("#folderNameInput"); const folderTabs = document.querySelector("#folderTabs"); const uploadFolderSelect = document.querySelector("#uploadFolderSelect"); const galleryForm = document.querySelector("#galleryForm"); const galleryInput = document.querySelector("#galleryInput"); const galleryGrid = document.querySelector("#galleryGrid"); const homeGalleryGrid = document.querySelector("#homeGalleryGrid"); const galleryStatus = document.querySelector("#galleryStatus"); const imageDialog = document.querySelector("#imageDialog"); const imagePreview = document.querySelector("#imagePreview"); const imageCloseButton = document.querySelector("#imageCloseButton"); const diaryForm = document.querySelector("#diaryForm"); const titleInput = document.querySelector("#diaryTitle"); const textInput = document.querySelector("#diaryText"); const diaryImageInput = document.querySelector("#diaryImageInput"); const diaryList = document.querySelector("#diaryList"); async function api(path, options = {}) { const response = await fetch(path, { headers: { "Content-Type": "application/json", ...(options.headers || {}), }, credentials: "same-origin", ...options, }); const data = await response.json().catch(() => ({})); if (!response.ok) { throw new Error(data.error || "请求失败"); } return data; } function showPage(route) { const nextRoute = route || "home"; pages.forEach((page) => { page.classList.toggle("active", page.dataset.page === nextRoute); }); routeLinks.forEach((link) => { link.classList.toggle("active", link.dataset.route === nextRoute); }); } function syncRoute() { const route = (location.hash || "#home").replace("#", ""); showPage(["home", "gallery", "diary"].includes(route) ? route : "home"); } function updateAuthUi() { ownerPanels.forEach((panel) => panel.classList.toggle("hidden", !state.authenticated)); loginOpenButton.classList.toggle("hidden", state.authenticated); logoutButton.classList.toggle("hidden", !state.authenticated); document.querySelectorAll(".delete-button").forEach((button) => { button.classList.toggle("hidden", !state.authenticated); }); } function dataUrlBytes(dataUrl) { const base64 = dataUrl.split(",")[1] || ""; return Math.ceil((base64.length * 3) / 4); } function drawImageToDataUrl(image, maxSize, quality) { const scale = Math.min(1, maxSize / Math.max(image.width, image.height)); const canvas = document.createElement("canvas"); canvas.width = Math.max(1, Math.round(image.width * scale)); canvas.height = Math.max(1, Math.round(image.height * scale)); const context = canvas.getContext("2d"); context.fillStyle = "#fff8fc"; context.fillRect(0, 0, canvas.width, canvas.height); context.drawImage(image, 0, 0, canvas.width, canvas.height); return canvas.toDataURL("image/jpeg", quality); } function readImage(file, options = {}) { const maxSize = options.maxSize || 900; const quality = options.quality || 0.78; const maxBytes = options.maxBytes || 120000; return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => { const image = new Image(); image.onload = () => { const sizes = [maxSize, 640, 520, 420, 320, 240, 180].filter((size) => size <= maxSize); const qualities = [quality, 0.62, 0.5, 0.42, 0.34, 0.28]; let bestImage = drawImageToDataUrl(image, sizes[0], qualities[0]); for (const size of sizes) { for (const nextQuality of qualities) { bestImage = drawImageToDataUrl(image, size, nextQuality); if (dataUrlBytes(bestImage) <= maxBytes) { resolve(bestImage); return; } } } resolve(bestImage); }; image.onerror = () => reject(new Error("图片读取失败")); image.src = reader.result; }; reader.onerror = () => reject(reader.error); reader.readAsDataURL(file); }); } async function loadSession() { const data = await api("/api/session"); state.authenticated = data.authenticated; updateAuthUi(); } async function loadProfile() { const profile = await api("/api/profile"); const name = profile.name || "CICI"; profileName.textContent = `你好,我是 ${name}`; profileBio.textContent = profile.bio || ""; nameInput.value = name; bioInput.value = profile.bio || ""; if (profile.avatar) { avatarPreview.innerHTML = ""; const image = document.createElement("img"); image.src = profile.avatar; image.alt = `${name} 的头像`; avatarPreview.append(image); } else { avatarPreview.innerHTML = "C"; } } function openImagePreview(src, alt = "相册大图") { imagePreview.src = src; imagePreview.alt = alt; imageDialog.showModal(); } function createPhotoCard(photo) { const figure = document.createElement("figure"); figure.className = "photo-card uploaded-photo"; const previewButton = document.createElement("button"); previewButton.className = "photo-preview-button"; previewButton.type = "button"; previewButton.setAttribute("aria-label", "查看大图"); const image = document.createElement("img"); image.src = photo.src; image.alt = photo.name || "CICI 的照片"; previewButton.append(image); previewButton.addEventListener("click", () => openImagePreview(photo.src, photo.name || "相册大图")); const deleteButton = document.createElement("button"); deleteButton.className = "delete-button"; deleteButton.type = "button"; deleteButton.textContent = "删除"; deleteButton.addEventListener("click", async () => { await api(`/api/photos/${photo.id}`, { method: "DELETE" }); await loadGallery(); updateAuthUi(); }); figure.append(previewButton, deleteButton); return figure; } function renderSampleGallery(target) { target.innerHTML = `
我的酷酷自拍
甜酷手账
闪亮表演视频
`; } function renderEmptyFolder(target) { target.innerHTML = ""; const empty = document.createElement("div"); empty.className = "empty-gallery"; empty.textContent = "这个文件夹还没有照片。"; target.append(empty); } function renderPhotoGrid(target, photos, options = {}) { target.innerHTML = ""; const visiblePhotos = typeof options.limit === "number" ? photos.slice(0, options.limit) : photos; if (visiblePhotos.length === 0) { if (options.showSamples) { renderSampleGallery(target); } else { renderEmptyFolder(target); } return; } visiblePhotos.forEach((photo) => { target.append(createPhotoCard(photo)); }); } function renderFolders() { folderTabs.innerHTML = ""; uploadFolderSelect.innerHTML = ""; state.folders.forEach((folder) => { const tab = document.createElement("button"); tab.className = "folder-tab"; tab.type = "button"; tab.classList.toggle("active", folder.id === state.selectedFolderId); tab.textContent = `${folder.name} (${folder.photo_count})`; tab.addEventListener("click", () => { state.selectedFolderId = folder.id; renderFolders(); renderSelectedFolderPhotos(); }); if (state.authenticated && folder.id !== 1) { const renameButton = document.createElement("span"); renameButton.className = "folder-action"; renameButton.textContent = "重命名"; renameButton.addEventListener("click", async (event) => { event.stopPropagation(); const nextName = window.prompt("请输入新的文件夹名称", folder.name); if (!nextName || nextName.trim() === folder.name) { return; } await api(`/api/folders/${folder.id}`, { method: "PUT", body: JSON.stringify({ name: nextName.trim() }), }); await loadGallery(); }); const deleteButton = document.createElement("span"); deleteButton.className = "folder-action folder-delete"; deleteButton.textContent = "删除"; deleteButton.addEventListener("click", async (event) => { event.stopPropagation(); await api(`/api/folders/${folder.id}`, { method: "DELETE" }); state.selectedFolderId = 1; await loadGallery(); }); tab.append(renameButton); tab.append(deleteButton); } folderTabs.append(tab); const option = document.createElement("option"); option.value = folder.id; option.textContent = folder.name; option.selected = folder.id === state.selectedFolderId; uploadFolderSelect.append(option); }); } function renderSelectedFolderPhotos() { const photos = state.photos.filter((photo) => photo.folder_id === state.selectedFolderId); renderPhotoGrid(galleryGrid, photos); updateAuthUi(); } async function loadGallery() { const [foldersData, photosData] = await Promise.all([api("/api/folders"), api("/api/photos")]); state.folders = foldersData.folders; state.photos = photosData.photos; if (!state.folders.some((folder) => folder.id === state.selectedFolderId)) { state.selectedFolderId = state.folders[0]?.id || 1; } renderFolders(); renderSelectedFolderPhotos(); renderPhotoGrid(homeGalleryGrid, state.photos, { limit: 3, showSamples: true }); updateAuthUi(); } async function loadDiary() { const data = await api("/api/diary"); diaryList.innerHTML = ""; if (data.entries.length === 0) { const empty = document.createElement("div"); empty.className = "empty-diary"; empty.textContent = "还没有日记。"; diaryList.append(empty); return; } data.entries.forEach((entry) => { const item = document.createElement("article"); item.className = "diary-entry"; const time = document.createElement("time"); time.textContent = entry.created_at; const title = document.createElement("h3"); title.textContent = entry.title; const text = document.createElement("p"); text.textContent = entry.text; item.append(time, title); if (entry.image) { const imageButton = document.createElement("button"); imageButton.className = "diary-image-button"; imageButton.type = "button"; imageButton.setAttribute("aria-label", "查看日记图片"); const image = document.createElement("img"); image.src = entry.image; image.alt = entry.title || "日记图片"; imageButton.append(image); imageButton.addEventListener("click", () => openImagePreview(entry.image, entry.title || "日记图片")); item.append(imageButton); } if (entry.text) { item.append(text); } const deleteButton = document.createElement("button"); deleteButton.className = "delete-button"; deleteButton.type = "button"; deleteButton.textContent = "删除"; deleteButton.addEventListener("click", async () => { await api(`/api/diary/${entry.id}`, { method: "DELETE" }); await loadDiary(); updateAuthUi(); }); item.append(deleteButton); diaryList.append(item); }); updateAuthUi(); } profileForm.addEventListener("submit", async (event) => { event.preventDefault(); let avatar = null; if (avatarInput.files[0]) { avatar = await readImage(avatarInput.files[0], { maxSize: 360, quality: 0.72, maxBytes: 80000, }); } await api("/api/profile", { method: "PUT", body: JSON.stringify({ name: nameInput.value.trim() || "CICI", bio: bioInput.value.trim(), avatar, }), }); avatarInput.value = ""; await loadProfile(); }); folderForm.addEventListener("submit", async (event) => { event.preventDefault(); const name = folderNameInput.value.trim(); if (!name) { folderNameInput.focus(); return; } const folder = await api("/api/folders", { method: "POST", body: JSON.stringify({ name }), }); state.selectedFolderId = folder.id; folderForm.reset(); await loadGallery(); }); galleryForm.addEventListener("submit", async (event) => { event.preventDefault(); const files = Array.from(galleryInput.files).slice(0, 12); if (files.length === 0) { galleryInput.focus(); return; } galleryStatus.textContent = "正在自动缩小照片,请稍等一下..."; const photos = await Promise.all( files.map(async (file) => ({ name: file.name.replace(/\.[^.]+$/, ""), src: await readImage(file, { maxSize: 520, quality: 0.68, maxBytes: 90000, }), })), ); const folderId = Number(uploadFolderSelect.value) || state.selectedFolderId || 1; await api("/api/photos", { method: "POST", body: JSON.stringify({ folder_id: folderId, photos }), }); state.selectedFolderId = folderId; galleryInput.value = ""; galleryStatus.textContent = `已上传 ${photos.length} 张照片。`; await loadGallery(); }); diaryForm.addEventListener("submit", async (event) => { event.preventDefault(); const text = textInput.value.trim(); let image = ""; if (diaryImageInput.files[0]) { image = await readImage(diaryImageInput.files[0], { maxSize: 620, quality: 0.72, maxBytes: 120000, }); } if (!text && !image) { textInput.focus(); return; } await api("/api/diary", { method: "POST", body: JSON.stringify({ title: titleInput.value.trim() || "今天的小日记", text, image, }), }); diaryForm.reset(); await loadDiary(); }); loginOpenButton.addEventListener("click", () => { loginMessage.textContent = ""; passwordInput.value = ""; loginDialog.showModal(); passwordInput.focus(); }); loginCancelButton.addEventListener("click", () => { loginDialog.close(); }); loginForm.addEventListener("submit", async (event) => { event.preventDefault(); try { await api("/api/login", { method: "POST", body: JSON.stringify({ password: passwordInput.value }), }); loginDialog.close(); await loadSession(); await Promise.all([loadProfile(), loadGallery(), loadDiary()]); } catch (error) { loginMessage.textContent = error.message; } }); logoutButton.addEventListener("click", async () => { await api("/api/logout", { method: "POST", body: "{}" }); await loadSession(); await loadGallery(); }); imageCloseButton.addEventListener("click", () => { imageDialog.close(); }); imageDialog.addEventListener("click", (event) => { if (event.target === imageDialog) { imageDialog.close(); } }); window.addEventListener("hashchange", syncRoute); async function start() { syncRoute(); await loadSession(); await Promise.all([loadProfile(), loadGallery(), loadDiary()]); } start().catch((error) => { console.error(error); });