Skip to content

Commit 4cb4d2e

Browse files
JasonYeYuheclaude
andcommitted
feat: add account dashboard, AI usage badges, Save to Projects on more pages
- Account page (/account): shows tier, AI usage stats, project count, favorites, quick links. Header "Account" button now links here. - Backend: new GET /me/usage endpoint for real-time usage stats - AI usage badge component: shows remaining generations on AI tool pages, turns orange when low, links to login/upgrade - Save to Projects added to: color detail pages (2016 colors), contrast checker (save color pairs) - UsageStats type added to auth-client Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 3a5424f commit 4cb4d2e

10 files changed

Lines changed: 316 additions & 3 deletions

File tree

app/account/page.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import type { Metadata } from "next";
2+
import { SiteHeader } from "@/src/components/site-header";
3+
import { AccountPage } from "@/src/components/account-page";
4+
5+
export const metadata: Metadata = {
6+
title: "Account",
7+
description: "Manage your ColorArchive account, subscription, and usage.",
8+
alternates: { canonical: "/account/" },
9+
robots: { index: false },
10+
};
11+
12+
export default function AccountRoute() {
13+
return (
14+
<>
15+
<SiteHeader currentPath="/login" />
16+
<AccountPage />
17+
</>
18+
);
19+
}

server/routes/me.js

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,45 @@ router.get("/", (req, res) => {
1717
});
1818
});
1919

20+
router.get("/usage", (req, res) => {
21+
const today = new Date().toISOString().slice(0, 10);
22+
const tier = req.user.tier || "free";
23+
24+
// AI usage today
25+
const aiRow = db.prepare(
26+
"SELECT count FROM ai_usage WHERE identifier = ? AND date = ?"
27+
).get(`user:${req.user.id}`, today);
28+
const aiUsed = aiRow ? aiRow.count : 0;
29+
const aiLimit = tier === "pro" ? null : tier === "free" ? 10 : 3;
30+
31+
// Project count
32+
const projectRow = db.prepare(
33+
"SELECT COUNT(*) as count FROM projects WHERE user_id = ?"
34+
).get(req.user.id);
35+
const projectCount = projectRow ? projectRow.count : 0;
36+
const projectLimit = tier === "pro" ? null : 3;
37+
38+
// Favorites count (from user_preferences)
39+
let favoritesCount = 0;
40+
try {
41+
const prefRow = db.prepare(
42+
"SELECT favorites_json FROM user_preferences WHERE user_id = ?"
43+
).get(req.user.id);
44+
if (prefRow) {
45+
favoritesCount = JSON.parse(prefRow.favorites_json).length;
46+
}
47+
} catch {
48+
favoritesCount = 0;
49+
}
50+
51+
return res.json({
52+
tier,
53+
ai: { used: aiUsed, limit: aiLimit },
54+
projects: { count: projectCount, limit: projectLimit },
55+
favorites: { count: favoritesCount },
56+
});
57+
});
58+
2059
router.get("/preferences", (req, res) => {
2160
return res.json(getUserPreferences(req.user.id));
2261
});

src/components/account-page.tsx

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
"use client";
2+
3+
import { useState, useEffect } from "react";
4+
import Link from "next/link";
5+
import { useAuth } from "@/src/components/auth-provider";
6+
import { fetchUsage, type UsageStats } from "@/src/lib/auth-client";
7+
8+
function UsageBar({ used, limit, label }: { used: number; limit: number | null; label: string }) {
9+
const isUnlimited = limit === null;
10+
const pct = isUnlimited ? 0 : limit > 0 ? Math.min((used / limit) * 100, 100) : 0;
11+
const isNearLimit = !isUnlimited && limit > 0 && used >= limit * 0.8;
12+
13+
return (
14+
<div>
15+
<div className="flex items-center justify-between mb-1.5">
16+
<span className="text-xs font-medium text-slate-700 dark:text-slate-300">{label}</span>
17+
<span className="text-xs text-slate-500 dark:text-slate-400">
18+
{isUnlimited ? (
19+
<span className="text-indigo-600 dark:text-indigo-400 font-semibold">Unlimited</span>
20+
) : (
21+
<>{used} / {limit}</>
22+
)}
23+
</span>
24+
</div>
25+
{!isUnlimited && (
26+
<div className="h-2 bg-slate-100 dark:bg-white/10 rounded-full overflow-hidden">
27+
<div
28+
className={`h-full rounded-full transition-all ${
29+
isNearLimit ? "bg-orange-500" : "bg-indigo-500"
30+
}`}
31+
style={{ width: `${pct}%` }}
32+
/>
33+
</div>
34+
)}
35+
</div>
36+
);
37+
}
38+
39+
export function AccountPage() {
40+
const { user, status, tier, logout } = useAuth();
41+
const [usage, setUsage] = useState<UsageStats | null>(null);
42+
const [loading, setLoading] = useState(true);
43+
44+
useEffect(() => {
45+
if (status === "authenticated") {
46+
fetchUsage()
47+
.then(setUsage)
48+
.catch(() => {})
49+
.finally(() => setLoading(false));
50+
} else if (status === "anonymous") {
51+
setLoading(false);
52+
}
53+
}, [status]);
54+
55+
if (status === "anonymous") {
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 p-4">
58+
<div className="text-center max-w-sm space-y-4">
59+
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">Sign in</h1>
60+
<p className="text-sm text-slate-500 dark:text-slate-400">
61+
Sign in to view your account, usage stats, and manage your subscription.
62+
</p>
63+
<Link
64+
href="/login?next=/account"
65+
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"
66+
>
67+
Sign in
68+
</Link>
69+
</div>
70+
</main>
71+
);
72+
}
73+
74+
const isPro = tier === "pro";
75+
76+
return (
77+
<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">
78+
<section className="max-w-2xl mx-auto px-4 pt-10 pb-6">
79+
<p className="text-xs font-semibold tracking-widest text-slate-400 uppercase mb-1">Account</p>
80+
<h1 className="text-3xl font-bold text-slate-900 dark:text-white leading-tight">
81+
{user?.email ?? "Account"}
82+
</h1>
83+
</section>
84+
85+
<div className="max-w-2xl mx-auto px-4 space-y-6">
86+
{/* Tier card */}
87+
<div className="bg-white dark:bg-neutral-900 rounded-2xl border border-slate-100 dark:border-white/10 shadow-sm p-6">
88+
<div className="flex items-center justify-between mb-4">
89+
<div className="flex items-center gap-3">
90+
<div className={`px-3 py-1 rounded-full text-xs font-bold ${
91+
isPro
92+
? "bg-indigo-600 text-white"
93+
: "bg-slate-200 dark:bg-white/10 text-slate-600 dark:text-slate-400"
94+
}`}>
95+
{isPro ? "PRO" : "FREE"}
96+
</div>
97+
<span className="text-sm text-slate-600 dark:text-slate-300">
98+
{isPro ? "You have full access to all features." : "Upgrade for unlimited AI and exports."}
99+
</span>
100+
</div>
101+
{!isPro && (
102+
<Link
103+
href="/pro"
104+
className="px-4 py-2 bg-indigo-600 text-white text-xs font-semibold rounded-xl hover:bg-indigo-500 transition-colors"
105+
>
106+
Upgrade to Pro
107+
</Link>
108+
)}
109+
</div>
110+
111+
{isPro && (
112+
<p className="text-xs text-slate-400">
113+
Thank you for supporting ColorArchive! Contact hello@colorarchive.me for billing questions.
114+
</p>
115+
)}
116+
</div>
117+
118+
{/* Usage stats */}
119+
{loading ? (
120+
<div className="bg-white dark:bg-neutral-900 rounded-2xl border border-slate-100 dark:border-white/10 shadow-sm p-6">
121+
<div className="h-24 bg-slate-100 dark:bg-white/5 rounded-xl animate-pulse" />
122+
</div>
123+
) : usage && (
124+
<div className="bg-white dark:bg-neutral-900 rounded-2xl border border-slate-100 dark:border-white/10 shadow-sm p-6 space-y-5">
125+
<h2 className="text-sm font-semibold text-slate-800 dark:text-white">Today&apos;s Usage</h2>
126+
<UsageBar
127+
label="AI Generations"
128+
used={usage.ai.used}
129+
limit={usage.ai.limit}
130+
/>
131+
<UsageBar
132+
label="Projects"
133+
used={usage.projects.count}
134+
limit={usage.projects.limit}
135+
/>
136+
<div className="flex items-center justify-between pt-2 border-t border-slate-100 dark:border-white/10">
137+
<span className="text-xs text-slate-500 dark:text-slate-400">Favorites saved</span>
138+
<span className="text-xs font-semibold text-slate-700 dark:text-slate-300">{usage.favorites.count}</span>
139+
</div>
140+
</div>
141+
)}
142+
143+
{/* Quick links */}
144+
<div className="bg-white dark:bg-neutral-900 rounded-2xl border border-slate-100 dark:border-white/10 shadow-sm p-6">
145+
<h2 className="text-sm font-semibold text-slate-800 dark:text-white mb-4">Quick Links</h2>
146+
<div className="grid grid-cols-2 gap-3">
147+
{[
148+
{ href: "/projects/", label: "My Projects", desc: `${usage?.projects.count ?? 0} saved` },
149+
{ href: "/favorites/", label: "Favorites", desc: `${usage?.favorites.count ?? 0} colors` },
150+
{ href: "/brand-generator/", label: "Brand Generator", desc: "AI palette" },
151+
{ href: "/mood-palette/", label: "Mood Palette", desc: "AI moods" },
152+
].map(({ href, label, desc }) => (
153+
<Link
154+
key={href}
155+
href={href}
156+
className="flex items-center justify-between p-3 rounded-xl border border-slate-100 dark:border-white/10 hover:border-indigo-200 dark:hover:border-indigo-800 transition-colors"
157+
>
158+
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">{label}</span>
159+
<span className="text-[10px] text-slate-400">{desc}</span>
160+
</Link>
161+
))}
162+
</div>
163+
</div>
164+
165+
{/* Sign out */}
166+
<button
167+
onClick={() => { logout(); window.location.href = "/"; }}
168+
className="w-full text-center py-2.5 text-sm text-slate-500 dark:text-slate-400 hover:text-red-500 transition-colors"
169+
>
170+
Sign out
171+
</button>
172+
</div>
173+
</main>
174+
);
175+
}

src/components/ai-usage-badge.tsx

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
"use client";
2+
3+
import { useState, useEffect } from "react";
4+
import Link from "next/link";
5+
import { fetchUsage } from "@/src/lib/auth-client";
6+
import { useAuth } from "@/src/components/auth-provider";
7+
8+
export function AiUsageBadge() {
9+
const { status, tier } = useAuth();
10+
const [used, setUsed] = useState<number | null>(null);
11+
const [limit, setLimit] = useState<number | null>(null);
12+
13+
useEffect(() => {
14+
if (status !== "authenticated") return;
15+
fetchUsage()
16+
.then((u) => {
17+
setUsed(u.ai.used);
18+
setLimit(u.ai.limit);
19+
})
20+
.catch(() => {});
21+
}, [status]);
22+
23+
// Don't show for Pro users (unlimited)
24+
if (tier === "pro") return null;
25+
26+
// Don't show if not loaded yet
27+
if (used === null) return null;
28+
29+
const remaining = limit !== null ? limit - used : null;
30+
const isLow = remaining !== null && remaining <= 3;
31+
32+
return (
33+
<div className={`flex items-center gap-2 text-[11px] rounded-lg px-3 py-1.5 ${
34+
isLow
35+
? "bg-orange-50 dark:bg-orange-900/20 text-orange-600 dark:text-orange-400"
36+
: "bg-slate-100 dark:bg-white/8 text-slate-500 dark:text-slate-400"
37+
}`}>
38+
<span>
39+
{status === "anonymous" ? (
40+
<>AI: {used}/{limit ?? 3} today</>
41+
) : (
42+
<>AI: {used}/{limit} today</>
43+
)}
44+
</span>
45+
{isLow && (
46+
<Link href={status === "anonymous" ? "/login" : "/pro"} className="font-semibold underline">
47+
{status === "anonymous" ? "Sign in for more" : "Go Pro"}
48+
</Link>
49+
)}
50+
</div>
51+
);
52+
}

src/components/brand-generator-page.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { UpgradeModal, useUpgradeModal } from "@/src/components/upgrade-modal";
99
import { ProGate } from "@/src/components/pro-gate";
1010
import { SaveToProjectButton } from "@/src/components/save-to-project";
1111
import { PaletteCritiquePanel } from "@/src/components/palette-critique-panel";
12+
import { AiUsageBadge } from "@/src/components/ai-usage-badge";
1213
import type { ColorRecord } from "@/src/types/color";
1314

1415
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "https://api.colorarchive.me";
@@ -308,7 +309,10 @@ export function BrandGeneratorPage() {
308309
<main className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 pb-24">
309310
{/* Hero */}
310311
<section className="max-w-3xl mx-auto px-4 pt-10 pb-8">
311-
<p className="text-xs font-semibold tracking-widest text-slate-400 uppercase mb-1">AI Tool</p>
312+
<div className="flex items-center gap-3 mb-1">
313+
<p className="text-xs font-semibold tracking-widest text-slate-400 uppercase">AI Tool</p>
314+
<AiUsageBadge />
315+
</div>
312316
<h1 className="text-3xl sm:text-4xl font-bold text-slate-900 leading-tight mb-2">
313317
Brand Color Generator
314318
</h1>

src/components/color-detail-page.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { useEffect, useState } from "react";
55
import { FavoriteButton } from "@/src/components/favorite-button";
66
import { ShareLinkButton, ShareOnXButton } from "@/src/components/share-link-button";
77
import { PinterestSaveButton } from "@/src/components/pinterest-save-button";
8+
import { SaveToProjectButton } from "@/src/components/save-to-project";
89
import { useLocale } from "@/src/components/locale-provider";
910
import {
1011
addManyToPalette,
@@ -504,6 +505,7 @@ export function ColorDetailPage({
504505
<ShareLinkButton href={`/colors/${color.id}/`} />
505506
<ShareOnXButton href={`/colors/${color.id}/`} text={`${color.name} ${color.hex} — from the ColorArchive`} />
506507
<PinterestSaveButton color={color} />
508+
<SaveToProjectButton palette={[color.hex]} defaultName={color.name} />
507509
<Link
508510
href="/recent/"
509511
className="rounded-full border border-black/8 bg-white px-3 py-1.5 text-xs font-medium uppercase tracking-[0.14em] text-neutral-600 transition hover:bg-neutral-950 hover:text-white"

src/components/contrast-page.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { useCallback, useMemo, useState } from "react";
55
import { colors } from "@/src/data/colors";
66
import { hslToRgb, rgbToHex } from "@/src/lib/color-utils";
77
import { useLocale } from "@/src/components/locale-provider";
8+
import { SaveToProjectButton } from "@/src/components/save-to-project";
89
import type { ColorRecord } from "@/src/types/color";
910

1011
/* ------------------------------------------------------------------ */
@@ -541,6 +542,9 @@ export function ContrastCheckerPage() {
541542
/>
542543
</div>
543544
)}
545+
<div className="mt-4 flex justify-end">
546+
<SaveToProjectButton palette={[fgHex, bgHex]} defaultName="Contrast Pair" />
547+
</div>
544548
</section>
545549

546550
{/* Color blindness simulation */}

src/components/mood-palette-page.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { ShareOnXButton, ShareLinkButton } from "@/src/components/share-link-but
55
import { UpgradeModal, useUpgradeModal } from "@/src/components/upgrade-modal";
66
import { SaveToProjectButton } from "@/src/components/save-to-project";
77
import { PaletteCritiquePanel } from "@/src/components/palette-critique-panel";
8+
import { AiUsageBadge } from "@/src/components/ai-usage-badge";
89
import { toggleFavoriteColor, getFavoriteColorIds } from "@/src/lib/favorites";
910
import { colors as archiveColors } from "@/src/data/colors";
1011

@@ -149,7 +150,10 @@ export function MoodPalettePage() {
149150
<main className="min-h-screen bg-white dark:bg-neutral-950">
150151
{/* Header */}
151152
<section className="max-w-2xl mx-auto px-4 pt-12 pb-8 text-center">
152-
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-slate-400 mb-3">AI Tool</p>
153+
<div className="flex items-center justify-center gap-3 mb-3">
154+
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-slate-400">AI Tool</p>
155+
<AiUsageBadge />
156+
</div>
153157
<h1 className="text-3xl sm:text-4xl font-bold tracking-tight text-neutral-900 dark:text-white mb-3">
154158
Mood Palette Generator
155159
</h1>

src/components/site-header.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,7 @@ export function SiteHeader({ currentPath }: SiteHeaderProps) {
212212
</Link>
213213
)}
214214
<Link
215-
href={loginHref}
215+
href={status === "authenticated" ? "/account" : loginHref}
216216
className={`hidden rounded-full border px-3 py-2 text-sm font-medium transition sm:inline-flex ${
217217
currentPath === "/login"
218218
? "border-neutral-950 bg-neutral-950 text-white dark:border-white dark:bg-white dark:text-neutral-950"

src/lib/auth-client.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,20 @@ export interface CritiqueResult {
205205
overall_assessment: string;
206206
}
207207

208+
export interface UsageStats {
209+
tier: UserTier;
210+
ai: { used: number; limit: number | null };
211+
projects: { count: number; limit: number | null };
212+
favorites: { count: number };
213+
}
214+
215+
export async function fetchUsage(): Promise<UsageStats> {
216+
const response = await fetch(`${API_URL}/me/usage`, {
217+
credentials: "include",
218+
});
219+
return parseResponse<UsageStats>(response);
220+
}
221+
208222
export async function fetchProjects(): Promise<{ projects: Project[] }> {
209223
const response = await fetch(`${API_URL}/projects`, {
210224
credentials: "include",

0 commit comments

Comments
 (0)