Skip to content

Commit e34d93e

Browse files
feat(dashboard): collapsible collections, sort toolbar, and improved sidebar nav
1 parent 68c8f28 commit e34d93e

4 files changed

Lines changed: 282 additions & 144 deletions

File tree

app/dashboard/page.tsx

Lines changed: 132 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
"use client";
22

33
import { Suspense } from "react";
4-
import { useState, useMemo, useEffect } from "react";
4+
import { useState, useMemo, useEffect, useCallback } from "react";
55
import Link from "next/link";
6+
import { useSearchParams, useRouter } from "next/navigation";
67
import { groupPromptsByCollection, filterPrompts } from "@/lib/promptData";
7-
import { PromptModel } from "@/lib/types";
8+
import { Prompt, PromptModel } from "@/lib/types";
89
import { Header } from "@/components/Header";
910
import { Sidebar } from "@/components/Sidebar";
1011
import { Layout } from "@/components/Layout";
@@ -14,6 +15,8 @@ import { useAuth } from "@/components/AuthProvider";
1415

1516
const ONBOARDING_KEY = (userId: string) => `closednote_onboarded_${userId}`;
1617

18+
type SortKey = "updated" | "created" | "alpha";
19+
1720
function WelcomeBanner({ userName, onDismiss }: { userName: string; onDismiss: () => void }) {
1821
return (
1922
<div className="max-w-xl mx-auto py-16 px-4">
@@ -28,39 +31,21 @@ function WelcomeBanner({ userName, onDismiss }: { userName: string; onDismiss: (
2831
</p>
2932

3033
<ol className="space-y-5 mb-10">
31-
<li className="flex gap-4">
32-
<span className="flex-shrink-0 w-7 h-7 rounded-full bg-neutral-100 dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300 text-xs font-semibold flex items-center justify-center">
33-
1
34-
</span>
35-
<div>
36-
<p className="text-sm font-medium text-neutral-900 dark:text-neutral-100">Save a prompt</p>
37-
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-0.5">
38-
Paste or type any prompt, give it a title, and hit save. Takes 10 seconds.
39-
</p>
40-
</div>
41-
</li>
42-
<li className="flex gap-4">
43-
<span className="flex-shrink-0 w-7 h-7 rounded-full bg-neutral-100 dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300 text-xs font-semibold flex items-center justify-center">
44-
2
45-
</span>
46-
<div>
47-
<p className="text-sm font-medium text-neutral-900 dark:text-neutral-100">Edit freely, versions save automatically</p>
48-
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-0.5">
49-
Every time you update a prompt, the previous version is kept. Restore any version in one click.
50-
</p>
51-
</div>
52-
</li>
53-
<li className="flex gap-4">
54-
<span className="flex-shrink-0 w-7 h-7 rounded-full bg-neutral-100 dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300 text-xs font-semibold flex items-center justify-center">
55-
3
56-
</span>
57-
<div>
58-
<p className="text-sm font-medium text-neutral-900 dark:text-neutral-100">Refine with AI when you need it</p>
59-
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-0.5">
60-
Open any prompt and ask AI to improve it. Add your OpenAI key in Settings to unlock GPT-4o.
61-
</p>
62-
</div>
63-
</li>
34+
{[
35+
["Save a prompt", "Paste or type any prompt, give it a title, and hit save. Takes 10 seconds."],
36+
["Edit freely, versions save automatically", "Every time you update a prompt, the previous version is kept. Restore any version in one click."],
37+
["Refine with AI when you need it", "Open any prompt and ask AI to improve it. Add your OpenAI key in Settings to unlock GPT-4o."],
38+
].map(([title, desc], i) => (
39+
<li key={i} className="flex gap-4">
40+
<span className="flex-shrink-0 w-7 h-7 rounded-full bg-neutral-100 dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300 text-xs font-semibold flex items-center justify-center">
41+
{i + 1}
42+
</span>
43+
<div>
44+
<p className="text-sm font-medium text-neutral-900 dark:text-neutral-100">{title}</p>
45+
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-0.5">{desc}</p>
46+
</div>
47+
</li>
48+
))}
6449
</ol>
6550

6651
<div className="flex items-center gap-4">
@@ -82,39 +67,35 @@ function WelcomeBanner({ userName, onDismiss }: { userName: string; onDismiss: (
8267
);
8368
}
8469

70+
function sortPrompts(prompts: Prompt[], sort: SortKey): Prompt[] {
71+
return [...prompts].sort((a, b) => {
72+
if (sort === "alpha") return a.title.localeCompare(b.title);
73+
if (sort === "created") return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
74+
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
75+
});
76+
}
77+
8578
function DashboardContent() {
8679
const { prompts: allPrompts, loading, error } = usePrompts();
8780
const { user, loading: authLoading } = useAuth();
88-
const [searchQuery, setSearchQuery] = useState("");
89-
const [filters, setFilters] = useState<{
90-
query: string;
91-
model: PromptModel | "";
92-
}>({ query: "", model: "" });
93-
const [activeCollection, setActiveCollection] = useState<
94-
string | undefined
95-
>();
81+
const searchParams = useSearchParams();
82+
const router = useRouter();
83+
84+
const activeCollection = searchParams.get("collection") ?? undefined;
85+
86+
const [filters, setFilters] = useState<{ query: string; model: PromptModel | "" }>({ query: "", model: "" });
87+
const [sort, setSort] = useState<SortKey>("updated");
9688
const [showWelcome, setShowWelcome] = useState(false);
9789

98-
useEffect(() => {
99-
if (typeof window !== "undefined") {
100-
const urlParams = new URLSearchParams(window.location.search);
101-
const collection = urlParams.get("collection");
102-
if (collection) setActiveCollection(collection);
103-
}
90+
const setSearchQuery = useCallback((q: string) => {
91+
setFilters((prev) => ({ ...prev, query: q }));
10492
}, []);
10593

106-
useEffect(() => {
107-
setFilters((prev) => ({ ...prev, query: searchQuery }));
108-
}, [searchQuery]);
109-
110-
// Show welcome only once per account, on first login with no prompts
11194
useEffect(() => {
11295
if (!user || authLoading || loading) return;
11396
if (allPrompts.length > 0) return;
11497
const key = ONBOARDING_KEY(user.id);
115-
if (!localStorage.getItem(key)) {
116-
setShowWelcome(true);
117-
}
98+
if (!localStorage.getItem(key)) setShowWelcome(true);
11899
}, [user, authLoading, loading, allPrompts.length]);
119100

120101
function dismissWelcome() {
@@ -130,22 +111,17 @@ function DashboardContent() {
130111
});
131112
}, [filters, allPrompts, activeCollection]);
132113

133-
const promptsByCollection = useMemo(() => {
134-
return groupPromptsByCollection(filteredPrompts);
135-
}, [filteredPrompts]);
114+
const sortedPrompts = useMemo(() => sortPrompts(filteredPrompts, sort), [filteredPrompts, sort]);
136115

116+
const promptsByCollection = useMemo(() => groupPromptsByCollection(sortedPrompts), [sortedPrompts]);
137117
const collections = Object.keys(promptsByCollection).sort();
138118

119+
const isFiltering = !!filters.query || !!activeCollection;
120+
139121
return (
140122
<Layout
141-
header={
142-
<Header onSearch={setSearchQuery} promptCount={allPrompts.length} />
143-
}
144-
sidebar={
145-
allPrompts.length > 0 ? (
146-
<Sidebar prompts={allPrompts} activeCollection={activeCollection} />
147-
) : null
148-
}
123+
header={<Header onSearch={setSearchQuery} promptCount={allPrompts.length} />}
124+
sidebar={allPrompts.length > 0 ? <Sidebar prompts={allPrompts} activeCollection={activeCollection} /> : null}
149125
>
150126
{error ? (
151127
<div className="max-w-2xl mx-auto">
@@ -155,22 +131,19 @@ function DashboardContent() {
155131
</div>
156132
</div>
157133
) : (authLoading || loading) ? (
158-
<div className="max-w-5xl mx-auto w-full animate-pulse space-y-6">
159-
{[1, 2, 3].map((i) => (
160-
<div key={i}>
161-
<div className="h-3 bg-neutral-200 dark:bg-neutral-800 rounded w-20 mb-2 mx-3" />
162-
{[1, 2, 3].map((j) => (
163-
<div key={j} className="h-10 bg-neutral-100 dark:bg-neutral-900 rounded-md mb-1 mx-1" />
134+
<div className="max-w-4xl mx-auto w-full space-y-px animate-pulse">
135+
{[3, 5, 2].map((n, i) => (
136+
<div key={i} className="mb-4">
137+
<div className="h-8 bg-neutral-100 dark:bg-neutral-800/60 rounded-md mb-px" />
138+
{Array.from({ length: n }).map((_, j) => (
139+
<div key={j} className="h-11 bg-neutral-50 dark:bg-neutral-900 border-b border-neutral-100 dark:border-neutral-800/40 last:border-0" />
164140
))}
165141
</div>
166142
))}
167143
</div>
168144
) : allPrompts.length === 0 ? (
169145
showWelcome ? (
170-
<WelcomeBanner
171-
userName={user?.displayName?.split(" ")[0] ?? ""}
172-
onDismiss={dismissWelcome}
173-
/>
146+
<WelcomeBanner userName={user?.displayName?.split(" ")[0] ?? ""} onDismiss={dismissWelcome} />
174147
) : (
175148
<div className="max-w-sm mx-auto text-center py-20">
176149
<p className="text-neutral-900 dark:text-neutral-100 font-medium mb-2">No prompts yet</p>
@@ -186,26 +159,90 @@ function DashboardContent() {
186159
</div>
187160
)
188161
) : (
189-
<div className="max-w-5xl mx-auto w-full">
190-
<div>
191-
{collections.length === 0 ? (
192-
<div className="text-center py-16">
193-
<p className="text-neutral-500 dark:text-neutral-400">
194-
No prompts found matching your filters.
195-
</p>
196-
</div>
197-
) : (
198-
<div className="border border-neutral-100 dark:border-neutral-800 rounded-lg overflow-hidden">
199-
{collections.map((collection) => (
200-
<PromptCollection
201-
key={collection}
202-
collection={collection}
203-
prompts={promptsByCollection[collection]}
204-
/>
162+
<div className="max-w-4xl mx-auto w-full">
163+
{/* Toolbar */}
164+
<div className="flex items-center justify-between mb-3 gap-3 flex-wrap">
165+
<div className="flex items-center gap-2 min-w-0">
166+
{activeCollection ? (
167+
<div className="flex items-center gap-2">
168+
<button
169+
onClick={() => router.push("/dashboard")}
170+
className="text-xs text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-200 transition-colors"
171+
>
172+
All
173+
</button>
174+
<span className="text-xs text-neutral-300 dark:text-neutral-600">/</span>
175+
<span className="text-xs font-medium text-neutral-700 dark:text-neutral-300 capitalize">
176+
{activeCollection}
177+
</span>
178+
</div>
179+
) : (
180+
<span className="text-xs text-neutral-400 dark:text-neutral-500">
181+
All prompts
182+
</span>
183+
)}
184+
<span className="text-xs text-neutral-300 dark:text-neutral-700">·</span>
185+
<span className="text-xs text-neutral-400 dark:text-neutral-500 tabular-nums">
186+
{filteredPrompts.length} {filteredPrompts.length === 1 ? "prompt" : "prompts"}
187+
{isFiltering && allPrompts.length !== filteredPrompts.length && (
188+
<span className="text-neutral-300 dark:text-neutral-600"> of {allPrompts.length}</span>
189+
)}
190+
</span>
191+
</div>
192+
193+
<div className="flex items-center gap-1.5">
194+
{isFiltering && (
195+
<button
196+
onClick={() => { setFilters({ query: "", model: "" }); router.push("/dashboard"); }}
197+
className="text-xs px-2.5 py-1.5 rounded-md border border-neutral-200 dark:border-neutral-700 text-neutral-500 dark:text-neutral-400 hover:bg-neutral-50 dark:hover:bg-neutral-800 transition-colors"
198+
>
199+
Clear filters
200+
</button>
201+
)}
202+
<div className="flex items-center gap-1 bg-neutral-100 dark:bg-neutral-800 rounded-lg p-0.5">
203+
{([["updated", "Recent"], ["created", "Oldest"], ["alpha", "A-Z"]] as [SortKey, string][]).map(([key, label]) => (
204+
<button
205+
key={key}
206+
onClick={() => setSort(key)}
207+
className={`px-2.5 py-1 text-xs rounded-md transition-colors ${
208+
sort === key
209+
? "bg-white dark:bg-neutral-700 text-neutral-900 dark:text-neutral-100 shadow-sm font-medium"
210+
: "text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-200"
211+
}`}
212+
>
213+
{label}
214+
</button>
205215
))}
206216
</div>
207-
)}
217+
</div>
208218
</div>
219+
220+
{/* Prompt list */}
221+
{collections.length === 0 ? (
222+
<div className="text-center py-16">
223+
<p className="text-sm text-neutral-500 dark:text-neutral-400">No prompts match your search.</p>
224+
{isFiltering && (
225+
<button
226+
onClick={() => { setFilters({ query: "", model: "" }); router.push("/dashboard"); }}
227+
className="mt-3 text-sm text-neutral-900 dark:text-neutral-100 underline underline-offset-2"
228+
>
229+
Clear filters
230+
</button>
231+
)}
232+
</div>
233+
) : (
234+
<div className="border border-neutral-200 dark:border-neutral-800 rounded-xl overflow-hidden">
235+
{collections.map((collection, i) => (
236+
<PromptCollection
237+
key={collection}
238+
collection={collection}
239+
prompts={promptsByCollection[collection]}
240+
defaultOpen={collections.length <= 5 || !!activeCollection}
241+
bordered={i < collections.length - 1}
242+
/>
243+
))}
244+
</div>
245+
)}
209246
</div>
210247
)}
211248
</Layout>

components/PromptCollection.tsx

Lines changed: 32 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,47 @@
1+
"use client";
2+
3+
import { useState } from "react";
14
import { Prompt } from "@/lib/types";
25
import { PromptListItem } from "./PromptListItem";
36

47
interface PromptCollectionProps {
58
collection: string;
69
prompts: Prompt[];
10+
defaultOpen?: boolean;
11+
bordered?: boolean;
712
}
813

9-
export function PromptCollection({ collection, prompts }: PromptCollectionProps) {
14+
export function PromptCollection({ collection, prompts, defaultOpen = true, bordered = false }: PromptCollectionProps) {
15+
const [open, setOpen] = useState(defaultOpen);
16+
const label = collection.charAt(0).toUpperCase() + collection.slice(1);
17+
1018
return (
11-
<div className="mb-2">
12-
<div className="flex items-center gap-3 px-3 py-2 border-b border-neutral-100 dark:border-neutral-800">
13-
<h2 className="text-xs font-semibold uppercase tracking-wider text-neutral-500 dark:text-neutral-400 capitalize">
14-
{collection}
19+
<div className={bordered ? "border-b border-neutral-200 dark:border-neutral-800" : ""}>
20+
<button
21+
onClick={() => setOpen((v) => !v)}
22+
className="w-full flex items-center gap-2.5 px-4 py-2.5 hover:bg-neutral-50 dark:hover:bg-neutral-800/50 transition-colors group"
23+
>
24+
<svg
25+
className={`w-3.5 h-3.5 text-neutral-400 shrink-0 transition-transform duration-150 ${open ? "rotate-0" : "-rotate-90"}`}
26+
fill="none" stroke="currentColor" viewBox="0 0 24 24"
27+
>
28+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
29+
</svg>
30+
<h2 className="text-xs font-semibold uppercase tracking-wider text-neutral-500 dark:text-neutral-400 flex-1 text-left">
31+
{label}
1532
</h2>
16-
<span className="text-xs text-neutral-300 dark:text-neutral-600 font-medium">
33+
<span className="text-xs tabular-nums text-neutral-400 dark:text-neutral-500 group-hover:text-neutral-600 dark:group-hover:text-neutral-300 transition-colors">
1734
{prompts.length}
1835
</span>
19-
</div>
20-
<div className="py-1">
21-
{prompts.map((prompt) => (
22-
<PromptListItem key={prompt.id} prompt={prompt} />
23-
))}
24-
</div>
36+
</button>
37+
38+
{open && (
39+
<div>
40+
{prompts.map((prompt) => (
41+
<PromptListItem key={prompt.id} prompt={prompt} />
42+
))}
43+
</div>
44+
)}
2545
</div>
2646
);
2747
}

0 commit comments

Comments
 (0)