Skip to content

Commit 3a5424f

Browse files
JasonYeYuheclaude
andcommitted
feat: polish product — shared projects, Save to Projects everywhere, tools page
- Shared project view page at /projects/shared/[shareId] (public read-only) - Save to Projects button added to: palette builder tray, image palette, UI preview page - Brand Color Analyzer added to Tools page with i18n - Preview page: Tailwind config export + ProGate on exports - Site header: added /projects and /analyze to valid paths Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 9a19aec commit 3a5424f

File tree

7 files changed

+255
-8
lines changed

7 files changed

+255
-8
lines changed
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import type { Metadata } from "next";
2+
import { SiteHeader } from "@/src/components/site-header";
3+
import { SharedProjectPage } from "@/src/components/shared-project-page";
4+
5+
export const metadata: Metadata = {
6+
title: "Shared Project",
7+
description: "View a shared ColorArchive project palette and design critique.",
8+
};
9+
10+
export default function SharedProjectRoute() {
11+
return (
12+
<>
13+
<SiteHeader currentPath="/projects" />
14+
<SharedProjectPage />
15+
</>
16+
);
17+
}

src/components/image-palette-page.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { useState, useRef, useCallback, useEffect, useMemo } from "react";
44
import Link from "next/link";
55
import { ProGate } from "@/src/components/pro-gate";
6+
import { SaveToProjectButton } from "@/src/components/save-to-project";
67
import { hexToRgb, rgbToHsl, rgbToHex } from "@/src/lib/color-utils";
78
import { colors as archiveColors } from "@/src/data/colors";
89
import { addManyToPalette } from "@/src/lib/palette-builder";
@@ -635,6 +636,10 @@ export function ImagePalettePage() {
635636
<h2 className="text-lg font-semibold text-slate-800">Color Details</h2>
636637
<div className="flex flex-wrap items-center gap-2">
637638
<AddMatchesToPaletteButton matchedColors={matchedColors} />
639+
<SaveToProjectButton
640+
palette={matchedColors.map((m) => m.extracted.hex)}
641+
defaultName="Image Palette"
642+
/>
638643
{shareUrl && (
639644
<>
640645
<ShareLinkButton href={shareUrl} label="Copy link" />

src/components/palette-builder-tray.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import Link from "next/link";
44
import { useEffect, useState } from "react";
5+
import { SaveToProjectButton } from "@/src/components/save-to-project";
56
import {
67
buildPaletteCssExport,
78
buildPaletteJsonExport,
@@ -181,6 +182,10 @@ export function PaletteBuilderTray() {
181182
>
182183
{t("tray.viewPalette")}
183184
</Link>
185+
<SaveToProjectButton
186+
palette={paletteColors.map((c) => c.hex)}
187+
defaultName={paletteName}
188+
/>
184189
<button
185190
type="button"
186191
onClick={clearPalette}

src/components/palette-preview-page.tsx

Lines changed: 49 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
"use client";
22

33
import { useState, useEffect, useCallback } from "react";
4+
import { ProGate } from "@/src/components/pro-gate";
5+
import { SaveToProjectButton } from "@/src/components/save-to-project";
46

57
// --- Color utilities ---
68

@@ -487,6 +489,28 @@ export function PalettePreviewPage() {
487489
setTimeout(() => setCopied(false), 1400);
488490
}, [cssVars]);
489491

492+
const [twCopied, setTwCopied] = useState(false);
493+
const tailwindConfig = `// tailwind.config.js
494+
module.exports = {
495+
theme: {
496+
extend: {
497+
colors: {
498+
bg: "${roles.bg}",
499+
surface: "${roles.surface}",
500+
primary: "${roles.primary}",
501+
text: "${roles.text}",
502+
accent: "${roles.accent}",
503+
},
504+
},
505+
},
506+
};`;
507+
508+
const copyTailwind = useCallback(() => {
509+
navigator.clipboard.writeText(tailwindConfig);
510+
setTwCopied(true);
511+
setTimeout(() => setTwCopied(false), 1400);
512+
}, [tailwindConfig]);
513+
490514
return (
491515
<main className="min-h-screen bg-slate-50 dark:bg-neutral-950">
492516
{/* Header */}
@@ -566,14 +590,31 @@ export function PalettePreviewPage() {
566590
</div>
567591
</div>
568592

569-
{/* Copy CSS */}
570-
<button
571-
type="button"
572-
onClick={copyCss}
573-
className="w-full text-xs font-semibold py-2.5 rounded-xl border border-slate-200 dark:border-white/15 text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-white/5 transition-colors"
574-
>
575-
{copied ? "✓ Copied CSS Variables" : "Copy CSS Variables"}
576-
</button>
593+
{/* Export */}
594+
<div className="space-y-2">
595+
<ProGate label="Export CSS">
596+
<button
597+
type="button"
598+
onClick={copyCss}
599+
className="w-full text-xs font-semibold py-2.5 rounded-xl border border-slate-200 dark:border-white/15 text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-white/5 transition-colors"
600+
>
601+
{copied ? "✓ Copied CSS Variables" : "Copy CSS Variables"}
602+
</button>
603+
</ProGate>
604+
<ProGate label="Export Tailwind">
605+
<button
606+
type="button"
607+
onClick={copyTailwind}
608+
className="w-full text-xs font-semibold py-2.5 rounded-xl border border-slate-200 dark:border-white/15 text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-white/5 transition-colors"
609+
>
610+
{twCopied ? "✓ Copied Tailwind Config" : "Copy Tailwind Config"}
611+
</button>
612+
</ProGate>
613+
<SaveToProjectButton
614+
palette={Object.values(roles)}
615+
defaultName="UI Preview Palette"
616+
/>
617+
</div>
577618
</div>
578619

579620
{/* Right: preview */}
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
"use client";
2+
3+
import { useState, useEffect } from "react";
4+
import Link from "next/link";
5+
import { usePathname } from "next/navigation";
6+
import { fetchSharedProject, type SharedProject } from "@/src/lib/auth-client";
7+
import { colors as archiveColors } from "@/src/data/colors";
8+
import { hexToRgb } from "@/src/lib/color-utils";
9+
import type { ColorRecord } from "@/src/types/color";
10+
11+
function colorDistance(r1: number, g1: number, b1: number, r2: number, g2: number, b2: number): number {
12+
const dr = r1 - r2, dg = g1 - g2, db = b1 - b2;
13+
return Math.sqrt(2 * dr * dr + 4 * dg * dg + 3 * db * db);
14+
}
15+
16+
function findClosest(hex: string): ColorRecord | null {
17+
const rgb = hexToRgb(hex);
18+
if (!rgb) return null;
19+
let best: { color: ColorRecord; d: number } | null = null;
20+
for (const ac of archiveColors) {
21+
const acRgb = hexToRgb(ac.hex);
22+
if (!acRgb) continue;
23+
const d = colorDistance(rgb.r, rgb.g, rgb.b, acRgb.r, acRgb.g, acRgb.b);
24+
if (!best || d < best.d) best = { color: ac, d };
25+
}
26+
return best?.color ?? null;
27+
}
28+
29+
function luminance(hex: string): number {
30+
const r = parseInt(hex.slice(1, 3), 16);
31+
const g = parseInt(hex.slice(3, 5), 16);
32+
const b = parseInt(hex.slice(5, 7), 16);
33+
return (0.299 * r + 0.587 * g + 0.114 * b) / 255;
34+
}
35+
36+
const SCORE_COLORS: Record<string, string> = {
37+
A: "bg-emerald-500", B: "bg-emerald-400", C: "bg-yellow-400", D: "bg-orange-400", F: "bg-red-500",
38+
};
39+
40+
export function SharedProjectPage() {
41+
const pathname = usePathname();
42+
const shareId = pathname.split("/").pop() ?? "";
43+
const [project, setProject] = useState<SharedProject | null>(null);
44+
const [loading, setLoading] = useState(true);
45+
const [error, setError] = useState("");
46+
47+
useEffect(() => {
48+
if (!shareId) return;
49+
fetchSharedProject(shareId)
50+
.then(setProject)
51+
.catch(() => setError("Project not found or link expired."))
52+
.finally(() => setLoading(false));
53+
}, [shareId]);
54+
55+
if (loading) {
56+
return (
57+
<main className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 dark:from-neutral-950 dark:to-neutral-900 flex items-center justify-center">
58+
<div className="w-8 h-8 border-2 border-slate-300 border-t-slate-600 rounded-full animate-spin" />
59+
</main>
60+
);
61+
}
62+
63+
if (error || !project) {
64+
return (
65+
<main className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 dark:from-neutral-950 dark:to-neutral-900 flex items-center justify-center p-4">
66+
<div className="text-center space-y-3">
67+
<p className="text-slate-600 dark:text-slate-400">{error || "Project not found."}</p>
68+
<Link href="/projects" className="text-sm text-indigo-600 hover:underline">Go to Projects</Link>
69+
</div>
70+
</main>
71+
);
72+
}
73+
74+
return (
75+
<main className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 dark:from-neutral-950 dark:to-neutral-900 pb-24">
76+
<section className="max-w-3xl mx-auto px-4 pt-10 pb-6">
77+
<p className="text-xs font-semibold tracking-widest text-slate-400 uppercase mb-1">Shared Project</p>
78+
<h1 className="text-3xl font-bold text-slate-900 dark:text-white leading-tight mb-2">
79+
{project.name}
80+
</h1>
81+
{project.tags.length > 0 && (
82+
<div className="flex flex-wrap gap-1.5 mb-2">
83+
{project.tags.map((tag) => (
84+
<span key={tag} className="text-[10px] px-2 py-0.5 bg-slate-200 dark:bg-white/10 text-slate-600 dark:text-slate-400 rounded-full">
85+
{tag}
86+
</span>
87+
))}
88+
</div>
89+
)}
90+
{project.notes && (
91+
<p className="text-sm text-slate-500 dark:text-slate-400">{project.notes}</p>
92+
)}
93+
</section>
94+
95+
<div className="max-w-3xl mx-auto px-4 space-y-6">
96+
{/* Palette strip */}
97+
{project.palette.length > 0 && (
98+
<div className="bg-white dark:bg-neutral-900 rounded-2xl border border-slate-100 dark:border-white/10 shadow-sm overflow-hidden">
99+
<div className="flex h-20">
100+
{project.palette.map((hex, i) => (
101+
<div key={i} className="flex-1 relative group" style={{ backgroundColor: hex }}>
102+
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
103+
<span
104+
className="text-[10px] font-mono font-semibold px-1.5 py-0.5 rounded"
105+
style={{ color: luminance(hex) > 0.5 ? "#1a1a1a" : "#ffffff" }}
106+
>
107+
{hex.toUpperCase()}
108+
</span>
109+
</div>
110+
</div>
111+
))}
112+
</div>
113+
114+
<div className="divide-y divide-slate-100 dark:divide-white/10">
115+
{project.palette.map((hex, i) => {
116+
const match = findClosest(hex);
117+
return (
118+
<div key={i} className="flex items-center gap-3 px-5 py-2.5">
119+
<div className="w-7 h-7 rounded-lg border border-black/10 dark:border-white/10 shrink-0" style={{ backgroundColor: hex }} />
120+
<span className="text-sm font-mono text-slate-700 dark:text-slate-300">{hex.toUpperCase()}</span>
121+
{match && (
122+
<Link href={`/colors/${match.id}/`} className="text-xs text-indigo-600 dark:text-indigo-400 hover:underline ml-auto">
123+
{match.name}
124+
</Link>
125+
)}
126+
</div>
127+
);
128+
})}
129+
</div>
130+
</div>
131+
)}
132+
133+
{/* Critique */}
134+
{project.critique && (
135+
<div className="bg-white dark:bg-neutral-900 rounded-2xl border border-slate-100 dark:border-white/10 shadow-sm overflow-hidden">
136+
<div className="flex items-center gap-4 p-5 border-b border-slate-100 dark:border-white/10">
137+
<div className={`w-12 h-12 rounded-xl ${SCORE_COLORS[project.critique.score] || "bg-slate-400"} flex items-center justify-center text-white text-xl font-bold`}>
138+
{project.critique.score}
139+
</div>
140+
<div>
141+
<p className="text-sm font-semibold text-slate-800 dark:text-white">Design Critique</p>
142+
<p className="text-xs text-slate-500 dark:text-slate-400">Harmony: {project.critique.harmony_type}</p>
143+
</div>
144+
</div>
145+
<div className="p-5">
146+
<p className="text-sm text-slate-600 dark:text-slate-300 leading-relaxed">
147+
{project.critique.overall_assessment}
148+
</p>
149+
</div>
150+
</div>
151+
)}
152+
153+
{/* CTA */}
154+
<div className="text-center pt-4">
155+
<Link
156+
href="/brand-generator/"
157+
className="inline-block px-6 py-2.5 bg-slate-900 text-white text-sm font-semibold rounded-xl hover:bg-slate-700 transition-colors dark:bg-white dark:text-neutral-950"
158+
>
159+
Create your own palette
160+
</Link>
161+
</div>
162+
</div>
163+
</main>
164+
);
165+
}

src/components/tools-page.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,15 @@ const TOOLS: Tool[] = [
199199
badgeKey: "tools.badge.new",
200200
accent: "bg-teal-100 text-teal-700",
201201
},
202+
{
203+
href: "/analyze/",
204+
icon: "◎",
205+
nameKey: "tools.analyze.name",
206+
descKey: "tools.analyze.desc",
207+
categoryKey: "tools.cat.creative",
208+
badgeKey: "tools.badge.new",
209+
accent: "bg-rose-100 text-rose-700",
210+
},
202211
{
203212
href: "/tokens/",
204213
icon: "⬡",

src/lib/i18n.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1942,6 +1942,11 @@ const translations: Record<string, Record<string, string>> = {
19421942
en: "Describe your brand and get a full 6-color palette — primary, accents, neutrals — with AI-generated rationale and export tokens.",
19431943
zh: "描述品牌,AI 生成完整 6 色系统,包含主色、辅色、中性色,附设计理由和导出格式。",
19441944
},
1945+
"tools.analyze.name": { en: "Brand Color Analyzer", zh: "品牌配色分析" },
1946+
"tools.analyze.desc": {
1947+
en: "Paste any URL to extract its color palette, match to the archive, and get an AI design critique.",
1948+
zh: "粘贴任意网址,提取配色方案,匹配色彩库,获取 AI 设计诊断。",
1949+
},
19451950
"tools.combinations.name": { en: "Color Combinations", zh: "配色组合" },
19461951
"tools.combinations.desc": {
19471952
en: "Browse 30+ curated color combinations — complementary, analogous, triadic, monochromatic — with hex codes and design use cases.",

0 commit comments

Comments
 (0)