From 4fecab2868aa9acbbe30eb44e0ec8bc1fb887db3 Mon Sep 17 00:00:00 2001 From: kenken64 Date: Wed, 28 Jan 2026 09:59:18 +0800 Subject: [PATCH 01/11] feat: group gallery images by date with section headers Images are now visually organized under date-based sections: Today, Yesterday, This Week, This Month, and older month/year labels. Supports both English and Chinese locales with timezone-aware grouping. Co-Authored-By: Claude Opus 4.5 --- frontend/src/hooks/useLanguage.ts | 12 +++ frontend/src/pages/GalleryPage.tsx | 53 ++++++++--- frontend/src/utils/dateGrouping.ts | 147 +++++++++++++++++++++++++++++ frontend/src/utils/index.ts | 2 + 4 files changed, 200 insertions(+), 14 deletions(-) create mode 100644 frontend/src/utils/dateGrouping.ts diff --git a/frontend/src/hooks/useLanguage.ts b/frontend/src/hooks/useLanguage.ts index d5633b4..45cf78b 100644 --- a/frontend/src/hooks/useLanguage.ts +++ b/frontend/src/hooks/useLanguage.ts @@ -347,6 +347,12 @@ export const translations = { size: "Size", style: "Style", created: "Created", + today: "Today", + yesterday: "Yesterday", + thisWeek: "This Week", + thisMonth: "This Month", + imageCount: "{count} images", + imageCountSingular: "{count} image", }, schedule: { title: "Schedule", @@ -765,6 +771,12 @@ export const translations = { size: "尺寸", style: "风格", created: "创建时间", + today: "今天", + yesterday: "昨天", + thisWeek: "本周", + thisMonth: "本月", + imageCount: "{count} 张图片", + imageCountSingular: "{count} 张图片", }, schedule: { title: "定时任务", diff --git a/frontend/src/pages/GalleryPage.tsx b/frontend/src/pages/GalleryPage.tsx index e90a1b4..cac3754 100644 --- a/frontend/src/pages/GalleryPage.tsx +++ b/frontend/src/pages/GalleryPage.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback, useRef, useEffect } from "react" +import { useState, useCallback, useRef, useEffect, useMemo } from "react" import { Button } from "../components/ui/button" import { Input } from "../components/ui/input" import { GalleryCard } from "../components/GalleryCard" @@ -8,7 +8,7 @@ import { useGallery, GalleryImage } from "../hooks/useGallery" import { useLanguage } from "../hooks/useLanguage" import { useAuth } from "../hooks/useAuth" import { RefreshCw, Upload } from "lucide-react" -import { detectBrowserTimezone } from "../utils" +import { detectBrowserTimezone, groupImagesByDate } from "../utils" type AppPage = "chat" | "gallery" | "schedule" | "batch" | "orders" | "subscription" | "settings" @@ -18,7 +18,7 @@ interface GalleryPageProps { } export function GalleryPage({ onNavigate, onLogout }: GalleryPageProps) { - const { t } = useLanguage() + const { t, language } = useLanguage() const { authFetch } = useAuth() const { images, @@ -65,6 +65,16 @@ export function GalleryPage({ onNavigate, onLogout }: GalleryPageProps) { fetchTimezone() }, [authFetch]) + const dateGroups = useMemo(() => { + const locale = language === "zh" ? "zh-CN" : "en-US" + return groupImagesByDate(images, userTimezone, locale, { + today: t.gallery?.today || "Today", + yesterday: t.gallery?.yesterday || "Yesterday", + thisWeek: t.gallery?.thisWeek || "This Week", + thisMonth: t.gallery?.thisMonth || "This Month", + }) + }, [images, userTimezone, language, t.gallery?.today, t.gallery?.yesterday, t.gallery?.thisWeek, t.gallery?.thisMonth]) + const handleSelectImage = useCallback((image: GalleryImage) => { setSelectedImage(image) setIsModalOpen(true) @@ -390,19 +400,34 @@ export function GalleryPage({ onNavigate, onLogout }: GalleryPageProps) { )} - {/* Image grid */} + {/* Image grid - grouped by date */} {images.length > 0 && ( <> -
- {images.map((image) => ( - +
+ {dateGroups.map((group) => ( +
+
+

{group.label}

+ + ({group.images.length === 1 + ? (t.gallery?.imageCountSingular || "{count} image").replace("{count}", "1") + : (t.gallery?.imageCount || "{count} images").replace("{count}", String(group.images.length)) + }) + +
+
+ {group.images.map((image) => ( + + ))} +
+
))}
diff --git a/frontend/src/utils/dateGrouping.ts b/frontend/src/utils/dateGrouping.ts new file mode 100644 index 0000000..ba19d44 --- /dev/null +++ b/frontend/src/utils/dateGrouping.ts @@ -0,0 +1,147 @@ +import type { GalleryImage } from "../hooks/useGallery" + +export interface DateGroup { + label: string + labelKey: string + images: GalleryImage[] +} + +/** + * Get the start of a day in a specific timezone. + * Returns a Date object representing midnight of that day in the given timezone. + */ +function getLocalDate(date: Date, timezone: string): { year: number; month: number; day: number } { + const parts = new Intl.DateTimeFormat("en-US", { + timeZone: timezone, + year: "numeric", + month: "numeric", + day: "numeric", + }).formatToParts(date) + + const year = Number(parts.find(p => p.type === "year")!.value) + const month = Number(parts.find(p => p.type === "month")!.value) + const day = Number(parts.find(p => p.type === "day")!.value) + + return { year, month, day } +} + +/** + * Get the ISO weekday (1=Monday, 7=Sunday) for a date in a specific timezone. + */ +function getISOWeekday(date: Date, timezone: string): number { + const weekday = new Intl.DateTimeFormat("en-US", { + timeZone: timezone, + weekday: "short", + }).format(date) + + const map: Record = { + Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6, Sun: 7, + } + return map[weekday] || 1 +} + +/** + * Determine which date bucket an image belongs to. + * Returns a labelKey like "today", "yesterday", "thisWeek", "thisMonth", or "2025-01" for older months. + */ +function getDateBucket( + imageDate: Date, + now: Date, + timezone: string, +): string { + const imgLocal = getLocalDate(imageDate, timezone) + const nowLocal = getLocalDate(now, timezone) + + // Same day + if (imgLocal.year === nowLocal.year && imgLocal.month === nowLocal.month && imgLocal.day === nowLocal.day) { + return "today" + } + + // Yesterday: create a date for yesterday in the timezone + const yesterdayMs = now.getTime() - 86400000 + const yesterdayDate = new Date(yesterdayMs) + const yLocal = getLocalDate(yesterdayDate, timezone) + if (imgLocal.year === yLocal.year && imgLocal.month === yLocal.month && imgLocal.day === yLocal.day) { + return "yesterday" + } + + // This week (ISO week: Monday-Sunday) + // Find the start of this week (Monday) in the user's timezone + const todayWeekday = getISOWeekday(now, timezone) + const daysSinceMonday = todayWeekday - 1 + const weekStartMs = now.getTime() - daysSinceMonday * 86400000 + const weekStartLocal = getLocalDate(new Date(weekStartMs), timezone) + + // Check if image is in the same week (on or after Monday of this week) + // We compare by checking if the image date >= week start date + const imgDateNum = imgLocal.year * 10000 + imgLocal.month * 100 + imgLocal.day + const weekStartNum = weekStartLocal.year * 10000 + weekStartLocal.month * 100 + weekStartLocal.day + const nowDateNum = nowLocal.year * 10000 + nowLocal.month * 100 + nowLocal.day + + if (imgDateNum >= weekStartNum && imgDateNum <= nowDateNum) { + return "thisWeek" + } + + // This month + if (imgLocal.year === nowLocal.year && imgLocal.month === nowLocal.month) { + return "thisMonth" + } + + // Older: group by year-month + return `${imgLocal.year}-${String(imgLocal.month).padStart(2, "0")}` +} + +/** + * Format an older month key like "2025-01" into a localized label like "January 2025". + */ +function formatMonthLabel(key: string, locale: string): string { + const [year, month] = key.split("-").map(Number) + const date = new Date(year, month - 1, 1) + return new Intl.DateTimeFormat(locale, { month: "long", year: "numeric" }).format(date) +} + +/** + * Group gallery images by date relative to now, using the user's timezone. + * Images should already be sorted newest-first (by created_at DESC). + */ +export function groupImagesByDate( + images: GalleryImage[], + timezone: string, + locale: string = "en", + labels: { today: string; yesterday: string; thisWeek: string; thisMonth: string } = { + today: "Today", + yesterday: "Yesterday", + thisWeek: "This Week", + thisMonth: "This Month", + }, +): DateGroup[] { + if (images.length === 0) return [] + + const now = new Date() + const groupMap = new Map() + const groupOrder: string[] = [] + + for (const image of images) { + const imageDate = new Date(image.createdAt) + const bucket = getDateBucket(imageDate, now, timezone) + + if (!groupMap.has(bucket)) { + groupMap.set(bucket, []) + groupOrder.push(bucket) + } + groupMap.get(bucket)!.push(image) + } + + const labelMap: Record = { + today: labels.today, + yesterday: labels.yesterday, + thisWeek: labels.thisWeek, + thisMonth: labels.thisMonth, + } + + return groupOrder.map(key => ({ + labelKey: key, + label: labelMap[key] || formatMonthLabel(key, locale), + images: groupMap.get(key)!, + })) +} diff --git a/frontend/src/utils/index.ts b/frontend/src/utils/index.ts index dbfa334..aae9af9 100644 --- a/frontend/src/utils/index.ts +++ b/frontend/src/utils/index.ts @@ -1,2 +1,4 @@ // Export all utility functions export { TIMEZONE_OPTIONS, detectBrowserTimezone, getTimezoneLabel, getTimezoneOffset, formatDateInTimezone, formatDateTimeInTimezone } from "./timezone" +export { groupImagesByDate } from "./dateGrouping" +export type { DateGroup } from "./dateGrouping" From bc0bd65bf782930a01eaa9213a4ad6cf520213d3 Mon Sep 17 00:00:00 2001 From: kenken64 Date: Wed, 28 Jan 2026 10:43:19 +0800 Subject: [PATCH 02/11] feat: add collections for gallery images and improve registration errors Add custom collections to organize gallery images: - Backend: collections + collection_images tables, CRUD API routes, collection filter on gallery endpoint - Frontend: useCollections hook, CollectionPicker modal, CollectionManager modal, gallery page collection filter dropdown - Integration in GalleryCard and ImageDetailModal action buttons - en/zh translations for all collection strings Improve registration error handling: - Backend returns distinct error code for duplicate email - Frontend maps backend errors to translated user-friendly messages - Add diagnostic logging to register route and registerUser service Co-Authored-By: Claude Opus 4.5 --- backend/src/db/index.ts | 141 +++++++++ backend/src/routes/auth.ts | 2 + backend/src/routes/collections.ts | 278 ++++++++++++++++++ backend/src/routes/gallery.ts | 14 +- backend/src/routes/index.ts | 2 + backend/src/services/auth-service.ts | 8 +- frontend/src/components/CollectionManager.tsx | 245 +++++++++++++++ frontend/src/components/CollectionPicker.tsx | 198 +++++++++++++ frontend/src/components/GalleryCard.tsx | 25 ++ frontend/src/components/ImageDetailModal.tsx | 23 ++ frontend/src/components/index.ts | 2 + frontend/src/hooks/index.ts | 2 + frontend/src/hooks/useCollections.ts | 220 ++++++++++++++ frontend/src/hooks/useGallery.ts | 11 +- frontend/src/hooks/useLanguage.ts | 52 ++++ frontend/src/pages/GalleryPage.tsx | 90 +++++- frontend/src/pages/RegisterPage.tsx | 14 +- 17 files changed, 1314 insertions(+), 13 deletions(-) create mode 100644 backend/src/routes/collections.ts create mode 100644 frontend/src/components/CollectionManager.tsx create mode 100644 frontend/src/components/CollectionPicker.tsx create mode 100644 frontend/src/hooks/useCollections.ts diff --git a/backend/src/db/index.ts b/backend/src/db/index.ts index 1b6d8d8..13e8f26 100644 --- a/backend/src/db/index.ts +++ b/backend/src/db/index.ts @@ -209,6 +209,25 @@ export interface UserDevice { updated_at: string } +// Collection type +export interface Collection { + id: number + user_id: number + name: string + description: string | null + cover_image_id: number | null + created_at: string + updated_at: string +} + +// Collection image join type +export interface CollectionImage { + id: number + collection_id: number + image_id: number + added_at: string +} + // Password reset token type export interface PasswordResetToken { id: number @@ -608,6 +627,39 @@ export function initDatabase() { db.run(`CREATE INDEX IF NOT EXISTS idx_user_devices_webhook_uuid ON user_devices(webhook_uuid)`) db.run(`CREATE INDEX IF NOT EXISTS idx_user_devices_is_default ON user_devices(is_default)`) + // Collections table + db.run(` + CREATE TABLE IF NOT EXISTS collections ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + name TEXT NOT NULL, + description TEXT, + cover_image_id INTEGER, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (cover_image_id) REFERENCES generated_images(id) ON DELETE SET NULL + ) + `) + + db.run(`CREATE INDEX IF NOT EXISTS idx_collections_user_id ON collections(user_id)`) + + // Collection images join table + db.run(` + CREATE TABLE IF NOT EXISTS collection_images ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + collection_id INTEGER NOT NULL, + image_id INTEGER NOT NULL, + added_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (collection_id) REFERENCES collections(id) ON DELETE CASCADE, + FOREIGN KEY (image_id) REFERENCES generated_images(id) ON DELETE CASCADE, + UNIQUE(collection_id, image_id) + ) + `) + + db.run(`CREATE INDEX IF NOT EXISTS idx_collection_images_collection_id ON collection_images(collection_id)`) + db.run(`CREATE INDEX IF NOT EXISTS idx_collection_images_image_id ON collection_images(image_id)`) + // Initialize prepared statements after tables are created initPreparedStatements() @@ -746,6 +798,24 @@ let _refreshTokenQueries: { deleteExpired: Statement } +let _collectionQueries: { + findById: Statement + findByIdAndUserId: Statement + findAllByUserId: Statement + countByUserId: Statement<{ count: number }, [number]> + create: Statement + update: Statement + delete: Statement +} + +let _collectionImageQueries: { + addImage: Statement + removeImage: Statement + findImagesByCollectionId: Statement + countImagesByCollectionId: Statement<{ count: number }, [number]> + findCollectionsForImage: Statement<{ collection_id: number }, [number, number]> +} + let _userDeviceQueries: { findById: Statement findByIdAndUserId: Statement @@ -1077,6 +1147,59 @@ function initPreparedStatements() { ), } + _collectionQueries = { + findById: db.prepare( + "SELECT * FROM collections WHERE id = ?" + ), + findByIdAndUserId: db.prepare( + "SELECT * FROM collections WHERE id = ? AND user_id = ?" + ), + findAllByUserId: db.prepare( + `SELECT c.*, COALESCE(ci.cnt, 0) as image_count + FROM collections c + LEFT JOIN (SELECT collection_id, COUNT(*) as cnt FROM collection_images GROUP BY collection_id) ci + ON ci.collection_id = c.id + WHERE c.user_id = ? + ORDER BY c.updated_at DESC` + ), + countByUserId: db.prepare<{ count: number }, [number]>( + "SELECT COUNT(*) as count FROM collections WHERE user_id = ?" + ), + create: db.prepare( + "INSERT INTO collections (user_id, name, description) VALUES (?, ?, ?) RETURNING *" + ), + update: db.prepare( + "UPDATE collections SET name = ?, description = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND user_id = ?" + ), + delete: db.prepare( + "DELETE FROM collections WHERE id = ? AND user_id = ?" + ), + } + + _collectionImageQueries = { + addImage: db.prepare( + "INSERT OR IGNORE INTO collection_images (collection_id, image_id) VALUES (?, ?) RETURNING *" + ), + removeImage: db.prepare( + "DELETE FROM collection_images WHERE collection_id = ? AND image_id = ?" + ), + findImagesByCollectionId: db.prepare( + `SELECT gi.* FROM generated_images gi + INNER JOIN collection_images ci ON ci.image_id = gi.id + WHERE ci.collection_id = ? AND gi.is_deleted = 0 + ORDER BY ci.added_at DESC + LIMIT ? OFFSET ?` + ), + countImagesByCollectionId: db.prepare<{ count: number }, [number]>( + `SELECT COUNT(*) as count FROM collection_images ci + INNER JOIN generated_images gi ON gi.id = ci.image_id + WHERE ci.collection_id = ? AND gi.is_deleted = 0` + ), + findCollectionsForImage: db.prepare<{ collection_id: number }, [number, number]>( + "SELECT collection_id FROM collection_images WHERE image_id = ? AND collection_id IN (SELECT id FROM collections WHERE user_id = ?)" + ), + } + _userDeviceQueries = { findById: db.prepare( "SELECT * FROM user_devices WHERE id = ?" @@ -1236,6 +1359,24 @@ export const refreshTokenQueries = { get deleteExpired() { return _refreshTokenQueries.deleteExpired }, } +export const collectionQueries = { + get findById() { return _collectionQueries.findById }, + get findByIdAndUserId() { return _collectionQueries.findByIdAndUserId }, + get findAllByUserId() { return _collectionQueries.findAllByUserId }, + get countByUserId() { return _collectionQueries.countByUserId }, + get create() { return _collectionQueries.create }, + get update() { return _collectionQueries.update }, + get delete() { return _collectionQueries.delete }, +} + +export const collectionImageQueries = { + get addImage() { return _collectionImageQueries.addImage }, + get removeImage() { return _collectionImageQueries.removeImage }, + get findImagesByCollectionId() { return _collectionImageQueries.findImagesByCollectionId }, + get countImagesByCollectionId() { return _collectionImageQueries.countImagesByCollectionId }, + get findCollectionsForImage() { return _collectionImageQueries.findCollectionsForImage }, +} + export const userDeviceQueries = { get findById() { return _userDeviceQueries.findById }, get findByIdAndUserId() { return _userDeviceQueries.findByIdAndUserId }, diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 96d045d..8d872b7 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -23,6 +23,7 @@ export const authRoutes = { const { email, password, name } = text ? JSON.parse(text) : {} if (!email || !password) { + log("WARN", "Registration missing credentials", { email: email || "(empty)", hasPassword: !!password }) return Response.json( { error: "Email and password are required" }, { status: 400 } @@ -33,6 +34,7 @@ export const authRoutes = { const result = await registerUser(email, password, name, ipAddress, userAgent) if ("error" in result) { + log("WARN", "Registration rejected", { email, error: result.error }) return Response.json({ error: result.error }, { status: 400 }) } diff --git a/backend/src/routes/collections.ts b/backend/src/routes/collections.ts new file mode 100644 index 0000000..9f28464 --- /dev/null +++ b/backend/src/routes/collections.ts @@ -0,0 +1,278 @@ +import { log } from "../utils" +import { withAuth } from "../middleware/auth" +import { collectionQueries, collectionImageQueries, generatedImageQueries } from "../db" +import { getGalleryThumbnailUrl } from "./gallery" + +export const collectionRoutes = { + // List user's collections + "/api/collections": { + GET: withAuth(async (req, user) => { + try { + const collections = collectionQueries.findAllByUserId.all(user.id) + + return Response.json({ + collections: collections.map((c) => ({ + id: c.id, + name: c.name, + description: c.description, + imageCount: c.image_count, + coverThumbnailUrl: c.cover_image_id + ? getGalleryThumbnailUrl(c.cover_image_id) + : null, + createdAt: c.created_at, + updatedAt: c.updated_at, + })), + }) + } catch (error) { + log("ERROR", "Failed to list collections", error) + return Response.json({ error: String(error) }, { status: 500 }) + } + }), + POST: withAuth(async (req, user) => { + try { + const body = await req.json() as { name?: string; description?: string } + const { name, description } = body + + if (!name || typeof name !== "string" || !name.trim()) { + return Response.json({ error: "Collection name is required" }, { status: 400 }) + } + + const collection = collectionQueries.create.get(user.id, name.trim(), description?.trim() || null) + + if (!collection) { + return Response.json({ error: "Failed to create collection" }, { status: 500 }) + } + + log("INFO", "Collection created", { collectionId: collection.id, userId: user.id }) + + return Response.json({ + collection: { + id: collection.id, + name: collection.name, + description: collection.description, + imageCount: 0, + coverThumbnailUrl: null, + createdAt: collection.created_at, + updatedAt: collection.updated_at, + }, + }) + } catch (error) { + log("ERROR", "Failed to create collection", error) + return Response.json({ error: String(error) }, { status: 500 }) + } + }), + }, + + // Get, update, or delete a collection + "/api/collections/:id": { + GET: withAuth(async (req, user) => { + try { + const collectionId = parseInt((req as any).params?.id, 10) + if (isNaN(collectionId)) { + return Response.json({ error: "Invalid collection ID" }, { status: 400 }) + } + + const collection = collectionQueries.findByIdAndUserId.get(collectionId, user.id) + if (!collection) { + return Response.json({ error: "Collection not found" }, { status: 404 }) + } + + const imageCount = collectionImageQueries.countImagesByCollectionId.get(collectionId)?.count || 0 + + return Response.json({ + collection: { + id: collection.id, + name: collection.name, + description: collection.description, + imageCount, + coverThumbnailUrl: collection.cover_image_id + ? getGalleryThumbnailUrl(collection.cover_image_id) + : null, + createdAt: collection.created_at, + updatedAt: collection.updated_at, + }, + }) + } catch (error) { + log("ERROR", "Failed to get collection", error) + return Response.json({ error: String(error) }, { status: 500 }) + } + }), + PATCH: withAuth(async (req, user) => { + try { + const collectionId = parseInt((req as any).params?.id, 10) + if (isNaN(collectionId)) { + return Response.json({ error: "Invalid collection ID" }, { status: 400 }) + } + + const existing = collectionQueries.findByIdAndUserId.get(collectionId, user.id) + if (!existing) { + return Response.json({ error: "Collection not found" }, { status: 404 }) + } + + const body = await req.json() as { name?: string; description?: string } + const name = body.name !== undefined ? body.name?.trim() : existing.name + const description = body.description !== undefined ? body.description?.trim() || null : existing.description + + if (!name) { + return Response.json({ error: "Collection name is required" }, { status: 400 }) + } + + collectionQueries.update.run(name, description, collectionId, user.id) + + log("INFO", "Collection updated", { collectionId, userId: user.id }) + + return Response.json({ success: true }) + } catch (error) { + log("ERROR", "Failed to update collection", error) + return Response.json({ error: String(error) }, { status: 500 }) + } + }), + DELETE: withAuth(async (req, user) => { + try { + const collectionId = parseInt((req as any).params?.id, 10) + if (isNaN(collectionId)) { + return Response.json({ error: "Invalid collection ID" }, { status: 400 }) + } + + const existing = collectionQueries.findByIdAndUserId.get(collectionId, user.id) + if (!existing) { + return Response.json({ error: "Collection not found" }, { status: 404 }) + } + + collectionQueries.delete.run(collectionId, user.id) + + log("INFO", "Collection deleted", { collectionId, userId: user.id }) + + return Response.json({ success: true }) + } catch (error) { + log("ERROR", "Failed to delete collection", error) + return Response.json({ error: String(error) }, { status: 500 }) + } + }), + }, + + // List images in a collection + "/api/collections/:id/images": { + GET: withAuth(async (req, user) => { + try { + const collectionId = parseInt((req as any).params?.id, 10) + if (isNaN(collectionId)) { + return Response.json({ error: "Invalid collection ID" }, { status: 400 }) + } + + const collection = collectionQueries.findByIdAndUserId.get(collectionId, user.id) + if (!collection) { + return Response.json({ error: "Collection not found" }, { status: 404 }) + } + + const url = new URL(req.url) + const page = parseInt(url.searchParams.get("page") || "1", 10) + const limit = Math.min(parseInt(url.searchParams.get("limit") || "20", 10), 50) + const offset = (page - 1) * limit + + const images = collectionImageQueries.findImagesByCollectionId.all(collectionId, limit, offset) + const totalCount = collectionImageQueries.countImagesByCollectionId.get(collectionId)?.count || 0 + + return Response.json({ + images, + pagination: { + page, + limit, + total: totalCount, + totalPages: Math.ceil(totalCount / limit), + hasMore: offset + images.length < totalCount, + }, + }) + } catch (error) { + log("ERROR", "Failed to list collection images", error) + return Response.json({ error: String(error) }, { status: 500 }) + } + }), + POST: withAuth(async (req, user) => { + try { + const collectionId = parseInt((req as any).params?.id, 10) + if (isNaN(collectionId)) { + return Response.json({ error: "Invalid collection ID" }, { status: 400 }) + } + + const collection = collectionQueries.findByIdAndUserId.get(collectionId, user.id) + if (!collection) { + return Response.json({ error: "Collection not found" }, { status: 404 }) + } + + const body = await req.json() as { imageId?: number } + const { imageId } = body + + if (!imageId || typeof imageId !== "number") { + return Response.json({ error: "imageId is required" }, { status: 400 }) + } + + // Verify the image belongs to the user + const image = generatedImageQueries.findByIdAndUserId.get(imageId, user.id) + if (!image) { + return Response.json({ error: "Image not found" }, { status: 404 }) + } + + collectionImageQueries.addImage.get(collectionId, imageId) + + // Update collection's updated_at + collectionQueries.update.run(collection.name, collection.description, collectionId, user.id) + + log("INFO", "Image added to collection", { collectionId, imageId, userId: user.id }) + + return Response.json({ success: true }) + } catch (error) { + log("ERROR", "Failed to add image to collection", error) + return Response.json({ error: String(error) }, { status: 500 }) + } + }), + }, + + // Remove image from collection + "/api/collections/:id/images/:imageId": { + DELETE: withAuth(async (req, user) => { + try { + const collectionId = parseInt((req as any).params?.id, 10) + const imageId = parseInt((req as any).params?.imageId, 10) + + if (isNaN(collectionId) || isNaN(imageId)) { + return Response.json({ error: "Invalid IDs" }, { status: 400 }) + } + + const collection = collectionQueries.findByIdAndUserId.get(collectionId, user.id) + if (!collection) { + return Response.json({ error: "Collection not found" }, { status: 404 }) + } + + collectionImageQueries.removeImage.run(collectionId, imageId) + + log("INFO", "Image removed from collection", { collectionId, imageId, userId: user.id }) + + return Response.json({ success: true }) + } catch (error) { + log("ERROR", "Failed to remove image from collection", error) + return Response.json({ error: String(error) }, { status: 500 }) + } + }), + }, + + // Get collections containing a specific image + "/api/collections/for-image/:imageId": { + GET: withAuth(async (req, user) => { + try { + const imageId = parseInt((req as any).params?.imageId, 10) + if (isNaN(imageId)) { + return Response.json({ error: "Invalid image ID" }, { status: 400 }) + } + + const results = collectionImageQueries.findCollectionsForImage.all(imageId, user.id) + const collectionIds = results.map((r) => r.collection_id) + + return Response.json({ collectionIds }) + } catch (error) { + log("ERROR", "Failed to get collections for image", error) + return Response.json({ error: String(error) }, { status: 500 }) + } + }), + }, +} diff --git a/backend/src/routes/gallery.ts b/backend/src/routes/gallery.ts index 113021c..9480e1f 100644 --- a/backend/src/routes/gallery.ts +++ b/backend/src/routes/gallery.ts @@ -1,6 +1,6 @@ import { log, toISODate } from "../utils" import { withAuth } from "../middleware/auth" -import { generatedImageQueries, type GeneratedImage } from "../db" +import { generatedImageQueries, collectionImageQueries, collectionQueries, type GeneratedImage } from "../db" import { config } from "../config" import { mkdirSync, existsSync } from "fs" import { join } from "path" @@ -105,12 +105,22 @@ export const galleryRoutes = { const limit = Math.min(parseInt(url.searchParams.get("limit") || "20", 10), 50) const favorites = url.searchParams.get("favorites") === "true" const search = url.searchParams.get("search") || "" + const collectionParam = url.searchParams.get("collection") + const collectionId = collectionParam ? parseInt(collectionParam, 10) : null const offset = (page - 1) * limit let images: GeneratedImage[] let totalCount: number - if (search) { + if (collectionId && !isNaN(collectionId)) { + // Verify collection belongs to user + const collection = collectionQueries.findByIdAndUserId.get(collectionId, user.id) + if (!collection) { + return Response.json({ error: "Collection not found" }, { status: 404 }) + } + images = collectionImageQueries.findImagesByCollectionId.all(collectionId, limit, offset) + totalCount = collectionImageQueries.countImagesByCollectionId.get(collectionId)?.count || 0 + } else if (search) { const searchPattern = `%${search}%` images = generatedImageQueries.search.all(user.id, searchPattern, searchPattern, limit, offset) // For search, we'd need a separate count query - for now estimate diff --git a/backend/src/routes/index.ts b/backend/src/routes/index.ts index 603b946..b6d756c 100644 --- a/backend/src/routes/index.ts +++ b/backend/src/routes/index.ts @@ -17,6 +17,7 @@ import { batchRoutes } from "./batch" import { speechRoutes } from "./speech" import { adminRoutes } from "./admin" import { promptRoutes } from "./prompt" +import { collectionRoutes } from "./collections" import { db } from "../db" import { config } from "../config" import { existsSync, readFileSync, readdirSync, statSync } from "fs" @@ -344,6 +345,7 @@ const allRoutes = { ...speechRoutes, ...adminRoutes, ...promptRoutes, + ...collectionRoutes, } // Export routes without rate limiting (rate limiting disabled) diff --git a/backend/src/services/auth-service.ts b/backend/src/services/auth-service.ts index e80fd32..6f7daf1 100644 --- a/backend/src/services/auth-service.ts +++ b/backend/src/services/auth-service.ts @@ -420,20 +420,25 @@ export async function registerUser( userAgent?: string ): Promise<{ user: AuthUser; tokens: TokenPair } | { error: string }> { try { + log("INFO", "Registration attempt", { email: email.toLowerCase(), hasName: !!name, ipAddress }) + // Check if user already exists const existingUser = userQueries.findByEmail.get(email.toLowerCase()) if (existingUser) { - return { error: "Registration failed. Please try again." } // Generic error to prevent email enumeration + log("WARN", "Registration blocked: email already exists", { email: email.toLowerCase() }) + return { error: "EMAIL_ALREADY_EXISTS" } } // Validate email format if (!isValidEmail(email)) { + log("WARN", "Registration blocked: invalid email format", { email }) return { error: "Invalid email format" } } // Validate password strength const passwordValidation = validatePasswordStrength(password) if (!passwordValidation.valid) { + log("WARN", "Registration blocked: weak password", { email: email.toLowerCase(), reason: passwordValidation.error }) return { error: passwordValidation.error! } } @@ -443,6 +448,7 @@ export async function registerUser( // Create user const user = userQueries.create.get(email.toLowerCase(), passwordHash, name || null) if (!user) { + log("ERROR", "Registration blocked: user creation returned null", { email: email.toLowerCase() }) return { error: "Failed to create user" } } diff --git a/frontend/src/components/CollectionManager.tsx b/frontend/src/components/CollectionManager.tsx new file mode 100644 index 0000000..615f600 --- /dev/null +++ b/frontend/src/components/CollectionManager.tsx @@ -0,0 +1,245 @@ +import { useState, useEffect, useRef } from "react" +import { Button } from "./ui/button" +import { Input } from "./ui/input" +import { useCollections } from "../hooks/useCollections" +import { useLanguage } from "../hooks/useLanguage" + +interface CollectionManagerProps { + isOpen: boolean + onClose: () => void +} + +export function CollectionManager({ isOpen, onClose }: CollectionManagerProps) { + const { t } = useLanguage() + const { + collections, + isLoading, + createCollection, + updateCollection, + deleteCollection, + refresh, + } = useCollections() + + const [editingId, setEditingId] = useState(null) + const [editName, setEditName] = useState("") + const [newName, setNewName] = useState("") + const [isCreating, setIsCreating] = useState(false) + const [deletingId, setDeletingId] = useState(null) + const modalRef = useRef(null) + + // Refresh collections when modal opens + useEffect(() => { + if (isOpen) { + refresh() + } + }, [isOpen]) // eslint-disable-line react-hooks/exhaustive-deps + + // Close on outside click + useEffect(() => { + if (!isOpen) return + const handleClickOutside = (e: MouseEvent) => { + if (modalRef.current && !modalRef.current.contains(e.target as Node)) { + onClose() + } + } + document.addEventListener("mousedown", handleClickOutside) + return () => document.removeEventListener("mousedown", handleClickOutside) + }, [isOpen, onClose]) + + // Close on Escape + useEffect(() => { + if (!isOpen) return + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + if (editingId) { + setEditingId(null) + } else { + onClose() + } + } + } + document.addEventListener("keydown", handleKeyDown) + return () => document.removeEventListener("keydown", handleKeyDown) + }, [isOpen, onClose, editingId]) + + if (!isOpen) return null + + const handleStartEdit = (id: number, name: string) => { + setEditingId(id) + setEditName(name) + } + + const handleSaveEdit = async () => { + if (!editingId || !editName.trim()) return + try { + await updateCollection(editingId, editName.trim()) + setEditingId(null) + } catch { + // Error logged in hook + } + } + + const handleDelete = async (id: number) => { + if (!confirm(t.collections.deleteConfirm)) return + setDeletingId(id) + try { + await deleteCollection(id) + } catch { + // Error logged in hook + } finally { + setDeletingId(null) + } + } + + const handleCreate = async () => { + if (!newName.trim()) return + setIsCreating(true) + try { + await createCollection(newName.trim()) + setNewName("") + } catch { + // Error logged in hook + } finally { + setIsCreating(false) + } + } + + return ( +
+
+
+ {/* Header */} +
+

{t.collections.manageCollections}

+ +
+ + {/* Collection list */} +
+ {isLoading ? ( +
+ + + + +
+ ) : collections.length === 0 ? ( +
+

{t.collections.noCollections}

+

{t.collections.noCollectionsHint}

+
+ ) : ( + collections.map((collection) => ( +
+ {editingId === collection.id ? ( +
+ setEditName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleSaveEdit() + if (e.key === "Escape") setEditingId(null) + }} + className="text-sm" + autoFocus + /> + + +
+ ) : ( + <> + {/* Collection icon */} + + + +
+

{collection.name}

+

+ {collection.imageCount === 1 + ? t.collections.imageCountSingular.replace("{count}", "1") + : t.collections.imageCount.replace("{count}", String(collection.imageCount))} +

+
+
+ + +
+ + )} +
+ )) + )} +
+ + {/* Create new collection */} +
+
+ setNewName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleCreate() + }} + className="text-sm" + /> + +
+
+
+
+ ) +} diff --git a/frontend/src/components/CollectionPicker.tsx b/frontend/src/components/CollectionPicker.tsx new file mode 100644 index 0000000..fc92e94 --- /dev/null +++ b/frontend/src/components/CollectionPicker.tsx @@ -0,0 +1,198 @@ +import { useState, useEffect, useRef } from "react" +import { Button } from "./ui/button" +import { Input } from "./ui/input" +import { useCollections, type Collection } from "../hooks/useCollections" +import { useLanguage } from "../hooks/useLanguage" + +interface CollectionPickerProps { + imageId: number + isOpen: boolean + onClose: () => void +} + +export function CollectionPicker({ imageId, isOpen, onClose }: CollectionPickerProps) { + const { t } = useLanguage() + const { + collections, + createCollection, + addImageToCollection, + removeImageFromCollection, + getCollectionsForImage, + } = useCollections() + + const [imageCollectionIds, setImageCollectionIds] = useState([]) + const [isLoadingIds, setIsLoadingIds] = useState(false) + const [isCreating, setIsCreating] = useState(false) + const [newName, setNewName] = useState("") + const [togglingId, setTogglingId] = useState(null) + const modalRef = useRef(null) + + // Fetch which collections this image belongs to + useEffect(() => { + if (isOpen && imageId) { + setIsLoadingIds(true) + getCollectionsForImage(imageId).then((ids) => { + setImageCollectionIds(ids) + setIsLoadingIds(false) + }) + } + }, [isOpen, imageId, getCollectionsForImage]) + + // Close on outside click + useEffect(() => { + if (!isOpen) return + const handleClickOutside = (e: MouseEvent) => { + if (modalRef.current && !modalRef.current.contains(e.target as Node)) { + onClose() + } + } + document.addEventListener("mousedown", handleClickOutside) + return () => document.removeEventListener("mousedown", handleClickOutside) + }, [isOpen, onClose]) + + // Close on Escape + useEffect(() => { + if (!isOpen) return + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose() + } + document.addEventListener("keydown", handleKeyDown) + return () => document.removeEventListener("keydown", handleKeyDown) + }, [isOpen, onClose]) + + if (!isOpen) return null + + const handleToggle = async (collection: Collection) => { + if (togglingId) return + setTogglingId(collection.id) + + try { + const isInCollection = imageCollectionIds.includes(collection.id) + if (isInCollection) { + await removeImageFromCollection(collection.id, imageId) + setImageCollectionIds((prev) => prev.filter((id) => id !== collection.id)) + } else { + await addImageToCollection(collection.id, imageId) + setImageCollectionIds((prev) => [...prev, collection.id]) + } + } catch { + // Error already logged in hook + } finally { + setTogglingId(null) + } + } + + const handleCreate = async () => { + if (!newName.trim()) return + setIsCreating(true) + try { + const created = await createCollection(newName.trim()) + if (created) { + // Auto-add the image to the newly created collection + await addImageToCollection(created.id, imageId) + setImageCollectionIds((prev) => [...prev, created.id]) + } + setNewName("") + } catch { + // Error already logged + } finally { + setIsCreating(false) + } + } + + return ( +
+
+
+ {/* Header */} +
+

{t.collections.addToCollection}

+ +
+ + {/* Collection list */} +
+ {isLoadingIds ? ( +
+ + + + +
+ ) : collections.length === 0 ? ( +
+

{t.collections.noCollections}

+

{t.collections.noCollectionsHint}

+
+ ) : ( + collections.map((collection) => { + const isInCollection = imageCollectionIds.includes(collection.id) + const isToggling = togglingId === collection.id + return ( + + ) + }) + )} +
+ + {/* Create new collection */} +
+
+ setNewName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleCreate() + }} + className="text-sm" + /> + +
+
+
+
+ ) +} diff --git a/frontend/src/components/GalleryCard.tsx b/frontend/src/components/GalleryCard.tsx index 4a3a64e..45d6f81 100644 --- a/frontend/src/components/GalleryCard.tsx +++ b/frontend/src/components/GalleryCard.tsx @@ -4,6 +4,7 @@ import { Button } from "./ui/button" import { GalleryImage } from "../hooks/useGallery" import { useLanguage } from "../hooks/useLanguage" import { formatDateInTimezone } from "../utils" +import { CollectionPicker } from "./CollectionPicker" // Fallback placeholder component function ImagePlaceholder() { @@ -48,6 +49,7 @@ export const GalleryCard = memo(function GalleryCard({ const [isFavoriting, setIsFavoriting] = useState(false) const [showActions, setShowActions] = useState(false) const [imageError, setImageError] = useState(false) + const [showCollectionPicker, setShowCollectionPicker] = useState(false) const handleFavorite = async (e: React.MouseEvent) => { e.stopPropagation() @@ -145,6 +147,20 @@ export const GalleryCard = memo(function GalleryCard({ > {/* Top actions */}
+
+ + {/* Collection picker modal */} + {showCollectionPicker && ( + setShowCollectionPicker(false)} + /> + )} ) }) diff --git a/frontend/src/components/ImageDetailModal.tsx b/frontend/src/components/ImageDetailModal.tsx index c3c9786..a85bc8b 100644 --- a/frontend/src/components/ImageDetailModal.tsx +++ b/frontend/src/components/ImageDetailModal.tsx @@ -3,6 +3,7 @@ import { Button } from "./ui/button" import { GalleryImage } from "../hooks/useGallery" import { useLanguage } from "../hooks/useLanguage" import { ShareButton } from "./ShareButton" +import { CollectionPicker } from "./CollectionPicker" import { useAuth } from "../hooks/useAuth" import { useTrmnlSync } from "../hooks/useTrmnlSync" import { formatDateTimeInTimezone } from "../utils" @@ -62,6 +63,7 @@ export function ImageDetailModal({ const [syncSuccess, setSyncSuccess] = useState(false) const [showDeviceMenu, setShowDeviceMenu] = useState(false) const [selectedDevices, setSelectedDevices] = useState([]) + const [showCollectionPicker, setShowCollectionPicker] = useState(false) const exportMenuRef = useRef(null) const deviceMenuRef = useRef(null) @@ -650,6 +652,18 @@ export function ImageDetailModal({ )}
+ +
+ + {/* Collection picker modal */} + {showCollectionPicker && image && ( + setShowCollectionPicker(false)} + /> + )} ) } diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts index 6ee840a..e3529f7 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -8,6 +8,8 @@ export { ImageDetailModal } from "./ImageDetailModal" export { PageHeader } from "./PageHeader" export { MaskDrawer } from "./MaskDrawer" export { PromptEnhanceModal } from "./PromptEnhanceModal" +export { CollectionPicker } from "./CollectionPicker" +export { CollectionManager } from "./CollectionManager" export { OfflineIndicator } from "./OfflineIndicator" export { Button } from "./ui/button" export { Input } from "./ui/input" diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts index fac1b8e..37a9260 100644 --- a/frontend/src/hooks/index.ts +++ b/frontend/src/hooks/index.ts @@ -9,6 +9,7 @@ export { useSubscription } from "./useSubscription" export { useGallery } from "./useGallery" export { useSchedule } from "./useSchedule" export { useBatch } from "./useBatch" +export { useCollections } from "./useCollections" export { useSEO, usePageTitle } from "./useSEO" export { useOnlineStatus } from "./useOnlineStatus" export type { Language } from "./useLanguage" @@ -18,3 +19,4 @@ export type { SubscriptionStatus, SubscriptionInfo, AccessStatus } from "./useSu export type { GalleryImage, GalleryPagination, GalleryStats } from "./useGallery" export type { ScheduledJob, CreateScheduledJobInput, SchedulePagination } from "./useSchedule" export type { BatchJob, BatchJobItem, BatchJobWithItems, CreateBatchJobInput, BatchStatus, BatchPagination } from "./useBatch" +export type { Collection } from "./useCollections" diff --git a/frontend/src/hooks/useCollections.ts b/frontend/src/hooks/useCollections.ts new file mode 100644 index 0000000..630c4d5 --- /dev/null +++ b/frontend/src/hooks/useCollections.ts @@ -0,0 +1,220 @@ +import { useState, useCallback, useEffect } from "react" +import { useAuth } from "./useAuth" + +export interface Collection { + id: number + name: string + description: string | null + imageCount: number + coverThumbnailUrl: string | null + createdAt: string + updatedAt: string +} + +interface CollectionsState { + collections: Collection[] + isLoading: boolean + error: string | null +} + +export function useCollections() { + const { authFetch, isAuthenticated } = useAuth() + const [state, setState] = useState({ + collections: [], + isLoading: false, + error: null, + }) + + const fetchCollections = useCallback(async () => { + if (!isAuthenticated) return + + setState((prev) => ({ ...prev, isLoading: true, error: null })) + + try { + const response = await authFetch("/api/collections") + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || "Failed to fetch collections") + } + + setState({ + collections: data.collections, + isLoading: false, + error: null, + }) + } catch (err) { + setState((prev) => ({ + ...prev, + isLoading: false, + error: err instanceof Error ? err.message : "Failed to fetch collections", + })) + } + }, [isAuthenticated, authFetch]) + + const createCollection = useCallback(async (name: string, description?: string): Promise => { + try { + const response = await authFetch("/api/collections", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name, description }), + }) + + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || "Failed to create collection") + } + + setState((prev) => ({ + ...prev, + collections: [data.collection, ...prev.collections], + })) + + return data.collection + } catch (err) { + console.error("Failed to create collection:", err) + throw err + } + }, [authFetch]) + + const updateCollection = useCallback(async (id: number, name: string, description?: string) => { + try { + const response = await authFetch(`/api/collections/${id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name, description }), + }) + + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || "Failed to update collection") + } + + setState((prev) => ({ + ...prev, + collections: prev.collections.map((c) => + c.id === id ? { ...c, name, description: description || null } : c + ), + })) + } catch (err) { + console.error("Failed to update collection:", err) + throw err + } + }, [authFetch]) + + const deleteCollection = useCallback(async (id: number) => { + try { + const response = await authFetch(`/api/collections/${id}`, { + method: "DELETE", + }) + + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || "Failed to delete collection") + } + + setState((prev) => ({ + ...prev, + collections: prev.collections.filter((c) => c.id !== id), + })) + } catch (err) { + console.error("Failed to delete collection:", err) + throw err + } + }, [authFetch]) + + const addImageToCollection = useCallback(async (collectionId: number, imageId: number) => { + try { + const response = await authFetch(`/api/collections/${collectionId}/images`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ imageId }), + }) + + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || "Failed to add image to collection") + } + + // Update image count locally + setState((prev) => ({ + ...prev, + collections: prev.collections.map((c) => + c.id === collectionId ? { ...c, imageCount: c.imageCount + 1 } : c + ), + })) + } catch (err) { + console.error("Failed to add image to collection:", err) + throw err + } + }, [authFetch]) + + const removeImageFromCollection = useCallback(async (collectionId: number, imageId: number) => { + try { + const response = await authFetch(`/api/collections/${collectionId}/images/${imageId}`, { + method: "DELETE", + }) + + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || "Failed to remove image from collection") + } + + // Update image count locally + setState((prev) => ({ + ...prev, + collections: prev.collections.map((c) => + c.id === collectionId ? { ...c, imageCount: Math.max(0, c.imageCount - 1) } : c + ), + })) + } catch (err) { + console.error("Failed to remove image from collection:", err) + throw err + } + }, [authFetch]) + + const getCollectionsForImage = useCallback(async (imageId: number): Promise => { + try { + const response = await authFetch(`/api/collections/for-image/${imageId}`) + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || "Failed to get collections for image") + } + + return data.collectionIds + } catch (err) { + console.error("Failed to get collections for image:", err) + return [] + } + }, [authFetch]) + + const refresh = useCallback(() => { + fetchCollections() + }, [fetchCollections]) + + // Initial fetch + useEffect(() => { + if (isAuthenticated) { + fetchCollections() + } + }, [isAuthenticated]) // eslint-disable-line react-hooks/exhaustive-deps + + return { + collections: state.collections, + isLoading: state.isLoading, + error: state.error, + createCollection, + updateCollection, + deleteCollection, + addImageToCollection, + removeImageFromCollection, + getCollectionsForImage, + refresh, + } +} diff --git a/frontend/src/hooks/useGallery.ts b/frontend/src/hooks/useGallery.ts index c36865e..ffd9a4e 100644 --- a/frontend/src/hooks/useGallery.ts +++ b/frontend/src/hooks/useGallery.ts @@ -51,6 +51,7 @@ export function useGallery() { isFromCache: false, }) const [filter, setFilter] = useState<"all" | "favorites">("all") + const [collectionId, setCollectionId] = useState(null) const [searchQuery, setSearchQuery] = useState("") const [debouncedSearchQuery, setDebouncedSearchQuery] = useState("") const debounceTimerRef = useRef | null>(null) @@ -82,7 +83,9 @@ export function useGallery() { limit: "20", }) - if (filter === "favorites") { + if (collectionId) { + params.set("collection", collectionId.toString()) + } else if (filter === "favorites") { params.set("favorites", "true") } @@ -134,7 +137,7 @@ export function useGallery() { : err instanceof Error ? err.message : "Failed to fetch gallery", })) } - }, [isAuthenticated, authFetch, filter, debouncedSearchQuery]) + }, [isAuthenticated, authFetch, filter, debouncedSearchQuery, collectionId]) // Fetch stats const fetchStats = useCallback(async () => { @@ -274,7 +277,7 @@ export function useGallery() { fetchImages(1, false) fetchStats() } - }, [isAuthenticated, filter, debouncedSearchQuery]) // eslint-disable-line react-hooks/exhaustive-deps + }, [isAuthenticated, filter, debouncedSearchQuery, collectionId]) // eslint-disable-line react-hooks/exhaustive-deps return { images: state.images, @@ -286,6 +289,8 @@ export function useGallery() { isFromCache: state.isFromCache, filter, setFilter, + collectionId, + setCollectionId, searchQuery, setSearchQuery, toggleFavorite, diff --git a/frontend/src/hooks/useLanguage.ts b/frontend/src/hooks/useLanguage.ts index 45cf78b..c473a38 100644 --- a/frontend/src/hooks/useLanguage.ts +++ b/frontend/src/hooks/useLanguage.ts @@ -96,6 +96,10 @@ export const translations = { signIn: "Sign in", passwordMismatch: "Passwords do not match", passwordTooShort: "Password must be at least 6 characters", + emailAlreadyExists: "An account with this email already exists. Please sign in instead.", + invalidEmail: "Please enter a valid email address.", + weakPassword: "Password must be at least 8 characters and include a number or special character.", + genericError: "Registration failed. Please try again.", }, forgotPassword: { title: "Forgot Password", @@ -418,6 +422,28 @@ export const translations = { duplicateBatch: "Duplicate Batch", syncedToTrmnl: "Synced to TRMNL", }, + collections: { + title: "Collections", + create: "Create Collection", + name: "Collection Name", + namePlaceholder: "e.g., Landscapes", + description: "Description (optional)", + descriptionPlaceholder: "What's this collection about?", + noCollections: "No collections yet", + noCollectionsHint: "Create a collection to organize your images.", + addToCollection: "Add to Collection", + removeFromCollection: "Remove from Collection", + manageCollections: "Manage Collections", + deleteConfirm: "Delete this collection? Images won't be deleted.", + imageCount: "{count} images", + imageCountSingular: "{count} image", + allCollections: "All", + createNew: "Create new...", + rename: "Rename", + delete: "Delete", + save: "Save", + cancel: "Cancel", + }, // Prompt Enhancement enhancePromptTitle: "Enhance Your Prompt?", originalPrompt: "Your Prompt", @@ -520,6 +546,10 @@ export const translations = { signIn: "登录", passwordMismatch: "密码不匹配", passwordTooShort: "密码至少需要6个字符", + emailAlreadyExists: "该邮箱已注册,请直接登录。", + invalidEmail: "请输入有效的邮箱地址。", + weakPassword: "密码至少需要8个字符,并包含数字或特殊字符。", + genericError: "注册失败,请重试。", }, forgotPassword: { title: "忘记密码", @@ -842,6 +872,28 @@ export const translations = { duplicateBatch: "复制批量任务", syncedToTrmnl: "已同步到 TRMNL", }, + collections: { + title: "合集", + create: "创建合集", + name: "合集名称", + namePlaceholder: "例如:风景", + description: "描述(可选)", + descriptionPlaceholder: "这个合集关于什么?", + noCollections: "暂无合集", + noCollectionsHint: "创建一个合集来整理您的图片。", + addToCollection: "添加到合集", + removeFromCollection: "从合集移除", + manageCollections: "管理合集", + deleteConfirm: "删除此合集?图片不会被删除。", + imageCount: "{count} 张图片", + imageCountSingular: "{count} 张图片", + allCollections: "全部", + createNew: "新建...", + rename: "重命名", + delete: "删除", + save: "保存", + cancel: "取消", + }, // Prompt Enhancement enhancePromptTitle: "增强您的提示词?", originalPrompt: "您的提示词", diff --git a/frontend/src/pages/GalleryPage.tsx b/frontend/src/pages/GalleryPage.tsx index cac3754..1943759 100644 --- a/frontend/src/pages/GalleryPage.tsx +++ b/frontend/src/pages/GalleryPage.tsx @@ -3,11 +3,13 @@ import { Button } from "../components/ui/button" import { Input } from "../components/ui/input" import { GalleryCard } from "../components/GalleryCard" import { ImageDetailModal } from "../components/ImageDetailModal" +import { CollectionManager } from "../components/CollectionManager" import { PageHeader } from "../components/PageHeader" import { useGallery, GalleryImage } from "../hooks/useGallery" +import { useCollections } from "../hooks/useCollections" import { useLanguage } from "../hooks/useLanguage" import { useAuth } from "../hooks/useAuth" -import { RefreshCw, Upload } from "lucide-react" +import { RefreshCw, Upload, FolderOpen, Settings2 } from "lucide-react" import { detectBrowserTimezone, groupImagesByDate } from "../utils" type AppPage = "chat" | "gallery" | "schedule" | "batch" | "orders" | "subscription" | "settings" @@ -28,6 +30,8 @@ export function GalleryPage({ onNavigate, onLogout }: GalleryPageProps) { error, filter, setFilter, + collectionId, + setCollectionId, searchQuery, setSearchQuery, toggleFavorite, @@ -36,12 +40,14 @@ export function GalleryPage({ onNavigate, onLogout }: GalleryPageProps) { loadMore, refresh, } = useGallery() + const { collections } = useCollections() const [selectedImage, setSelectedImage] = useState(null) const [isModalOpen, setIsModalOpen] = useState(false) const [localSearch, setLocalSearch] = useState("") const [isUploading, setIsUploading] = useState(false) const [uploadError, setUploadError] = useState(null) + const [showCollectionManager, setShowCollectionManager] = useState(false) const fileInputRef = useRef(null) const [userTimezone, setUserTimezone] = useState(() => detectBrowserTimezone()) @@ -221,21 +227,21 @@ export function GalleryPage({ onNavigate, onLogout }: GalleryPageProps) {
+ {/* Collection filter dropdown */} + {collections.length > 0 && ( +
+
+ + +
+ +
+ )} + {collections.length === 0 && ( + + )} + {/* Search */}
@@ -305,6 +360,22 @@ export function GalleryPage({ onNavigate, onLogout }: GalleryPageProps) {
+ {/* Active collection indicator */} + {collectionId && ( +
+ + + {t.collections.title}: {collections.find(c => c.id === collectionId)?.name} + + +
+ )} + {/* Active search indicator */} {searchQuery && (
@@ -384,7 +455,7 @@ export function GalleryPage({ onNavigate, onLogout }: GalleryPageProps) { ? t.gallery.noFavoritesHint : t.gallery.noImagesHint}

- {(searchQuery || filter === "favorites") && ( + {(searchQuery || filter === "favorites" || collectionId) && (
) } diff --git a/frontend/src/pages/RegisterPage.tsx b/frontend/src/pages/RegisterPage.tsx index 91d603a..53b19df 100644 --- a/frontend/src/pages/RegisterPage.tsx +++ b/frontend/src/pages/RegisterPage.tsx @@ -23,6 +23,10 @@ interface RegisterPageProps { signIn: string passwordMismatch: string passwordTooShort: string + emailAlreadyExists: string + invalidEmail: string + weakPassword: string + genericError: string } } @@ -36,6 +40,14 @@ export function RegisterPage({ onRegister, onSwitchToLogin, translations: t }: R const [isLoading, setIsLoading] = useState(false) const [error, setError] = useState(null) + // Map backend error codes/messages to user-friendly translated strings + const mapErrorMessage = (error: string): string => { + if (error === "EMAIL_ALREADY_EXISTS") return t.emailAlreadyExists + if (error === "Invalid email format") return t.invalidEmail + if (error.includes("Password must be")) return t.weakPassword + return t.genericError + } + const handleSubmit = async (e: FormEvent) => { e.preventDefault() setError(null) @@ -57,7 +69,7 @@ export function RegisterPage({ onRegister, onSwitchToLogin, translations: t }: R const result = await onRegister(email, password, name || undefined) if (!result.success) { - setError(result.error || "Registration failed") + setError(mapErrorMessage(result.error || "")) } setIsLoading(false) } From 2839a76e9b2d0fa3a954a029fdde7068189f04a0 Mon Sep 17 00:00:00 2001 From: kenken64 Date: Wed, 28 Jan 2026 10:47:57 +0800 Subject: [PATCH 03/11] fix: match all password validation errors in register error mapping Changed error check from includes("Password must be") to startsWith("Password must") to also catch "Password must contain" errors from the backend password strength validator. Co-Authored-By: Claude Opus 4.5 --- frontend/src/pages/RegisterPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/pages/RegisterPage.tsx b/frontend/src/pages/RegisterPage.tsx index 53b19df..46acd42 100644 --- a/frontend/src/pages/RegisterPage.tsx +++ b/frontend/src/pages/RegisterPage.tsx @@ -44,7 +44,7 @@ export function RegisterPage({ onRegister, onSwitchToLogin, translations: t }: R const mapErrorMessage = (error: string): string => { if (error === "EMAIL_ALREADY_EXISTS") return t.emailAlreadyExists if (error === "Invalid email format") return t.invalidEmail - if (error.includes("Password must be")) return t.weakPassword + if (error.startsWith("Password must")) return t.weakPassword return t.genericError } From f1e92be16be2909fd8f6473704633f26a806c916 Mon Sep 17 00:00:00 2001 From: kenken64 Date: Wed, 28 Jan 2026 11:01:34 +0800 Subject: [PATCH 04/11] fix: patch XSS vulnerability in share page and scope chat by user Escape user prompts in the share page HTML to prevent stored XSS attacks. Scope chat message localStorage key per user ID so different users on the same browser no longer see each other's conversation history. Co-Authored-By: Claude Opus 4.5 --- backend/src/routes/share.ts | 5 +++-- backend/src/services/seo-service.ts | 2 +- frontend/src/App.tsx | 32 ++++++++++++++++++++--------- 3 files changed, 26 insertions(+), 13 deletions(-) diff --git a/backend/src/routes/share.ts b/backend/src/routes/share.ts index 4f9c79c..01445dd 100644 --- a/backend/src/routes/share.ts +++ b/backend/src/routes/share.ts @@ -5,6 +5,7 @@ import { config } from "../config" import { mkdirSync, existsSync } from "fs" import { join } from "path" import { randomBytes } from "crypto" +import { escapeHtml } from "../services/seo-service" // Shared image type export interface SharedImage { @@ -331,7 +332,7 @@ export const shareRoutes = { sharedImageQueries.incrementViewCount.run(shareId) const title = "AI-Generated Image | PromptInk" - const description = sharedImage.prompt || "An image created with PromptInk's AI-powered image generator" + const description = escapeHtml(sharedImage.prompt || "An image created with PromptInk's AI-powered image generator") const imageUrl = sharedImage.image_url const pageUrl = getSharePageUrl(shareId) @@ -432,7 +433,7 @@ export const shareRoutes = {
AI-Generated Image
- ${sharedImage.prompt ? `

"${sharedImage.prompt}"

` : ''} + ${sharedImage.prompt ? `

"${escapeHtml(sharedImage.prompt)}"

` : ''} Create Your Own AI Images

${sharedImage.view_count + 1} views

diff --git a/backend/src/services/seo-service.ts b/backend/src/services/seo-service.ts index 6cf08de..fa7d076 100644 --- a/backend/src/services/seo-service.ts +++ b/backend/src/services/seo-service.ts @@ -207,7 +207,7 @@ export function generateStructuredData(seo: SEOConfig, pathname: string): string } // Escape HTML special characters -function escapeHtml(text: string): string { +export function escapeHtml(text: string): string { return text .replace(/&/g, "&") .replace(/ { return "chat" } -// Storage key for chat messages -const CHAT_MESSAGES_KEY = "promptink_chat_messages" +// Storage key for chat messages - scoped per user +const getChatMessagesKey = (userId: number) => `promptink_chat_messages_${userId}` -// Load messages from localStorage -const loadMessagesFromStorage = (): Message[] => { +// Load messages from localStorage for a specific user +const loadMessagesForUser = (userId: number): Message[] => { try { - const stored = localStorage.getItem(CHAT_MESSAGES_KEY) + const stored = localStorage.getItem(getChatMessagesKey(userId)) if (stored) { const parsed = JSON.parse(stored) // Filter out any loading messages that may have been persisted during generation @@ -82,7 +82,7 @@ const loadMessagesFromStorage = (): Message[] => { } export default function App() { - const [messages, setMessages] = useState(loadMessagesFromStorage) + const [messages, setMessages] = useState([]) const [authPage, setAuthPage] = useState("login") const [appPage, setAppPage] = useState(getInitialPage) const [confirmationOrderId, setConfirmationOrderId] = useState(null) @@ -180,16 +180,26 @@ export default function App() { } }, [appPage, scrollToBottom, messages.length]) - // Persist messages to localStorage when they change + // Load user-specific messages when user changes useEffect(() => { + if (user?.id) { + setMessages(loadMessagesForUser(user.id)) + } else { + setMessages([]) + } + }, [user?.id]) + + // Persist messages to localStorage when they change (scoped per user) + useEffect(() => { + if (!user?.id) return try { // Only persist non-loading messages const messagesToPersist = messages.filter(msg => !msg.isLoading) - localStorage.setItem(CHAT_MESSAGES_KEY, JSON.stringify(messagesToPersist)) + localStorage.setItem(getChatMessagesKey(user.id), JSON.stringify(messagesToPersist)) } catch (e) { console.error("Failed to save messages to storage:", e) } - }, [messages]) + }, [messages, user?.id]) // Close mobile menu when clicking outside useEffect(() => { @@ -424,7 +434,9 @@ export default function App() { const handleNewChat = () => { setMessages([]) - localStorage.removeItem(CHAT_MESSAGES_KEY) + if (user?.id) { + localStorage.removeItem(getChatMessagesKey(user.id)) + } } const handleLogin = async (email: string, password: string) => { From de2e24d639ed781d93534163c4d936f23c444e9b Mon Sep 17 00:00:00 2001 From: kenken64 Date: Wed, 28 Jan 2026 11:23:09 +0800 Subject: [PATCH 05/11] fix: improve mobile responsiveness for collection components - GalleryPage: collection chips as horizontally scrollable row with touch momentum, hidden scrollbar, icon-only manage button on mobile, search stacks below on small screens - CollectionPicker: bottom sheet on mobile, larger touch targets, drag handle, safe area padding - CollectionManager: bottom sheet on mobile, stacked edit buttons, larger touch targets for rename/delete icons Co-Authored-By: Claude Opus 4.5 --- frontend/src/components/CollectionManager.tsx | 44 +++-- frontend/src/components/CollectionPicker.tsx | 20 +- frontend/src/pages/GalleryPage.tsx | 173 ++++++++---------- 3 files changed, 115 insertions(+), 122 deletions(-) diff --git a/frontend/src/components/CollectionManager.tsx b/frontend/src/components/CollectionManager.tsx index 615f600..1fe28fb 100644 --- a/frontend/src/components/CollectionManager.tsx +++ b/frontend/src/components/CollectionManager.tsx @@ -105,18 +105,23 @@ export function CollectionManager({ isOpen, onClose }: CollectionManagerProps) { } return ( -
+
+ {/* Drag handle for mobile */} +
+
+
+ {/* Header */}

{t.collections.manageCollections}

{/* Collection list */} -
+
{isLoading ? (
@@ -142,10 +147,10 @@ export function CollectionManager({ isOpen, onClose }: CollectionManagerProps) { collections.map((collection) => (
{editingId === collection.id ? ( -
+
- - +
+ + +
) : ( <> @@ -182,29 +189,29 @@ export function CollectionManager({ isOpen, onClose }: CollectionManagerProps) {
{/* Create new collection */} -
+
{isCreating ? "..." : t.collections.create} diff --git a/frontend/src/components/CollectionPicker.tsx b/frontend/src/components/CollectionPicker.tsx index fc92e94..085beb0 100644 --- a/frontend/src/components/CollectionPicker.tsx +++ b/frontend/src/components/CollectionPicker.tsx @@ -101,18 +101,23 @@ export function CollectionPicker({ imageId, isOpen, onClose }: CollectionPickerP } return ( -
+
+ {/* Drag handle for mobile */} +
+
+
+ {/* Header */}

{t.collections.addToCollection}

{/* Collection list */} -
+
{isLoadingIds ? (
@@ -141,14 +146,14 @@ export function CollectionPicker({ imageId, isOpen, onClose }: CollectionPickerP return (

{collection.name}

@@ -171,7 +176,7 @@ export function CollectionPicker({ imageId, isOpen, onClose }: CollectionPickerP
{/* Create new collection */} -
+
{isCreating ? "..." : t.collections.create} diff --git a/frontend/src/pages/GalleryPage.tsx b/frontend/src/pages/GalleryPage.tsx index 1943759..d540b1f 100644 --- a/frontend/src/pages/GalleryPage.tsx +++ b/frontend/src/pages/GalleryPage.tsx @@ -222,94 +222,89 @@ export function GalleryPage({ onNavigate, onLogout }: GalleryPageProps) { )} {/* Filters & Search */} -
- {/* Filter tabs */} -
- - -
- - {/* Collection filter dropdown */} - {collections.length > 0 && ( -
-
- - -
- + + + + + {t.gallery?.favorites} +
- )} - {collections.length === 0 && ( - + ))} + + {/* Manage collections button - icon only on mobile, text on desktop */} + - )} + + + {collections.length === 0 ? t.collections.title : t.collections.manageCollections} + + +
{/* Search */} -
+
- {/* Active collection indicator */} - {collectionId && ( -
- - - {t.collections.title}: {collections.find(c => c.id === collectionId)?.name} - - -
- )} - {/* Active search indicator */} {searchQuery && (
From 573d92103e5f89bcbe95061c73dcf005e98dd329 Mon Sep 17 00:00:00 2001 From: kenken64 Date: Wed, 28 Jan 2026 11:27:14 +0800 Subject: [PATCH 06/11] fix: stack create collection input and button on mobile Input and button now stack vertically on small screens with full-width button, and display inline on desktop. Co-Authored-By: Claude Opus 4.5 --- frontend/src/components/CollectionManager.tsx | 4 ++-- frontend/src/components/CollectionPicker.tsx | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/CollectionManager.tsx b/frontend/src/components/CollectionManager.tsx index 1fe28fb..535f30d 100644 --- a/frontend/src/components/CollectionManager.tsx +++ b/frontend/src/components/CollectionManager.tsx @@ -226,7 +226,7 @@ export function CollectionManager({ isOpen, onClose }: CollectionManagerProps) { {/* Create new collection */}
-
+
{isCreating ? "..." : t.collections.create} diff --git a/frontend/src/components/CollectionPicker.tsx b/frontend/src/components/CollectionPicker.tsx index 085beb0..5077697 100644 --- a/frontend/src/components/CollectionPicker.tsx +++ b/frontend/src/components/CollectionPicker.tsx @@ -176,8 +176,8 @@ export function CollectionPicker({ imageId, isOpen, onClose }: CollectionPickerP
{/* Create new collection */} -
-
+
+
{isCreating ? "..." : t.collections.create} From 0c9faaa15d383716f0896cff35a5ebb970e0339b Mon Sep 17 00:00:00 2001 From: kenken64 Date: Wed, 28 Jan 2026 11:31:27 +0800 Subject: [PATCH 07/11] fix: refresh collection chips immediately after changes Each component had its own useCollections instance so creating, deleting, or modifying collections in the manager/picker did not update the gallery filter chips. Now GalleryPage refreshes its collections when CollectionManager closes, and when CollectionPicker closes from either ImageDetailModal or GalleryCard. Co-Authored-By: Claude Opus 4.5 --- frontend/src/components/GalleryCard.tsx | 7 ++++++- frontend/src/components/ImageDetailModal.tsx | 7 ++++++- frontend/src/pages/GalleryPage.tsx | 9 +++++++-- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/GalleryCard.tsx b/frontend/src/components/GalleryCard.tsx index 45d6f81..ff819b2 100644 --- a/frontend/src/components/GalleryCard.tsx +++ b/frontend/src/components/GalleryCard.tsx @@ -35,6 +35,7 @@ interface GalleryCardProps { onToggleFavorite: (imageId: number) => Promise onDelete: (imageId: number) => Promise userTimezone?: string + onCollectionsChange?: () => void } export const GalleryCard = memo(function GalleryCard({ @@ -43,6 +44,7 @@ export const GalleryCard = memo(function GalleryCard({ onToggleFavorite, onDelete, userTimezone, + onCollectionsChange, }: GalleryCardProps) { const { t } = useLanguage() const [isDeleting, setIsDeleting] = useState(false) @@ -238,7 +240,10 @@ export const GalleryCard = memo(function GalleryCard({ setShowCollectionPicker(false)} + onClose={() => { + setShowCollectionPicker(false) + onCollectionsChange?.() + }} /> )} diff --git a/frontend/src/components/ImageDetailModal.tsx b/frontend/src/components/ImageDetailModal.tsx index a85bc8b..a26bdde 100644 --- a/frontend/src/components/ImageDetailModal.tsx +++ b/frontend/src/components/ImageDetailModal.tsx @@ -41,6 +41,7 @@ interface ImageDetailModalProps { hasPrev?: boolean hasNext?: boolean userTimezone?: string + onCollectionsChange?: () => void } export function ImageDetailModal({ @@ -53,6 +54,7 @@ export function ImageDetailModal({ hasPrev = false, hasNext = false, userTimezone, + onCollectionsChange, }: ImageDetailModalProps) { const { t } = useLanguage() const { accessToken: token } = useAuth() @@ -708,7 +710,10 @@ export function ImageDetailModal({ setShowCollectionPicker(false)} + onClose={() => { + setShowCollectionPicker(false) + onCollectionsChange?.() + }} /> )}
diff --git a/frontend/src/pages/GalleryPage.tsx b/frontend/src/pages/GalleryPage.tsx index d540b1f..c8ff0ac 100644 --- a/frontend/src/pages/GalleryPage.tsx +++ b/frontend/src/pages/GalleryPage.tsx @@ -40,7 +40,7 @@ export function GalleryPage({ onNavigate, onLogout }: GalleryPageProps) { loadMore, refresh, } = useGallery() - const { collections } = useCollections() + const { collections, refresh: refreshCollections } = useCollections() const [selectedImage, setSelectedImage] = useState(null) const [isModalOpen, setIsModalOpen] = useState(false) @@ -475,6 +475,7 @@ export function GalleryPage({ onNavigate, onLogout }: GalleryPageProps) { onToggleFavorite={toggleFavorite} onDelete={deleteImage} userTimezone={userTimezone} + onCollectionsChange={refreshCollections} /> ))}
@@ -541,12 +542,16 @@ export function GalleryPage({ onNavigate, onLogout }: GalleryPageProps) { hasPrev={currentIndex > 0} hasNext={currentIndex < images.length - 1} userTimezone={userTimezone} + onCollectionsChange={refreshCollections} /> {/* Collection manager modal */} setShowCollectionManager(false)} + onClose={() => { + setShowCollectionManager(false) + refreshCollections() + }} />
) From 147628b38b5bfa60d159e644bc8935dd5d5b67cf Mon Sep 17 00:00:00 2001 From: kenken64 Date: Wed, 28 Jan 2026 11:38:10 +0800 Subject: [PATCH 08/11] fix: update collection counter immediately on add/remove The collection chip counters now refresh instantly when an image is toggled in the picker, not only when the picker closes. Added onChange callback to CollectionPicker that fires after each successful add, remove, or new collection creation. Co-Authored-By: Claude Opus 4.5 --- frontend/src/components/CollectionPicker.tsx | 5 ++++- frontend/src/components/GalleryCard.tsx | 2 +- frontend/src/components/ImageDetailModal.tsx | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/CollectionPicker.tsx b/frontend/src/components/CollectionPicker.tsx index 5077697..dad8214 100644 --- a/frontend/src/components/CollectionPicker.tsx +++ b/frontend/src/components/CollectionPicker.tsx @@ -8,9 +8,10 @@ interface CollectionPickerProps { imageId: number isOpen: boolean onClose: () => void + onChange?: () => void } -export function CollectionPicker({ imageId, isOpen, onClose }: CollectionPickerProps) { +export function CollectionPicker({ imageId, isOpen, onClose, onChange }: CollectionPickerProps) { const { t } = useLanguage() const { collections, @@ -75,6 +76,7 @@ export function CollectionPicker({ imageId, isOpen, onClose }: CollectionPickerP await addImageToCollection(collection.id, imageId) setImageCollectionIds((prev) => [...prev, collection.id]) } + onChange?.() } catch { // Error already logged in hook } finally { @@ -93,6 +95,7 @@ export function CollectionPicker({ imageId, isOpen, onClose }: CollectionPickerP setImageCollectionIds((prev) => [...prev, created.id]) } setNewName("") + onChange?.() } catch { // Error already logged } finally { diff --git a/frontend/src/components/GalleryCard.tsx b/frontend/src/components/GalleryCard.tsx index ff819b2..715b03e 100644 --- a/frontend/src/components/GalleryCard.tsx +++ b/frontend/src/components/GalleryCard.tsx @@ -242,8 +242,8 @@ export const GalleryCard = memo(function GalleryCard({ isOpen={showCollectionPicker} onClose={() => { setShowCollectionPicker(false) - onCollectionsChange?.() }} + onChange={onCollectionsChange} /> )} diff --git a/frontend/src/components/ImageDetailModal.tsx b/frontend/src/components/ImageDetailModal.tsx index a26bdde..f0e253c 100644 --- a/frontend/src/components/ImageDetailModal.tsx +++ b/frontend/src/components/ImageDetailModal.tsx @@ -712,8 +712,8 @@ export function ImageDetailModal({ isOpen={showCollectionPicker} onClose={() => { setShowCollectionPicker(false) - onCollectionsChange?.() }} + onChange={onCollectionsChange} /> )}
From 8de5665d429eebeaf398dee48ebd797da2840577 Mon Sep 17 00:00:00 2001 From: kenken64 Date: Wed, 28 Jan 2026 11:54:30 +0800 Subject: [PATCH 09/11] feat: add keyboard shortcuts with help overlay and dedicated settings page Adds global keyboard shortcuts (Ctrl+N new chat, ? help overlay), gallery shortcuts (/ focus search, R refresh), and image modal shortcuts (F favorite, D delete, C collection, E export). Includes a dedicated Keyboard Shortcuts page accessible from Settings. Co-Authored-By: Claude Opus 4.5 --- frontend/src/App.tsx | 36 ++++- frontend/src/components/ImageDetailModal.tsx | 69 ++++++-- .../src/components/KeyboardShortcutsHelp.tsx | 151 ++++++++++++++++++ frontend/src/components/index.ts | 1 + frontend/src/hooks/index.ts | 2 + frontend/src/hooks/useKeyboardShortcuts.ts | 98 ++++++++++++ frontend/src/hooks/useLanguage.ts | 44 +++++ frontend/src/pages/GalleryPage.tsx | 30 ++++ frontend/src/pages/KeyboardShortcutsPage.tsx | 117 ++++++++++++++ frontend/src/pages/SettingsPage.tsx | 30 +++- 10 files changed, 557 insertions(+), 21 deletions(-) create mode 100644 frontend/src/components/KeyboardShortcutsHelp.tsx create mode 100644 frontend/src/hooks/useKeyboardShortcuts.ts create mode 100644 frontend/src/pages/KeyboardShortcutsPage.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0014749..14c32ed 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import { useState, useRef, useEffect, lazy, Suspense, useCallback } from "react" +import { useState, useRef, useEffect, lazy, Suspense, useCallback, useMemo } from "react" import { Sparkles, Plus, LogOut, Settings, ShoppingBag, CreditCard, Image, Menu, X, RefreshCw, Calendar, Layers, ChevronDown } from "lucide-react" import { Button } from "./components/ui/button" import { ScrollArea } from "./components/ui/scroll-area" @@ -9,6 +9,7 @@ import { LanguageToggle } from "./components/LanguageToggle" import { AuthGuard } from "./components/AuthGuard" import { PromptEnhanceModal } from "./components/PromptEnhanceModal" import { OfflineIndicator } from "./components/OfflineIndicator" +import { KeyboardShortcutsHelp } from "./components/KeyboardShortcutsHelp" import { LoginPage } from "./pages/LoginPage" import { RegisterPage } from "./pages/RegisterPage" import { ForgotPasswordPage } from "./pages/ForgotPasswordPage" @@ -16,6 +17,7 @@ import { ResetPasswordPage } from "./pages/ResetPasswordPage" import { PurchasePage } from "./pages/PurchasePage" import { OrderConfirmationPage } from "./pages/OrderConfirmationPage" import { OrdersPage } from "./pages/OrdersPage" +import { KeyboardShortcutsPage } from "./pages/KeyboardShortcutsPage" import { SubscriptionPage } from "./pages/SubscriptionPage" import { useImageGeneration, type ImageStylePreset } from "./hooks/useImageGeneration" import { useTheme } from "./hooks/useTheme" @@ -25,6 +27,8 @@ import { useAuth } from "./hooks/useAuth" import { useSubscription } from "./hooks/useSubscription" import { useSuggestions } from "./hooks/useSuggestions" import { useSEO } from "./hooks/useSEO" +import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts" +import type { ShortcutDef } from "./hooks/useKeyboardShortcuts" // Lazy load heavier pages for code splitting const SettingsPage = lazy(() => import("./pages/SettingsPage").then(m => ({ default: m.SettingsPage }))) @@ -48,11 +52,11 @@ interface Message { } type AuthPage = "login" | "register" | "forgot-password" | "reset-password" -type AppPage = "chat" | "settings" | "purchase" | "order-confirmation" | "orders" | "subscription" | "gallery" | "schedule" | "batch" +type AppPage = "chat" | "settings" | "purchase" | "order-confirmation" | "orders" | "subscription" | "gallery" | "schedule" | "batch" | "keyboard-shortcuts" type ImageSize = "1024x1024" | "1792x1024" | "1024x1792" // Valid app pages that can be restored from URL hash -const validAppPages: AppPage[] = ["chat", "settings", "orders", "subscription", "gallery", "schedule", "batch"] +const validAppPages: AppPage[] = ["chat", "settings", "orders", "subscription", "gallery", "schedule", "batch", "keyboard-shortcuts"] // Get initial page from URL hash const getInitialPage = (): AppPage => { @@ -130,6 +134,26 @@ export default function App() { // Update SEO meta tags when page changes useSEO() + // Global keyboard shortcuts + const globalShortcuts: ShortcutDef[] = useMemo(() => [ + { + key: "n", + ctrl: true, + handler: () => { + handleNewChat() + setAppPage("chat") + }, + description: t.shortcuts.newChat, + category: t.shortcuts.navigation, + allowInInput: true, + }, + ], [t.shortcuts.newChat, t.shortcuts.navigation]) + + const { isHelpOpen: isShortcutsHelpOpen, closeHelp: closeShortcutsHelp } = useKeyboardShortcuts( + globalShortcuts, + { enabled: isAuthenticated } + ) + // Check if user needs to be redirected to purchase page useEffect(() => { if (isAuthenticated && !subscriptionLoading && subscription) { @@ -1044,6 +1068,8 @@ export default function App() { return renderSchedulePage() case "batch": return renderBatchPage() + case "keyboard-shortcuts": + return setAppPage(page)} onLogout={logout} /> case "chat": default: return renderChatApp() @@ -1061,6 +1087,10 @@ export default function App() { > {renderCurrentPage()} + ) } diff --git a/frontend/src/components/ImageDetailModal.tsx b/frontend/src/components/ImageDetailModal.tsx index f0e253c..8339220 100644 --- a/frontend/src/components/ImageDetailModal.tsx +++ b/frontend/src/components/ImageDetailModal.tsx @@ -91,10 +91,20 @@ export function ImageDetailModal({ setSelectedDevices([]) }, [image?.id]) - // Handle keyboard navigation + // Handle keyboard navigation and shortcuts const handleKeyDown = useCallback((e: KeyboardEvent) => { if (!isOpen) return + // Skip single-key shortcuts when focus is in an input (e.g. collection picker) + const inInput = (() => { + const el = document.activeElement + if (!el) return false + const tag = el.tagName + if (tag === "INPUT" || tag === "TEXTAREA") return true + if ((el as HTMLElement).isContentEditable) return true + return false + })() + switch (e.key) { case "Escape": onClose() @@ -105,8 +115,33 @@ export function ImageDetailModal({ case "ArrowRight": if (hasNext && onNavigate) onNavigate("next") break + case "f": + case "F": + if (inInput || e.ctrlKey || e.metaKey || e.altKey) break + e.preventDefault() + handleFavorite() + break + case "d": + case "D": + if (inInput || e.ctrlKey || e.metaKey || e.altKey) break + e.preventDefault() + handleDelete() + break + case "c": + case "C": + if (inInput || e.ctrlKey || e.metaKey || e.altKey) break + if (showCollectionPicker) break + e.preventDefault() + setShowCollectionPicker(true) + break + case "e": + case "E": + if (inInput || e.ctrlKey || e.metaKey || e.altKey) break + e.preventDefault() + handleDownload() + break } - }, [isOpen, onClose, onNavigate, hasPrev, hasNext]) + }, [isOpen, onClose, onNavigate, hasPrev, hasNext, handleFavorite, handleDelete, handleDownload, showCollectionPicker]) useEffect(() => { document.addEventListener("keydown", handleKeyDown) @@ -127,11 +162,11 @@ export function ImageDetailModal({ if (!isOpen || !image) return null - const handleExport = async (format: "png" | "jpg" | "webp") => { - if (!token || isExporting) return + const handleExport = useCallback(async (format: "png" | "jpg" | "webp") => { + if (!token || isExporting || !image) return setIsExporting(true) setShowExportMenu(false) - + try { const response = await fetch( `/api/gallery/export/${image.id}?format=${format}&quality=90`, @@ -141,16 +176,16 @@ export function ImageDetailModal({ }, } ) - + if (!response.ok) { throw new Error("Export failed") } - + const blob = await response.blob() const url = window.URL.createObjectURL(blob) const a = document.createElement("a") a.href = url - + // Extract filename from Content-Disposition header or generate one const contentDisposition = response.headers.get("Content-Disposition") let filename = `promptink-${image.id}.${format}` @@ -158,7 +193,7 @@ export function ImageDetailModal({ const match = contentDisposition.match(/filename="(.+)"/) if (match) filename = match[1] } - + a.download = filename document.body.appendChild(a) a.click() @@ -169,22 +204,24 @@ export function ImageDetailModal({ } finally { setIsExporting(false) } - } + }, [token, isExporting, image]) - const handleDownload = async () => { + const handleDownload = useCallback(async () => { // Default to PNG for backward compatibility await handleExport("png") - } + }, [handleExport]) - const handleFavorite = async () => { + const handleFavorite = useCallback(async () => { + if (!image) return try { await onToggleFavorite(image.id) } catch (err) { console.error("Failed to toggle favorite:", err) } - } + }, [image, onToggleFavorite]) - const handleDelete = async () => { + const handleDelete = useCallback(async () => { + if (!image) return if (!confirm(t.gallery.confirmDelete)) return try { await onDelete(image.id) @@ -192,7 +229,7 @@ export function ImageDetailModal({ } catch (err) { console.error("Failed to delete:", err) } - } + }, [image, t.gallery.confirmDelete, onDelete, onClose]) const formatDate = (dateStr: string) => { // Function handles fallback to browser timezone when userTimezone is empty diff --git a/frontend/src/components/KeyboardShortcutsHelp.tsx b/frontend/src/components/KeyboardShortcutsHelp.tsx new file mode 100644 index 0000000..0cb4d34 --- /dev/null +++ b/frontend/src/components/KeyboardShortcutsHelp.tsx @@ -0,0 +1,151 @@ +import { useEffect, useRef } from "react" +import { useLanguage } from "../hooks/useLanguage" +import { getModifierLabel } from "../hooks/useKeyboardShortcuts" + +interface KeyboardShortcutsHelpProps { + isOpen: boolean + onClose: () => void +} + +interface ShortcutEntry { + keys: string[] + description: string +} + +interface ShortcutGroup { + title: string + shortcuts: ShortcutEntry[] +} + +export function KeyboardShortcutsHelp({ isOpen, onClose }: KeyboardShortcutsHelpProps) { + const { t } = useLanguage() + const modalRef = useRef(null) + const mod = getModifierLabel() + + useEffect(() => { + if (!isOpen) return + const handleClickOutside = (e: MouseEvent) => { + if (modalRef.current && !modalRef.current.contains(e.target as Node)) { + onClose() + } + } + document.addEventListener("mousedown", handleClickOutside) + return () => document.removeEventListener("mousedown", handleClickOutside) + }, [isOpen, onClose]) + + useEffect(() => { + if (isOpen) { + document.body.style.overflow = "hidden" + } else { + document.body.style.overflow = "" + } + return () => { + document.body.style.overflow = "" + } + }, [isOpen]) + + if (!isOpen) return null + + const groups: ShortcutGroup[] = [ + { + title: t.shortcuts.navigation, + shortcuts: [ + { keys: [mod, "N"], description: t.shortcuts.newChat }, + ], + }, + { + title: t.shortcuts.chat, + shortcuts: [ + { keys: ["Enter"], description: t.shortcuts.sendMessage }, + { keys: ["Shift", "Enter"], description: t.shortcuts.newLine }, + ], + }, + { + title: t.shortcuts.gallery, + shortcuts: [ + { keys: ["/"], description: t.shortcuts.focusSearch }, + { keys: ["R"], description: t.shortcuts.refreshGallery }, + ], + }, + { + title: t.shortcuts.imageViewer, + shortcuts: [ + { keys: ["\u2190"], description: t.shortcuts.previousImage }, + { keys: ["\u2192"], description: t.shortcuts.nextImage }, + { keys: ["F"], description: t.shortcuts.toggleFavorite }, + { keys: ["D"], description: t.shortcuts.deleteImage }, + { keys: ["C"], description: t.shortcuts.addToCollection }, + { keys: ["E"], description: t.shortcuts.exportImage }, + { keys: ["Esc"], description: t.shortcuts.closeModal }, + ], + }, + { + title: t.shortcuts.general, + shortcuts: [ + { keys: ["?"], description: t.shortcuts.showShortcuts }, + ], + }, + ] + + return ( +
+ {/* Backdrop */} +
+ + {/* Modal */} +
+ {/* Header */} +
+

{t.shortcuts.title}

+ +
+ + {/* Body */} +
+ {groups.map((group) => ( +
+

+ {group.title} +

+
+ {group.shortcuts.map((shortcut, i) => ( +
+ + {shortcut.description} + +
+ {shortcut.keys.map((key, j) => ( + + {j > 0 && ( + + + )} + + {key} + + + ))} +
+
+ ))} +
+
+ ))} +
+
+
+ ) +} diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts index e3529f7..f8045d7 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -11,6 +11,7 @@ export { PromptEnhanceModal } from "./PromptEnhanceModal" export { CollectionPicker } from "./CollectionPicker" export { CollectionManager } from "./CollectionManager" export { OfflineIndicator } from "./OfflineIndicator" +export { KeyboardShortcutsHelp } from "./KeyboardShortcutsHelp" export { Button } from "./ui/button" export { Input } from "./ui/input" export { Textarea } from "./ui/textarea" diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts index 37a9260..366bb85 100644 --- a/frontend/src/hooks/index.ts +++ b/frontend/src/hooks/index.ts @@ -12,6 +12,8 @@ export { useBatch } from "./useBatch" export { useCollections } from "./useCollections" export { useSEO, usePageTitle } from "./useSEO" export { useOnlineStatus } from "./useOnlineStatus" +export { useKeyboardShortcuts, getModifierLabel } from "./useKeyboardShortcuts" +export type { ShortcutDef } from "./useKeyboardShortcuts" export type { Language } from "./useLanguage" export type { User } from "./useAuth" export type { Order, CreateOrderInput, CreateOrderResponse, VerifyPaymentInput, VerifyPaymentResponse } from "./useOrders" diff --git a/frontend/src/hooks/useKeyboardShortcuts.ts b/frontend/src/hooks/useKeyboardShortcuts.ts new file mode 100644 index 0000000..7082a45 --- /dev/null +++ b/frontend/src/hooks/useKeyboardShortcuts.ts @@ -0,0 +1,98 @@ +import { useEffect, useState, useCallback, useMemo } from "react" + +export interface ShortcutDef { + key: string + ctrl?: boolean + handler: () => void + description: string + category: string + allowInInput?: boolean + when?: () => boolean +} + +interface UseKeyboardShortcutsOptions { + enabled?: boolean +} + +const isMac = typeof navigator !== "undefined" && /Mac|iPod|iPhone|iPad/.test(navigator.platform) + +export function getModifierLabel() { + return isMac ? "Cmd" : "Ctrl" +} + +function isInputElement(el: Element | null): boolean { + if (!el) return false + const tag = el.tagName + if (tag === "INPUT" || tag === "TEXTAREA") return true + if ((el as HTMLElement).isContentEditable) return true + return false +} + +export function useKeyboardShortcuts( + shortcuts: ShortcutDef[], + options: UseKeyboardShortcutsOptions = {} +) { + const { enabled = true } = options + const [isHelpOpen, setIsHelpOpen] = useState(false) + + const closeHelp = useCallback(() => setIsHelpOpen(false), []) + const openHelp = useCallback(() => setIsHelpOpen(true), []) + + // Memoize shortcuts to avoid re-registering on every render + const stableShortcuts = useMemo(() => shortcuts, [JSON.stringify(shortcuts.map(s => ({ key: s.key, ctrl: s.ctrl, category: s.category, description: s.description, allowInInput: s.allowInInput })))]) + + useEffect(() => { + if (!enabled) return + + const handleKeyDown = (e: KeyboardEvent) => { + // Help overlay toggle + if (e.key === "?" && !e.ctrlKey && !e.metaKey && !e.altKey) { + if (isInputElement(document.activeElement)) return + e.preventDefault() + setIsHelpOpen(prev => !prev) + return + } + + // Esc closes help + if (e.key === "Escape" && isHelpOpen) { + e.preventDefault() + setIsHelpOpen(false) + return + } + + // Don't process shortcuts when help is open (except Esc handled above) + if (isHelpOpen) return + + for (const shortcut of stableShortcuts) { + const ctrlMatch = shortcut.ctrl + ? (isMac ? e.metaKey : e.ctrlKey) + : (!e.ctrlKey && !e.metaKey) + + if ( + e.key.toLowerCase() === shortcut.key.toLowerCase() && + ctrlMatch && + !e.altKey + ) { + // Guard against inputs + if (!shortcut.allowInInput && isInputElement(document.activeElement)) { + continue + } + + // Conditional activation + if (shortcut.when && !shortcut.when()) { + continue + } + + e.preventDefault() + shortcut.handler() + return + } + } + } + + document.addEventListener("keydown", handleKeyDown) + return () => document.removeEventListener("keydown", handleKeyDown) + }, [enabled, stableShortcuts, isHelpOpen]) + + return { isHelpOpen, closeHelp, openHelp } +} diff --git a/frontend/src/hooks/useLanguage.ts b/frontend/src/hooks/useLanguage.ts index c473a38..a93fe06 100644 --- a/frontend/src/hooks/useLanguage.ts +++ b/frontend/src/hooks/useLanguage.ts @@ -444,6 +444,28 @@ export const translations = { save: "Save", cancel: "Cancel", }, + // Keyboard Shortcuts + shortcuts: { + title: "Keyboard Shortcuts", + navigation: "Navigation", + newChat: "New Chat", + chat: "Chat", + sendMessage: "Send message", + newLine: "New line", + gallery: "Gallery", + focusSearch: "Focus search", + refreshGallery: "Refresh gallery", + imageViewer: "Image Viewer", + previousImage: "Previous image", + nextImage: "Next image", + toggleFavorite: "Toggle favorite", + deleteImage: "Delete image", + addToCollection: "Add to collection", + exportImage: "Export image", + closeModal: "Close", + general: "General", + showShortcuts: "Show keyboard shortcuts", + }, // Prompt Enhancement enhancePromptTitle: "Enhance Your Prompt?", originalPrompt: "Your Prompt", @@ -894,6 +916,28 @@ export const translations = { save: "保存", cancel: "取消", }, + // Keyboard Shortcuts + shortcuts: { + title: "键盘快捷键", + navigation: "导航", + newChat: "新对话", + chat: "聊天", + sendMessage: "发送消息", + newLine: "换行", + gallery: "图库", + focusSearch: "聚焦搜索", + refreshGallery: "刷新图库", + imageViewer: "图片查看器", + previousImage: "上一张", + nextImage: "下一张", + toggleFavorite: "切换收藏", + deleteImage: "删除图片", + addToCollection: "添加到合集", + exportImage: "导出图片", + closeModal: "关闭", + general: "通用", + showShortcuts: "显示键盘快捷键", + }, // Prompt Enhancement enhancePromptTitle: "增强您的提示词?", originalPrompt: "您的提示词", diff --git a/frontend/src/pages/GalleryPage.tsx b/frontend/src/pages/GalleryPage.tsx index c8ff0ac..f9e3aef 100644 --- a/frontend/src/pages/GalleryPage.tsx +++ b/frontend/src/pages/GalleryPage.tsx @@ -49,6 +49,7 @@ export function GalleryPage({ onNavigate, onLogout }: GalleryPageProps) { const [uploadError, setUploadError] = useState(null) const [showCollectionManager, setShowCollectionManager] = useState(false) const fileInputRef = useRef(null) + const searchInputRef = useRef(null) const [userTimezone, setUserTimezone] = useState(() => detectBrowserTimezone()) // Fetch user's saved timezone (only use if explicitly set, not default UTC) @@ -71,6 +72,34 @@ export function GalleryPage({ onNavigate, onLogout }: GalleryPageProps) { fetchTimezone() }, [authFetch]) + // Gallery keyboard shortcuts: / to focus search, R to refresh + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // Skip when modal or collection manager is open + if (isModalOpen || showCollectionManager) return + + // Skip when focus is in an input/textarea + const tag = (document.activeElement as HTMLElement)?.tagName + if (tag === "INPUT" || tag === "TEXTAREA") return + if ((document.activeElement as HTMLElement)?.isContentEditable) return + + if (e.key === "/" && !e.ctrlKey && !e.metaKey && !e.altKey) { + e.preventDefault() + searchInputRef.current?.focus() + return + } + + if (e.key.toLowerCase() === "r" && !e.ctrlKey && !e.metaKey && !e.altKey) { + e.preventDefault() + refresh() + return + } + } + + document.addEventListener("keydown", handleKeyDown) + return () => document.removeEventListener("keydown", handleKeyDown) + }, [isModalOpen, showCollectionManager, refresh]) + const dateGroups = useMemo(() => { const locale = language === "zh" ? "zh-CN" : "en-US" return groupImagesByDate(images, userTimezone, locale, { @@ -307,6 +336,7 @@ export function GalleryPage({ onNavigate, onLogout }: GalleryPageProps) {
void + onLogout?: () => void +} + +interface ShortcutEntry { + keys: string[] + description: string +} + +interface ShortcutGroup { + title: string + shortcuts: ShortcutEntry[] +} + +export function KeyboardShortcutsPage({ onNavigate, onLogout }: KeyboardShortcutsPageProps) { + const { t } = useLanguage() + const mod = getModifierLabel() + + const groups: ShortcutGroup[] = [ + { + title: t.shortcuts.navigation, + shortcuts: [ + { keys: [mod, "N"], description: t.shortcuts.newChat }, + ], + }, + { + title: t.shortcuts.chat, + shortcuts: [ + { keys: ["Enter"], description: t.shortcuts.sendMessage }, + { keys: ["Shift", "Enter"], description: t.shortcuts.newLine }, + ], + }, + { + title: t.shortcuts.gallery, + shortcuts: [ + { keys: ["/"], description: t.shortcuts.focusSearch }, + { keys: ["R"], description: t.shortcuts.refreshGallery }, + ], + }, + { + title: t.shortcuts.imageViewer, + shortcuts: [ + { keys: ["\u2190"], description: t.shortcuts.previousImage }, + { keys: ["\u2192"], description: t.shortcuts.nextImage }, + { keys: ["F"], description: t.shortcuts.toggleFavorite }, + { keys: ["D"], description: t.shortcuts.deleteImage }, + { keys: ["C"], description: t.shortcuts.addToCollection }, + { keys: ["E"], description: t.shortcuts.exportImage }, + { keys: ["Esc"], description: t.shortcuts.closeModal }, + ], + }, + { + title: t.shortcuts.general, + shortcuts: [ + { keys: ["?"], description: t.shortcuts.showShortcuts }, + ], + }, + ] + + return ( +
+ + +
+
+
+ {groups.map((group) => ( +
+

+ {group.title} +

+
+ {group.shortcuts.map((shortcut, i) => ( +
0 ? "border-t border-border" : "" + }`} + > + + {shortcut.description} + +
+ {shortcut.keys.map((key, j) => ( + + {j > 0 && ( + + + )} + + {key} + + + ))} +
+
+ ))} +
+
+ ))} +
+
+
+
+ ) +} diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index 5f1bef8..fafb03c 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -1,12 +1,13 @@ import { useState, useEffect, FormEvent } from "react" -import { Settings, Loader2, Check, Copy, ExternalLink, Eye, EyeOff, Monitor, Lock, Plus, Trash2, Star, StarOff, Edit2, X, User, Mail, Globe, MapPin } from "lucide-react" +import { Settings, Loader2, Check, Copy, ExternalLink, Eye, EyeOff, Monitor, Lock, Plus, Trash2, Star, StarOff, Edit2, X, User, Mail, Globe, MapPin, Keyboard, ChevronRight } from "lucide-react" import { Button } from "../components/ui/button" import { PageHeader } from "../components/PageHeader" import { cn } from "../lib/utils" import { useAuth } from "../hooks/useAuth" +import { useLanguage } from "../hooks/useLanguage" import { TIMEZONE_OPTIONS, detectBrowserTimezone, getTimezoneLabel } from "../utils" -type AppPage = "chat" | "gallery" | "schedule" | "batch" | "orders" | "subscription" | "settings" +type AppPage = "chat" | "gallery" | "schedule" | "batch" | "orders" | "subscription" | "settings" | "keyboard-shortcuts" interface SettingsPageProps { userId: number @@ -79,6 +80,7 @@ interface Device { export function SettingsPage({ userId, onNavigate, onLogout, translations: t }: SettingsPageProps) { const { authFetch, user } = useAuth() + const { t: fullT } = useLanguage() // Devices state const [devices, setDevices] = useState([]) @@ -829,6 +831,30 @@ export function SettingsPage({ userId, onNavigate, onLogout, translations: t }:
+ + {/* Divider */} +
+
+
+
+
+ + {/* Keyboard Shortcuts Link */} +

From 128dd7661553539debde5bede558a1d01c28e701 Mon Sep 17 00:00:00 2001 From: kenken64 Date: Wed, 28 Jan 2026 11:57:40 +0800 Subject: [PATCH 10/11] fix: move useCallback hooks before early return in ImageDetailModal The useCallback hooks were placed after the conditional early return, violating React's rules of hooks and causing a runtime error when the gallery loaded the modal component. Co-Authored-By: Claude Opus 4.5 --- frontend/src/components/ImageDetailModal.tsx | 138 +++++++++---------- 1 file changed, 69 insertions(+), 69 deletions(-) diff --git a/frontend/src/components/ImageDetailModal.tsx b/frontend/src/components/ImageDetailModal.tsx index 8339220..396d25b 100644 --- a/frontend/src/components/ImageDetailModal.tsx +++ b/frontend/src/components/ImageDetailModal.tsx @@ -91,6 +91,75 @@ export function ImageDetailModal({ setSelectedDevices([]) }, [image?.id]) + // Action callbacks (must be before early return since they are hooks) + const handleExport = useCallback(async (format: "png" | "jpg" | "webp") => { + if (!token || isExporting || !image) return + setIsExporting(true) + setShowExportMenu(false) + + try { + const response = await fetch( + `/api/gallery/export/${image.id}?format=${format}&quality=90`, + { + headers: { + Authorization: `Bearer ${token}`, + }, + } + ) + + if (!response.ok) { + throw new Error("Export failed") + } + + const blob = await response.blob() + const url = window.URL.createObjectURL(blob) + const a = document.createElement("a") + a.href = url + + // Extract filename from Content-Disposition header or generate one + const contentDisposition = response.headers.get("Content-Disposition") + let filename = `promptink-${image.id}.${format}` + if (contentDisposition) { + const match = contentDisposition.match(/filename="(.+)"/) + if (match) filename = match[1] + } + + a.download = filename + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + window.URL.revokeObjectURL(url) + } catch (err) { + console.error("Export failed:", err) + } finally { + setIsExporting(false) + } + }, [token, isExporting, image]) + + const handleDownload = useCallback(async () => { + await handleExport("png") + }, [handleExport]) + + const handleFavorite = useCallback(async () => { + if (!image) return + try { + await onToggleFavorite(image.id) + } catch (err) { + console.error("Failed to toggle favorite:", err) + } + }, [image, onToggleFavorite]) + + const handleDelete = useCallback(async () => { + if (!image) return + if (!confirm(t.gallery.confirmDelete)) return + try { + await onDelete(image.id) + onClose() + } catch (err) { + console.error("Failed to delete:", err) + } + }, [image, t.gallery.confirmDelete, onDelete, onClose]) + // Handle keyboard navigation and shortcuts const handleKeyDown = useCallback((e: KeyboardEvent) => { if (!isOpen) return @@ -162,75 +231,6 @@ export function ImageDetailModal({ if (!isOpen || !image) return null - const handleExport = useCallback(async (format: "png" | "jpg" | "webp") => { - if (!token || isExporting || !image) return - setIsExporting(true) - setShowExportMenu(false) - - try { - const response = await fetch( - `/api/gallery/export/${image.id}?format=${format}&quality=90`, - { - headers: { - Authorization: `Bearer ${token}`, - }, - } - ) - - if (!response.ok) { - throw new Error("Export failed") - } - - const blob = await response.blob() - const url = window.URL.createObjectURL(blob) - const a = document.createElement("a") - a.href = url - - // Extract filename from Content-Disposition header or generate one - const contentDisposition = response.headers.get("Content-Disposition") - let filename = `promptink-${image.id}.${format}` - if (contentDisposition) { - const match = contentDisposition.match(/filename="(.+)"/) - if (match) filename = match[1] - } - - a.download = filename - document.body.appendChild(a) - a.click() - document.body.removeChild(a) - window.URL.revokeObjectURL(url) - } catch (err) { - console.error("Export failed:", err) - } finally { - setIsExporting(false) - } - }, [token, isExporting, image]) - - const handleDownload = useCallback(async () => { - // Default to PNG for backward compatibility - await handleExport("png") - }, [handleExport]) - - const handleFavorite = useCallback(async () => { - if (!image) return - try { - await onToggleFavorite(image.id) - } catch (err) { - console.error("Failed to toggle favorite:", err) - } - }, [image, onToggleFavorite]) - - const handleDelete = useCallback(async () => { - if (!image) return - if (!confirm(t.gallery.confirmDelete)) return - try { - await onDelete(image.id) - onClose() - } catch (err) { - console.error("Failed to delete:", err) - } - }, [image, t.gallery.confirmDelete, onDelete, onClose]) - const formatDate = (dateStr: string) => { // Function handles fallback to browser timezone when userTimezone is empty return formatDateTimeInTimezone(dateStr, userTimezone || "", { From 52b3ba41fcd3b853f4ab4cc4da7476de502963f4 Mon Sep 17 00:00:00 2001 From: kenken64 Date: Wed, 28 Jan 2026 13:30:16 +0800 Subject: [PATCH 11/11] docs: update future enhancements with completed features Mark keyboard shortcuts, collections, and prompt enhancement AI as completed. Expand generation history timeline description with detailed concept explanation. Add three newly completed features to the completed features list. Co-Authored-By: Claude Opus 4.5 --- docs/ARCHITECTURE.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 95c9947..1c2a932 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1540,24 +1540,27 @@ k6 run -e BASE_URL=https://promptink-production.up.railway.app k6/scripts/load-t - [x] Admin dashboard with data export/import - [x] URL migration for server transfers - [x] Infographic generation from GitHub repos (Repomix integration) +- [x] Image collections/folders for gallery organization +- [x] Prompt enhancement AI (GPT-powered prompt improvement) +- [x] Keyboard shortcuts with help overlay and dedicated settings page ## Future Enhancements ### Quick Wins - [ ] **Image templates** - Pre-defined prompt templates users can customize (e.g., "Profile picture", "Product photo", "Book cover") - [ ] **Prompt history autocomplete** - Suggest previously used prompts as user types -- [ ] **Keyboard shortcuts** - Quick actions (Ctrl+Enter to generate, Ctrl+S to sync, etc.) +- [x] **Keyboard shortcuts** - Global shortcuts (`Ctrl+N` new chat, `?` help overlay), gallery shortcuts (`/` focus search, `R` refresh), image modal shortcuts (`F` favorite, `D` delete, `C` collection, `E` export). Dedicated shortcuts page accessible from Settings. *(Implemented: `useKeyboardShortcuts` hook, `KeyboardShortcutsHelp` overlay, `KeyboardShortcutsPage`)* - [ ] **Image comparison view** - Side-by-side comparison of original vs edited images ### User Experience -- [ ] **Collections/folders** - Organize gallery images into custom collections +- [x] **Collections/folders** - Organize gallery images into custom collections with create, rename, delete, and per-image add/remove. Collection chips in gallery filter bar. *(Implemented: `CollectionPicker`, `CollectionManager`, `useCollections` hook)* - [ ] **Bulk operations** - Select multiple gallery images for batch delete, export, or share - [ ] **Image tagging** - Add custom tags to images for better organization and search -- [ ] **Generation history timeline** - Visual timeline view of all generated images +- [ ] **Generation history timeline** - A vertical, chronologically-ordered timeline view as an alternative to the current date-grouped gallery grid. Each entry would show the timestamp on a vertical line, image thumbnail, full prompt, and metadata (model, size, style, source: manual/batch/scheduled). The timeline would surface the *progression* of a user's creative work — how prompts evolved across a session, which edits branched from which originals — whereas the gallery focuses on browsing and finding images. This is essentially a journal/narrative view of the generation history, complementing the existing portfolio/library view. - [ ] **Undo/redo for edits** - Track edit history and allow reverting changes ### Advanced Features -- [ ] **Prompt enhancement AI** - Use GPT to improve user prompts before generation +- [x] **Prompt enhancement AI** - GPT-powered prompt improvement before generation. Shows original vs enhanced side-by-side in a modal, letting the user choose. *(Implemented: `PromptEnhanceModal`, `/api/prompt/enhance` endpoint)* - [ ] **Image upscaling** - AI-powered upscaling for higher resolution exports - [ ] **Background removal** - Automatic background removal tool - [ ] **Inpainting improvements** - Better mask drawing tools with brush sizes