539 lines
16 KiB
JavaScript
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);
|
|
});
|