Add portfolio thingies
This commit is contained in:
5
prisma/migrations/20251225010559_artwork_8/migration.sql
Normal file
5
prisma/migrations/20251225010559_artwork_8/migration.sql
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Artwork_published_sortKey_id_idx" ON "Artwork"("published", "sortKey", "id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Artwork_year_published_sortKey_id_idx" ON "Artwork"("year", "published", "sortKey", "id");
|
||||||
7
prisma/migrations/20251225013027_artwork_9/migration.sql
Normal file
7
prisma/migrations/20251225013027_artwork_9/migration.sql
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Artwork" ADD COLUMN "colorError" TEXT,
|
||||||
|
ADD COLUMN "colorStatus" TEXT NOT NULL DEFAULT 'PENDING',
|
||||||
|
ADD COLUMN "colorsGeneratedAt" TIMESTAMP(3);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Artwork_colorStatus_idx" ON "Artwork"("colorStatus");
|
||||||
@ -37,6 +37,10 @@ model Artwork {
|
|||||||
published Boolean @default(false)
|
published Boolean @default(false)
|
||||||
setAsHeader Boolean @default(false)
|
setAsHeader Boolean @default(false)
|
||||||
|
|
||||||
|
colorStatus String @default("PENDING") // PENDING | PROCESSING | READY | FAILED
|
||||||
|
colorError String?
|
||||||
|
colorsGeneratedAt DateTime?
|
||||||
|
|
||||||
fileId String @unique
|
fileId String @unique
|
||||||
file FileData @relation(fields: [fileId], references: [id])
|
file FileData @relation(fields: [fileId], references: [id])
|
||||||
|
|
||||||
@ -50,6 +54,10 @@ model Artwork {
|
|||||||
colors ArtworkColor[]
|
colors ArtworkColor[]
|
||||||
tags ArtTag[]
|
tags ArtTag[]
|
||||||
variants FileVariant[]
|
variants FileVariant[]
|
||||||
|
|
||||||
|
@@index([colorStatus])
|
||||||
|
@@index([published, sortKey, id])
|
||||||
|
@@index([year, published, sortKey, id])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Album {
|
model Album {
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
"use server"
|
"use server";
|
||||||
|
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { VibrantSwatch } from "@/types/VibrantSwatch";
|
import { VibrantSwatch } from "@/types/VibrantSwatch";
|
||||||
import { getImageBufferFromS3 } from "@/utils/getImageBufferFromS3";
|
import { getImageBufferFromS3Key } from "@/utils/getImageBufferFromS3";
|
||||||
import { generateColorName, rgbToHex } from "@/utils/uploadHelper";
|
import { generateColorName, rgbToHex } from "@/utils/uploadHelper";
|
||||||
import { converter, parse } from "culori";
|
import { converter, parse } from "culori";
|
||||||
import { Vibrant } from "node-vibrant/node";
|
import { Vibrant } from "node-vibrant/node";
|
||||||
@ -11,19 +11,14 @@ const toOklab = converter("oklab");
|
|||||||
const A_MIN = -0.5, A_MAX = 0.5;
|
const A_MIN = -0.5, A_MAX = 0.5;
|
||||||
const B_MIN = -0.5, B_MAX = 0.5;
|
const B_MIN = -0.5, B_MAX = 0.5;
|
||||||
|
|
||||||
function clamp01(x: number) {
|
function clamp01(x: number) { return Math.max(0, Math.min(1, x)); }
|
||||||
return Math.max(0, Math.min(1, x));
|
function norm(x: number, lo: number, hi: number) { return clamp01((x - lo) / (hi - lo)); }
|
||||||
}
|
|
||||||
|
|
||||||
function norm(x: number, lo: number, hi: number) {
|
|
||||||
return clamp01((x - lo) / (hi - lo));
|
|
||||||
}
|
|
||||||
|
|
||||||
function hilbertIndex15(x01: number, y01: number): number {
|
function hilbertIndex15(x01: number, y01: number): number {
|
||||||
let x = Math.floor(clamp01(x01) * 32767);
|
let x = Math.floor(clamp01(x01) * 32767);
|
||||||
let y = Math.floor(clamp01(y01) * 32767);
|
let y = Math.floor(clamp01(y01) * 32767);
|
||||||
let index = 0;
|
let index = 0;
|
||||||
for (let s = 1 << 14; s > 0; s >>= 1) { // start at bit 14
|
for (let s = 1 << 14; s > 0; s >>= 1) {
|
||||||
const rx = (x & s) ? 1 : 0;
|
const rx = (x & s) ? 1 : 0;
|
||||||
const ry = (y & s) ? 1 : 0;
|
const ry = (y & s) ? 1 : 0;
|
||||||
index += s * s * ((3 * rx) ^ ry);
|
index += s * s * ((3 * rx) ^ ry);
|
||||||
@ -36,7 +31,6 @@ function hilbertIndex15(x01: number, y01: number): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function centroidFromPaletteHexes(hexByType: Record<string, string | undefined>) {
|
function centroidFromPaletteHexes(hexByType: Record<string, string | undefined>) {
|
||||||
// Tweak weights as you like. Biasing toward Vibrant keeps things “readable”.
|
|
||||||
const weights: Record<string, number> = {
|
const weights: Record<string, number> = {
|
||||||
Vibrant: 0.7,
|
Vibrant: 0.7,
|
||||||
Muted: 0.15,
|
Muted: 0.15,
|
||||||
@ -46,7 +40,6 @@ function centroidFromPaletteHexes(hexByType: Record<string, string | undefined>)
|
|||||||
LightMuted: 0.01,
|
LightMuted: 0.01,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Ensure we have at least a vibrant color to anchor on
|
|
||||||
const fallbackHex =
|
const fallbackHex =
|
||||||
hexByType["Vibrant"] ||
|
hexByType["Vibrant"] ||
|
||||||
hexByType["Muted"] ||
|
hexByType["Muted"] ||
|
||||||
@ -57,8 +50,7 @@ function centroidFromPaletteHexes(hexByType: Record<string, string | undefined>)
|
|||||||
|
|
||||||
let L = 0, A = 0, B = 0, W = 0;
|
let L = 0, A = 0, B = 0, W = 0;
|
||||||
|
|
||||||
const entries = Object.entries(weights);
|
for (const [type, w] of Object.entries(weights)) {
|
||||||
for (const [type, w] of entries) {
|
|
||||||
const hex = hexByType[type] ?? fallbackHex;
|
const hex = hexByType[type] ?? fallbackHex;
|
||||||
if (!hex || w <= 0) continue;
|
if (!hex || w <= 0) continue;
|
||||||
const c = toOklab(parse(hex));
|
const c = toOklab(parse(hex));
|
||||||
@ -66,15 +58,36 @@ function centroidFromPaletteHexes(hexByType: Record<string, string | undefined>)
|
|||||||
L += c.l * w; A += c.a * w; B += c.b * w; W += w;
|
L += c.l * w; A += c.a * w; B += c.b * w; W += w;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (W === 0) {
|
if (W === 0) return { l: 0.5, a: 0, b: 0 };
|
||||||
// Should be rare; default to mid-gray
|
|
||||||
return { l: 0.5, a: 0, b: 0 };
|
|
||||||
}
|
|
||||||
return { l: L / W, a: A / W, b: B / W };
|
return { l: L / W, a: A / W, b: B / W };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateArtworkColors(artworkId: string, fileKey: string, fileType?: string) {
|
/**
|
||||||
const buffer = await getImageBufferFromS3(fileKey, fileType);
|
* Generates ArtworkColor rows + sortKey/okLab fields for an artwork.
|
||||||
|
* Safe to call multiple times.
|
||||||
|
*/
|
||||||
|
export async function generateArtworkColorsForArtwork(artworkId: string) {
|
||||||
|
// mark processing (optional but recommended)
|
||||||
|
await prisma.artwork.update({
|
||||||
|
where: { id: artworkId },
|
||||||
|
data: { colorStatus: "PROCESSING", colorError: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const artwork = await prisma.artwork.findUnique({
|
||||||
|
where: { id: artworkId },
|
||||||
|
select: {
|
||||||
|
file: { select: { fileKey: true } },
|
||||||
|
variants: { where: { type: "original" }, select: { s3Key: true }, take: 1 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!artwork) throw new Error("Artwork not found");
|
||||||
|
|
||||||
|
const original = artwork.variants[0];
|
||||||
|
if (!original?.s3Key) throw new Error("Missing modified variant s3Key");
|
||||||
|
|
||||||
|
const buffer = await getImageBufferFromS3Key(original.s3Key);
|
||||||
|
|
||||||
const palette = await Vibrant.from(buffer).getPalette();
|
const palette = await Vibrant.from(buffer).getPalette();
|
||||||
|
|
||||||
const vibrantHexes = Object.entries(palette).map(([key, swatch]) => {
|
const vibrantHexes = Object.entries(palette).map(([key, swatch]) => {
|
||||||
@ -92,58 +105,45 @@ export async function generateArtworkColors(artworkId: string, fileKey: string,
|
|||||||
|
|
||||||
const color = await prisma.color.upsert({
|
const color = await prisma.color.upsert({
|
||||||
where: { name },
|
where: { name },
|
||||||
create: {
|
create: { name, type, hex, red: r, green: g, blue: b },
|
||||||
name,
|
update: { hex, red: r, green: g, blue: b },
|
||||||
type,
|
|
||||||
hex,
|
|
||||||
red: r,
|
|
||||||
green: g,
|
|
||||||
blue: b,
|
|
||||||
},
|
|
||||||
update: {
|
|
||||||
hex,
|
|
||||||
red: r,
|
|
||||||
green: g,
|
|
||||||
blue: b,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await prisma.artworkColor.upsert({
|
await prisma.artworkColor.upsert({
|
||||||
where: {
|
where: { artworkId_type: { artworkId, type } },
|
||||||
artworkId_type: {
|
create: { artworkId, colorId: color.id, type },
|
||||||
artworkId,
|
update: { colorId: color.id },
|
||||||
type,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
artworkId,
|
|
||||||
colorId: color.id,
|
|
||||||
type,
|
|
||||||
},
|
|
||||||
update: {
|
|
||||||
colorId: color.id,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) Compute OKLab centroid → Hilbert sortKey (incremental-safe)
|
|
||||||
const hexByType: Record<string, string | undefined> = Object.fromEntries(
|
const hexByType: Record<string, string | undefined> = Object.fromEntries(
|
||||||
vibrantHexes.map(({ type, hex }) => [type, hex])
|
vibrantHexes.map(({ type, hex }) => [type, hex])
|
||||||
);
|
);
|
||||||
|
|
||||||
const { l, a, b } = centroidFromPaletteHexes(hexByType);
|
const { l, a, b } = centroidFromPaletteHexes(hexByType);
|
||||||
const ax = norm(a, A_MIN, A_MAX);
|
const sortKey = hilbertIndex15(norm(a, A_MIN, A_MAX), norm(b, B_MIN, B_MAX));
|
||||||
const bx = norm(b, B_MIN, B_MAX);
|
|
||||||
const sortKey = hilbertIndex15(ax, bx);
|
|
||||||
|
|
||||||
// 3) Store on the Image (plus optional OKLab fields)
|
|
||||||
await prisma.artwork.update({
|
await prisma.artwork.update({
|
||||||
where: { id: artworkId },
|
where: { id: artworkId },
|
||||||
data: { sortKey, okLabL: l, okLabA: a, okLabB: b },
|
data: {
|
||||||
|
sortKey,
|
||||||
|
okLabL: l,
|
||||||
|
okLabA: a,
|
||||||
|
okLabB: b,
|
||||||
|
colorStatus: "READY",
|
||||||
|
colorsGeneratedAt: new Date(),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return await prisma.artworkColor.findMany({
|
return { ok: true as const, sortKey };
|
||||||
where: { artworkId },
|
} catch (e) {
|
||||||
include: { color: true },
|
await prisma.artwork.update({
|
||||||
|
where: { id: artworkId },
|
||||||
|
data: {
|
||||||
|
colorStatus: "FAILED",
|
||||||
|
colorError: e instanceof Error ? e.message : "Color generation failed",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
return { ok: false as const, error: e instanceof Error ? e.message : "Color generation failed" };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
39
src/actions/colors/getArtworkColorStats.ts
Normal file
39
src/actions/colors/getArtworkColorStats.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export type ArtworkColorStats = {
|
||||||
|
total: number;
|
||||||
|
ready: number;
|
||||||
|
pending: number;
|
||||||
|
processing: number;
|
||||||
|
failed: number;
|
||||||
|
missingSortKey: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getArtworkColorStats(): Promise<ArtworkColorStats> {
|
||||||
|
const [
|
||||||
|
total,
|
||||||
|
ready,
|
||||||
|
pending,
|
||||||
|
processing,
|
||||||
|
failed,
|
||||||
|
missingSortKey,
|
||||||
|
] = await Promise.all([
|
||||||
|
prisma.artwork.count(),
|
||||||
|
prisma.artwork.count({ where: { colorStatus: "READY" } }),
|
||||||
|
prisma.artwork.count({ where: { colorStatus: "PENDING" } }),
|
||||||
|
prisma.artwork.count({ where: { colorStatus: "PROCESSING" } }),
|
||||||
|
prisma.artwork.count({ where: { colorStatus: "FAILED" } }),
|
||||||
|
prisma.artwork.count({ where: { sortKey: null } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
total,
|
||||||
|
ready,
|
||||||
|
pending,
|
||||||
|
processing,
|
||||||
|
failed,
|
||||||
|
missingSortKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
70
src/actions/colors/processPendingArtworkColors.ts
Normal file
70
src/actions/colors/processPendingArtworkColors.ts
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { Prisma } from "@/generated/prisma/client";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { generateArtworkColorsForArtwork } from "../artworks/generateArtworkColors";
|
||||||
|
|
||||||
|
export type ProcessColorsResult = {
|
||||||
|
picked: number;
|
||||||
|
processed: number;
|
||||||
|
ok: number;
|
||||||
|
failed: number;
|
||||||
|
results: Array<{ artworkId: string; ok: boolean; error?: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function processPendingArtworkColors(args?: {
|
||||||
|
limit?: number;
|
||||||
|
includeFailed?: boolean;
|
||||||
|
includeMissingSortKey?: boolean;
|
||||||
|
}): Promise<ProcessColorsResult> {
|
||||||
|
const limit = Math.min(Math.max(args?.limit ?? 10, 1), 50);
|
||||||
|
const includeFailed = args?.includeFailed ?? true;
|
||||||
|
const includeMissingSortKey = args?.includeMissingSortKey ?? true;
|
||||||
|
|
||||||
|
// Build OR as a mutable array with explicit Prisma typing
|
||||||
|
const or: Prisma.ArtworkWhereInput[] = [];
|
||||||
|
|
||||||
|
if (includeFailed) {
|
||||||
|
or.push({ colorStatus: { in: ["PENDING", "FAILED"] } });
|
||||||
|
} else {
|
||||||
|
or.push({ colorStatus: { equals: "PENDING" } });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (includeMissingSortKey) {
|
||||||
|
or.push({ sortKey: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
const where: Prisma.ArtworkWhereInput = {
|
||||||
|
OR: or,
|
||||||
|
variants: { some: { type: "modified" } },
|
||||||
|
};
|
||||||
|
|
||||||
|
const picked = await prisma.artwork.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
|
||||||
|
take: limit,
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const results: ProcessColorsResult["results"] = [];
|
||||||
|
|
||||||
|
for (const a of picked) {
|
||||||
|
const r = await generateArtworkColorsForArtwork(a.id);
|
||||||
|
results.push({
|
||||||
|
artworkId: a.id,
|
||||||
|
ok: r.ok,
|
||||||
|
...(r.ok ? {} : { error: r.error }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = results.filter((r) => r.ok).length;
|
||||||
|
const failed = results.length - ok;
|
||||||
|
|
||||||
|
return {
|
||||||
|
picked: picked.length,
|
||||||
|
processed: results.length,
|
||||||
|
ok,
|
||||||
|
failed,
|
||||||
|
results,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -21,7 +21,7 @@ export async function createImagesBulk(formData: FormData): Promise<BulkResult[]
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const artwork = await createImageFromFile(f);
|
const artwork = await createImageFromFile(f, { colorMode: "defer" });
|
||||||
if (!artwork) {
|
if (!artwork) {
|
||||||
results.push({ ok: false, name: f.name, error: "Upload failed" });
|
results.push({ ok: false, name: f.name, error: "Upload failed" });
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import { createImageFromFile } from "./createImageFromFile";
|
|||||||
|
|
||||||
export async function createImage(values: z.infer<typeof fileUploadSchema>) {
|
export async function createImage(values: z.infer<typeof fileUploadSchema>) {
|
||||||
const imageFile = values.file[0];
|
const imageFile = values.file[0];
|
||||||
return createImageFromFile(imageFile);
|
return createImageFromFile(imageFile, { colorMode: "inline" });
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
@ -6,8 +6,9 @@ import { PutObjectCommand } from "@aws-sdk/client-s3";
|
|||||||
import "dotenv/config";
|
import "dotenv/config";
|
||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
import { generateArtworkColorsForArtwork } from "../artworks/generateArtworkColors";
|
||||||
|
|
||||||
export async function createImageFromFile(imageFile: File, opts?: { originalName?: string }) {
|
export async function createImageFromFile(imageFile: File, opts?: { originalName?: string, colorMode?: "inline" | "defer" | "off" }) {
|
||||||
if (!(imageFile instanceof File)) {
|
if (!(imageFile instanceof File)) {
|
||||||
console.log("No image or invalid type");
|
console.log("No image or invalid type");
|
||||||
return null;
|
return null;
|
||||||
@ -126,6 +127,7 @@ export async function createImageFromFile(imageFile: File, opts?: { originalName
|
|||||||
slug: artworkSlug,
|
slug: artworkSlug,
|
||||||
creationDate: lastModified,
|
creationDate: lastModified,
|
||||||
fileId: fileRecord.id,
|
fileId: fileRecord.id,
|
||||||
|
colorStatus: "PENDING",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -194,5 +196,16 @@ export async function createImageFromFile(imageFile: File, opts?: { originalName
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const mode = opts?.colorMode ?? "defer";
|
||||||
|
|
||||||
|
if (mode === "inline") {
|
||||||
|
// blocks, but guarantees sortKey is ready immediately
|
||||||
|
await generateArtworkColorsForArtwork(artworkRecord.id);
|
||||||
|
} else if (mode === "defer") {
|
||||||
|
// mark pending; a separate job will process these
|
||||||
|
// (nothing else to do here)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
return artworkRecord;
|
return artworkRecord;
|
||||||
}
|
}
|
||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { ArtworkColorProcessor } from "@/components/artworks/ArtworkColorProcessor";
|
||||||
import { ArtworksTable } from "@/components/artworks/ArtworksTable";
|
import { ArtworksTable } from "@/components/artworks/ArtworksTable";
|
||||||
import { getArtworksPage } from "@/lib/queryArtworks";
|
import { getArtworksPage } from "@/lib/queryArtworks";
|
||||||
|
|
||||||
@ -56,6 +57,8 @@ export default async function ArtworksPage({
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<h1 className="text-2xl font-bold">Artworks</h1>
|
<h1 className="text-2xl font-bold">Artworks</h1>
|
||||||
|
{/* <ProcessArtworkColorsButton /> */}
|
||||||
|
<ArtworkColorProcessor />
|
||||||
<ArtworksTable />
|
<ArtworksTable />
|
||||||
</div>
|
</div>
|
||||||
// <div>
|
// <div>
|
||||||
|
|||||||
65
src/components/artworks/ArtworkColorProcessor.tsx
Normal file
65
src/components/artworks/ArtworkColorProcessor.tsx
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { getArtworkColorStats } from "@/actions/colors/getArtworkColorStats";
|
||||||
|
import { processPendingArtworkColors } from "@/actions/colors/processPendingArtworkColors";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
export function ArtworkColorProcessor() {
|
||||||
|
const [stats, setStats] = React.useState<Awaited<ReturnType<typeof getArtworkColorStats>> | null>(null);
|
||||||
|
const [loading, setLoading] = React.useState(false);
|
||||||
|
const [msg, setMsg] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
const refreshStats = async () => {
|
||||||
|
const s = await getArtworkColorStats();
|
||||||
|
setStats(s);
|
||||||
|
};
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
void refreshStats();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const run = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setMsg(null);
|
||||||
|
try {
|
||||||
|
const res = await processPendingArtworkColors({
|
||||||
|
limit: 50,
|
||||||
|
includeFailed: true,
|
||||||
|
includeMissingSortKey: true,
|
||||||
|
});
|
||||||
|
setMsg(`Processed ${res.processed}: ${res.ok} ok, ${res.failed} failed`);
|
||||||
|
await refreshStats();
|
||||||
|
} catch (e) {
|
||||||
|
setMsg(e instanceof Error ? e.message : "Failed");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const done =
|
||||||
|
stats &&
|
||||||
|
stats.pending === 0 &&
|
||||||
|
stats.processing === 0 &&
|
||||||
|
stats.failed === 0 &&
|
||||||
|
stats.missingSortKey === 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button onClick={run} disabled={loading || done}>
|
||||||
|
{done ? "All colors processed" : loading ? "Processing…" : "Process pending colors"}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{stats && (
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Ready {stats.ready}/{stats.total}
|
||||||
|
{stats.failed > 0 && ` · Failed ${stats.failed}`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{msg && <p className="text-sm text-muted-foreground">{msg}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -25,6 +25,17 @@ const artworkItems = [
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const commissionItems = [
|
||||||
|
{
|
||||||
|
title: "Commissions",
|
||||||
|
href: "/commissions",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Types",
|
||||||
|
href: "/commissions/types",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
// const portfolioItems = [
|
// const portfolioItems = [
|
||||||
// {
|
// {
|
||||||
// title: "Images",
|
// title: "Images",
|
||||||
@ -104,9 +115,22 @@ export default function TopNav() {
|
|||||||
</NavigationMenuItem>
|
</NavigationMenuItem>
|
||||||
|
|
||||||
<NavigationMenuItem>
|
<NavigationMenuItem>
|
||||||
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
|
<NavigationMenuTrigger>Commissions</NavigationMenuTrigger>
|
||||||
<Link href="/commissions">Commissions</Link>
|
<NavigationMenuContent>
|
||||||
|
<ul className="grid w-50 gap-4">
|
||||||
|
{commissionItems.map((item) => (
|
||||||
|
<li key={item.title}>
|
||||||
|
<NavigationMenuLink asChild>
|
||||||
|
<Link href={item.href}>
|
||||||
|
<div className="text-sm leading-none font-medium">{item.title}</div>
|
||||||
|
<p className="text-muted-foreground line-clamp-2 text-sm leading-snug">
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
</NavigationMenuLink>
|
</NavigationMenuLink>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</NavigationMenuContent>
|
||||||
</NavigationMenuItem>
|
</NavigationMenuItem>
|
||||||
|
|
||||||
{/* <NavigationMenuItem>
|
{/* <NavigationMenuItem>
|
||||||
|
|||||||
@ -1,23 +1,18 @@
|
|||||||
import { s3 } from "@/lib/s3";
|
import { s3 } from "@/lib/s3";
|
||||||
import { GetObjectCommand } from "@aws-sdk/client-s3";
|
import { GetObjectCommand } from "@aws-sdk/client-s3";
|
||||||
import "dotenv/config";
|
|
||||||
import { Readable } from "stream";
|
import { Readable } from "stream";
|
||||||
|
|
||||||
export async function getImageBufferFromS3(fileKey: string, fileType?: string): Promise<Buffer> {
|
export async function getImageBufferFromS3Key(s3Key: string): Promise<Buffer> {
|
||||||
// const type = fileType ? fileType.split("/")[1] : "webp";
|
|
||||||
|
|
||||||
const command = new GetObjectCommand({
|
const command = new GetObjectCommand({
|
||||||
Bucket: `${process.env.BUCKET_NAME}`,
|
Bucket: process.env.BUCKET_NAME!,
|
||||||
Key: `original/${fileKey}.${fileType}`,
|
Key: s3Key,
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await s3.send(command);
|
const response = await s3.send(command);
|
||||||
const stream = response.Body as Readable;
|
const stream = response.Body as Readable;
|
||||||
|
|
||||||
const chunks: Uint8Array[] = [];
|
const chunks: Uint8Array[] = [];
|
||||||
for await (const chunk of stream) {
|
for await (const chunk of stream) chunks.push(chunk);
|
||||||
chunks.push(chunk);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Buffer.concat(chunks);
|
return Buffer.concat(chunks);
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user