Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions backend/bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"typescript": "^5"
},
"dependencies": {
"jszip": "3.10.1",
"repomix": "^0.3.4",
"sharp": "0.34.5"
}
Expand Down
40 changes: 40 additions & 0 deletions backend/src/db/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -660,6 +660,34 @@ export function initDatabase() {
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)`)

// Shared galleries table (for bulk sharing multiple images)
db.run(`
CREATE TABLE IF NOT EXISTS shared_galleries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
share_id TEXT UNIQUE NOT NULL,
user_id INTEGER NOT NULL,
title TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME,
view_count INTEGER DEFAULT 0,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`)
db.run(`CREATE INDEX IF NOT EXISTS idx_shared_galleries_share_id ON shared_galleries(share_id)`)

// Shared gallery images join table
db.run(`
CREATE TABLE IF NOT EXISTS shared_gallery_images (
id INTEGER PRIMARY KEY AUTOINCREMENT,
gallery_share_id TEXT NOT NULL,
image_id INTEGER NOT NULL,
sort_order INTEGER DEFAULT 0,
FOREIGN KEY (gallery_share_id) REFERENCES shared_galleries(share_id) ON DELETE CASCADE,
FOREIGN KEY (image_id) REFERENCES generated_images(id) ON DELETE CASCADE
)
`)
db.run(`CREATE INDEX IF NOT EXISTS idx_shared_gallery_images_share_id ON shared_gallery_images(gallery_share_id)`)

// Initialize prepared statements after tables are created
initPreparedStatements()

Expand Down Expand Up @@ -1275,6 +1303,18 @@ export const generatedImageQueries = {
get softDelete() { return _generatedImageQueries.softDelete },
get updateImageUrl() { return _generatedImageQueries.updateImageUrl },
get search() { return _generatedImageQueries.search },
// Dynamic query helpers (variable IN clause length)
bulkSoftDelete(ids: number[], userId: number): number {
const placeholders = ids.map(() => "?").join(",")
const stmt = db.prepare(`UPDATE generated_images SET is_deleted = 1 WHERE id IN (${placeholders}) AND user_id = ?`)
const result = stmt.run(...ids, userId)
return result.changes
},
findByIds(ids: number[], userId: number): GeneratedImage[] {
const placeholders = ids.map(() => "?").join(",")
const stmt = db.prepare<GeneratedImage, any[]>(`SELECT * FROM generated_images WHERE id IN (${placeholders}) AND user_id = ? AND is_deleted = 0`)
return stmt.all(...ids, userId)
},
}

export const orderQueries = {
Expand Down
129 changes: 129 additions & 0 deletions backend/src/routes/gallery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { config } from "../config"
import { mkdirSync, existsSync } from "fs"
import { join } from "path"
import sharp from "sharp"
import JSZip from "jszip"

// Supported export formats
type ExportFormat = "png" | "jpg" | "webp"
Expand Down Expand Up @@ -545,6 +546,134 @@ export const galleryRoutes = {
}),
},

// Bulk delete images
"/api/gallery/bulk-delete": {
POST: withAuth(async (req, user) => {
try {
const text = await req.text()
const { ids } = text ? JSON.parse(text) : {}

if (!Array.isArray(ids) || ids.length === 0) {
return Response.json({ error: "ids array is required" }, { status: 400 })
}

if (ids.length > 50) {
return Response.json({ error: "Maximum 50 images per bulk delete" }, { status: 400 })
}

if (!ids.every((id: unknown) => Number.isInteger(id) && (id as number) > 0)) {
return Response.json({ error: "All ids must be positive integers" }, { status: 400 })
}

const deletedCount = generatedImageQueries.bulkSoftDelete(ids, user.id)

log("INFO", "Bulk deleted gallery images", { userId: user.id, requested: ids.length, deletedCount })

return Response.json({ success: true, deletedCount })
} catch (error) {
log("ERROR", "Failed to bulk delete gallery images", error)
return Response.json({ error: String(error) }, { status: 500 })
}
}),
},

// Bulk export images as ZIP
"/api/gallery/bulk-export": {
POST: withAuth(async (req, user) => {
try {
const text = await req.text()
const { ids, format: requestedFormat } = text ? JSON.parse(text) : {}
const format = (requestedFormat || "png") as ExportFormat

if (!Array.isArray(ids) || ids.length === 0) {
return Response.json({ error: "ids array is required" }, { status: 400 })
}

if (ids.length > 20) {
return Response.json({ error: "Maximum 20 images per bulk export" }, { status: 400 })
}

if (!ids.every((id: unknown) => Number.isInteger(id) && (id as number) > 0)) {
return Response.json({ error: "All ids must be positive integers" }, { status: 400 })
}

if (!SUPPORTED_FORMATS.includes(format)) {
return Response.json({
error: `Unsupported format. Supported: ${SUPPORTED_FORMATS.join(", ")}`
}, { status: 400 })
}

// Fetch images and verify ownership
const images = generatedImageQueries.findByIds(ids, user.id)

if (images.length === 0) {
return Response.json({ error: "No images found" }, { status: 404 })
}

const zip = new JSZip()

for (const image of images) {
const filePath = getGalleryImagePath(user.id, image.id)
const file = Bun.file(filePath)

if (!(await file.exists())) continue

const inputBuffer = Buffer.from(await file.arrayBuffer())

let outputBuffer: Buffer
let extension: string

switch (format) {
case "jpg":
outputBuffer = await sharp(inputBuffer).jpeg({ quality: 85 }).toBuffer()
extension = "jpg"
break
case "webp":
outputBuffer = await sharp(inputBuffer).webp({ quality: 85 }).toBuffer()
extension = "webp"
break
default:
outputBuffer = await sharp(inputBuffer).png().toBuffer()
extension = "png"
}

const promptSlug = image.original_prompt
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.substring(0, 30)
.replace(/-+$/, "")
const filename = `promptink-${image.id}-${promptSlug}.${extension}`

zip.file(filename, outputBuffer)
}

const zipBuffer = await zip.generateAsync({
type: "nodebuffer",
compression: "DEFLATE",
compressionOptions: { level: 6 },
})

log("INFO", "Bulk exported gallery images", {
userId: user.id,
imageCount: images.length,
format,
zipSize: zipBuffer.length,
})

return new Response(zipBuffer, {
headers: {
"Content-Type": "application/zip",
"Content-Disposition": `attachment; filename="promptink-export-${Date.now()}.zip"`,
"Content-Length": zipBuffer.length.toString(),
},
})
} catch (error) {
log("ERROR", "Failed to bulk export gallery images", error)
return Response.json({ error: String(error) }, { status: 500 })
}
}),
},

// Debug endpoint to diagnose gallery issues (protected)
"/api/gallery/debug": {
GET: withAuth(async (req, user) => {
Expand Down
Loading
Loading