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/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/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/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(/ 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 => { @@ -63,13 +67,13 @@ const getInitialPage = (): AppPage => { 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 +86,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) @@ -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) { @@ -180,16 +204,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 +458,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) => { @@ -1032,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() @@ -1049,6 +1087,10 @@ export default function App() { > {renderCurrentPage()} + ) } diff --git a/frontend/src/components/CollectionManager.tsx b/frontend/src/components/CollectionManager.tsx new file mode 100644 index 0000000..535f30d --- /dev/null +++ b/frontend/src/components/CollectionManager.tsx @@ -0,0 +1,253 @@ +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 ( +
+
+
+ {/* Drag handle for mobile */} +
+
+
+ + {/* 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..dad8214 --- /dev/null +++ b/frontend/src/components/CollectionPicker.tsx @@ -0,0 +1,207 @@ +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 + onChange?: () => void +} + +export function CollectionPicker({ imageId, isOpen, onClose, onChange }: 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]) + } + onChange?.() + } 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("") + onChange?.() + } catch { + // Error already logged + } finally { + setIsCreating(false) + } + } + + return ( +
+
+
+ {/* Drag handle for mobile */} +
+
+
+ + {/* 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..715b03e 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() { @@ -34,6 +35,7 @@ interface GalleryCardProps { onToggleFavorite: (imageId: number) => Promise onDelete: (imageId: number) => Promise userTimezone?: string + onCollectionsChange?: () => void } export const GalleryCard = memo(function GalleryCard({ @@ -42,12 +44,14 @@ export const GalleryCard = memo(function GalleryCard({ onToggleFavorite, onDelete, userTimezone, + onCollectionsChange, }: GalleryCardProps) { const { t } = useLanguage() const [isDeleting, setIsDeleting] = useState(false) 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 +149,20 @@ export const GalleryCard = memo(function GalleryCard({ > {/* Top actions */}
+
+ + {/* Collection picker modal */} + {showCollectionPicker && ( + { + setShowCollectionPicker(false) + }} + onChange={onCollectionsChange} + /> + )} ) }) diff --git a/frontend/src/components/ImageDetailModal.tsx b/frontend/src/components/ImageDetailModal.tsx index c3c9786..396d25b 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" @@ -40,6 +41,7 @@ interface ImageDetailModalProps { hasPrev?: boolean hasNext?: boolean userTimezone?: string + onCollectionsChange?: () => void } export function ImageDetailModal({ @@ -52,6 +54,7 @@ export function ImageDetailModal({ hasPrev = false, hasNext = false, userTimezone, + onCollectionsChange, }: ImageDetailModalProps) { const { t } = useLanguage() const { accessToken: token } = useAuth() @@ -62,6 +65,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) @@ -87,47 +91,12 @@ export function ImageDetailModal({ setSelectedDevices([]) }, [image?.id]) - // Handle keyboard navigation - const handleKeyDown = useCallback((e: KeyboardEvent) => { - if (!isOpen) return - - switch (e.key) { - case "Escape": - onClose() - break - case "ArrowLeft": - if (hasPrev && onNavigate) onNavigate("prev") - break - case "ArrowRight": - if (hasNext && onNavigate) onNavigate("next") - break - } - }, [isOpen, onClose, onNavigate, hasPrev, hasNext]) - - useEffect(() => { - document.addEventListener("keydown", handleKeyDown) - return () => document.removeEventListener("keydown", handleKeyDown) - }, [handleKeyDown]) - - // Prevent body scroll when modal is open - useEffect(() => { - if (isOpen) { - document.body.style.overflow = "hidden" - } else { - document.body.style.overflow = "" - } - return () => { - document.body.style.overflow = "" - } - }, [isOpen]) - - if (!isOpen || !image) return null - - const handleExport = async (format: "png" | "jpg" | "webp") => { - if (!token || isExporting) return + // 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`, @@ -137,16 +106,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}` @@ -154,7 +123,7 @@ export function ImageDetailModal({ const match = contentDisposition.match(/filename="(.+)"/) if (match) filename = match[1] } - + a.download = filename document.body.appendChild(a) a.click() @@ -165,22 +134,23 @@ export function ImageDetailModal({ } finally { setIsExporting(false) } - } + }, [token, isExporting, image]) - const handleDownload = async () => { - // Default to PNG for backward compatibility + const handleDownload = useCallback(async () => { 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) @@ -188,7 +158,78 @@ export function ImageDetailModal({ } 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 + + // 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() + break + case "ArrowLeft": + if (hasPrev && onNavigate) onNavigate("prev") + break + 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, handleFavorite, handleDelete, handleDownload, showCollectionPicker]) + + useEffect(() => { + document.addEventListener("keydown", handleKeyDown) + return () => document.removeEventListener("keydown", handleKeyDown) + }, [handleKeyDown]) + + // Prevent body scroll when modal is open + useEffect(() => { + if (isOpen) { + document.body.style.overflow = "hidden" + } else { + document.body.style.overflow = "" + } + return () => { + document.body.style.overflow = "" + } + }, [isOpen]) + + if (!isOpen || !image) return null const formatDate = (dateStr: string) => { // Function handles fallback to browser timezone when userTimezone is empty @@ -650,6 +691,18 @@ export function ImageDetailModal({ )}
+ +
+ + {/* Collection picker modal */} + {showCollectionPicker && image && ( + { + setShowCollectionPicker(false) + }} + onChange={onCollectionsChange} + /> + )}
) } 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 6ee840a..f8045d7 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -8,7 +8,10 @@ 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 { 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 fac1b8e..366bb85 100644 --- a/frontend/src/hooks/index.ts +++ b/frontend/src/hooks/index.ts @@ -9,8 +9,11 @@ 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 { 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" @@ -18,3 +21,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/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 d5633b4..a93fe06 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", @@ -347,6 +351,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", @@ -412,6 +422,50 @@ 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", + }, + // 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", @@ -514,6 +568,10 @@ export const translations = { signIn: "登录", passwordMismatch: "密码不匹配", passwordTooShort: "密码至少需要6个字符", + emailAlreadyExists: "该邮箱已注册,请直接登录。", + invalidEmail: "请输入有效的邮箱地址。", + weakPassword: "密码至少需要8个字符,并包含数字或特殊字符。", + genericError: "注册失败,请重试。", }, forgotPassword: { title: "忘记密码", @@ -765,6 +823,12 @@ export const translations = { size: "尺寸", style: "风格", created: "创建时间", + today: "今天", + yesterday: "昨天", + thisWeek: "本周", + thisMonth: "本月", + imageCount: "{count} 张图片", + imageCountSingular: "{count} 张图片", }, schedule: { title: "定时任务", @@ -830,6 +894,50 @@ 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: "取消", + }, + // 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 e90a1b4..f9e3aef 100644 --- a/frontend/src/pages/GalleryPage.tsx +++ b/frontend/src/pages/GalleryPage.tsx @@ -1,14 +1,16 @@ -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" 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 { detectBrowserTimezone } from "../utils" +import { RefreshCw, Upload, FolderOpen, Settings2 } from "lucide-react" +import { detectBrowserTimezone, groupImagesByDate } from "../utils" type AppPage = "chat" | "gallery" | "schedule" | "batch" | "orders" | "subscription" | "settings" @@ -18,7 +20,7 @@ interface GalleryPageProps { } export function GalleryPage({ onNavigate, onLogout }: GalleryPageProps) { - const { t } = useLanguage() + const { t, language } = useLanguage() const { authFetch } = useAuth() const { images, @@ -28,6 +30,8 @@ export function GalleryPage({ onNavigate, onLogout }: GalleryPageProps) { error, filter, setFilter, + collectionId, + setCollectionId, searchQuery, setSearchQuery, toggleFavorite, @@ -36,13 +40,16 @@ export function GalleryPage({ onNavigate, onLogout }: GalleryPageProps) { loadMore, refresh, } = useGallery() + const { collections, refresh: refreshCollections } = 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 searchInputRef = useRef(null) const [userTimezone, setUserTimezone] = useState(() => detectBrowserTimezone()) // Fetch user's saved timezone (only use if explicitly set, not default UTC) @@ -65,6 +72,44 @@ 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, { + 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) @@ -206,47 +251,92 @@ export function GalleryPage({ onNavigate, onLogout }: GalleryPageProps) { )} {/* Filters & Search */} -
- {/* Filter tabs */} -
- +
+ {/* Filter tabs + collection chips - horizontally scrollable on mobile */} +
+ +
+ + +
+ + {/* Collection chips */} + {collections.map((c) => ( + + ))} + + {/* Manage collections button - icon only on mobile, text on desktop */}
{/* Search */} -
+
- {(searchQuery || filter === "favorites") && ( + {(searchQuery || filter === "favorites" || collectionId) && (
)} - {/* 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) => ( + + ))} +
+
))}
@@ -465,6 +572,16 @@ export function GalleryPage({ onNavigate, onLogout }: GalleryPageProps) { hasPrev={currentIndex > 0} hasNext={currentIndex < images.length - 1} userTimezone={userTimezone} + onCollectionsChange={refreshCollections} + /> + + {/* Collection manager modal */} + { + setShowCollectionManager(false) + refreshCollections() + }} />
) diff --git a/frontend/src/pages/KeyboardShortcutsPage.tsx b/frontend/src/pages/KeyboardShortcutsPage.tsx new file mode 100644 index 0000000..f4c1a16 --- /dev/null +++ b/frontend/src/pages/KeyboardShortcutsPage.tsx @@ -0,0 +1,117 @@ +import { useLanguage } from "../hooks/useLanguage" +import { getModifierLabel } from "../hooks/useKeyboardShortcuts" +import { PageHeader } from "../components/PageHeader" + +type AppPage = "chat" | "gallery" | "schedule" | "batch" | "orders" | "subscription" | "settings" + +interface KeyboardShortcutsPageProps { + onNavigate: (page: AppPage) => 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/RegisterPage.tsx b/frontend/src/pages/RegisterPage.tsx index 91d603a..46acd42 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.startsWith("Password must")) 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) } 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 */} +

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"