cici-web/script.js
2026-05-31 22:21:37 +08:00

539 lines
16 KiB
JavaScript

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 = "<span>C</span>";
}
}
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 = `
<figure class="photo-card"><div class="photo-illustration night"></div><figcaption>我的酷酷自拍</figcaption></figure>
<figure class="photo-card"><div class="photo-illustration doodle"></div><figcaption>甜酷手账</figcaption></figure>
<figure class="photo-card"><div class="photo-illustration stage"></div><figcaption>闪亮表演视频</figcaption></figure>
`;
}
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);
});