frontmatter in skills files
This commit is contained in:
@@ -18,10 +18,9 @@ export function LandingPage() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="absolute inset-x-0 bottom-0 z-20 flex justify-center pb-0 md:pb-12">
|
||||
<div className="absolute inset-x-0 bottom-0 z-20 flex justify-center pb-0 md:pb-2">
|
||||
{registrationOpen ? <Bootstrap /> : <Login />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,17 +7,20 @@ import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { useForm } from 'hooks/useForm';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import { useGlobal } from 'hooks/useGlobal';
|
||||
|
||||
export function Login() {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { state, formRef, update, isValid } = useForm<LoginFormState>({}, validateForm);
|
||||
const { signin } = useAuth();
|
||||
const [, setDuckHidden] = useGlobal('DUCK_HIDDEN', false);
|
||||
|
||||
const handleSubmit = async (ev: React.FormEvent) => {
|
||||
ev.preventDefault();
|
||||
if (!isValid || isSubmitting) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
setDuckHidden(true);
|
||||
try {
|
||||
await signin({ email: state.email!, password: state.password! });
|
||||
window.location.href = '/';
|
||||
@@ -31,7 +34,7 @@ export function Login() {
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="md:py-12 md:px-24 flex flex-col gap-6">
|
||||
<Card className="md:py-6 md:px-24 flex flex-col gap-6">
|
||||
<div className="text-center">
|
||||
<div className="text-duck-dark text-2xl font-bold">Welcome Back</div>
|
||||
<div className="text-duck-dark/60">Sign in to your account</div>
|
||||
|
||||
@@ -7,7 +7,7 @@ export function AuthenticationLayout({ children }: AuthenticationLayoutProps) {
|
||||
<div className="relative overflow-hidden h-dvh outline-none inset-0">
|
||||
<section className="relative h-dvh snap-start overflow-hidden">
|
||||
<Background />
|
||||
<div className="absolute inset-0 z-520">
|
||||
<div className="absolute inset-0 z-20">
|
||||
<div className="absolute inset-x-0 bottom-0 z-20 flex justify-center pb-8 md:pb-12">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { DuckAvatar } from "./DuckAvatar";
|
||||
import { PixelGrid } from "@/components/PixelGrid";
|
||||
import { useGlobal } from 'hooks/useGlobal';
|
||||
import landscapebg from './landscape1.jpg';
|
||||
|
||||
export function Background() {
|
||||
const [duckHidden] = useGlobal('DUCK_HIDDEN', false);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute inset-0 z-0"
|
||||
@@ -13,7 +16,7 @@ export function Background() {
|
||||
}}
|
||||
>
|
||||
<PixelGrid />
|
||||
<DuckAvatar showDebug={false} fullControlMode={false} currentSection={0} />
|
||||
{!duckHidden && <DuckAvatar showDebug={false} fullControlMode={false} currentSection={0} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,77 +1,58 @@
|
||||
import { Link } from 'react-router';
|
||||
import { MessageSquare, Trash2, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { Card } from '@/components/Card';
|
||||
import { MessageSquare, Trash2 } from 'lucide-react';
|
||||
import { Widget } from '@/components/Widget';
|
||||
import { useChatSessions } from '@/state/useChatSessions';
|
||||
import { useUserState } from '@/state/useUserState';
|
||||
|
||||
export const ChatHistory = () => {
|
||||
const [collapsed, setCollapsed] = useUserState('widget:chatHistory:collapsed', true);
|
||||
const { sessions, deleteSession } = useChatSessions();
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Card className="overflow-hidden">
|
||||
<div className={`flex items-center justify-between px-4 pt-3 ${collapsed ? 'pb-3' : 'pb-1'}`}>
|
||||
<Link to="/chat" className="text-xs font-semibold text-duck-dark/60 uppercase tracking-wide hover:underline">
|
||||
Chat History
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => setCollapsed((c) => !c)}
|
||||
className="text-duck-dark/40 hover:text-duck-dark/70 cursor-pointer transition-colors"
|
||||
>
|
||||
{collapsed ? <ChevronDown className="h-4 w-4" /> : <ChevronUp className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<>
|
||||
<div className="px-4 pb-3 max-h-72 overflow-y-auto">
|
||||
{sessions.length === 0 ? (
|
||||
<p className="text-xs text-duck-dark/40 py-4 text-center">No sessions yet</p>
|
||||
) : (
|
||||
<ul className="space-y-0.5">
|
||||
{sessions.map((session) => (
|
||||
<li
|
||||
key={`${session.provider}-${session.id}`}
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-duck-dark/5 group"
|
||||
>
|
||||
<Link
|
||||
to={session.provider === 'claude' ? `/chat/${session.id}` : `/chat/opencode/${session.id}`}
|
||||
className="flex items-center gap-2 flex-1 min-w-0"
|
||||
<Widget title="Chat History">
|
||||
<div className="px-4 pb-3 max-h-72 overflow-y-auto">
|
||||
{sessions.length === 0 ? (
|
||||
<p className="text-xs text-duck-dark/40 py-4 text-center">No sessions yet</p>
|
||||
) : (
|
||||
<ul className="space-y-0.5">
|
||||
{sessions.map((session) => (
|
||||
<li
|
||||
key={`${session.provider}-${session.id}`}
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-duck-dark/5 group"
|
||||
>
|
||||
<Link
|
||||
to={session.provider === 'claude' ? `/chat/${session.id}` : `/chat/opencode/${session.id}`}
|
||||
className="flex items-center gap-2 flex-1 min-w-0"
|
||||
>
|
||||
<MessageSquare className="h-4 w-4 shrink-0 text-duck-teal/60" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="text-sm text-duck-dark truncate block">{session.title}</span>
|
||||
<span className="text-xs text-duck-dark/40 truncate block">
|
||||
{new Date(session.createdAt).toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
<span
|
||||
className={`ml-1.5 font-medium ${
|
||||
session.provider === 'claude' ? 'text-duck-teal' : 'text-duck-orange'
|
||||
}`}
|
||||
>
|
||||
<MessageSquare className="h-4 w-4 shrink-0 text-duck-teal/60" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="text-sm text-duck-dark truncate block">{session.title}</span>
|
||||
<span className="text-xs text-duck-dark/40 truncate block">
|
||||
{new Date(session.createdAt).toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
<span
|
||||
className={`ml-1.5 font-medium ${
|
||||
session.provider === 'claude' ? 'text-duck-teal' : 'text-duck-orange'
|
||||
}`}
|
||||
>
|
||||
{session.provider === 'claude' ? 'Claude' : 'OpenCode'}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => deleteSession(session.provider, session.id)}
|
||||
className="shrink-0 p-1 rounded text-duck-dark/20 md:opacity-0 md:group-hover:opacity-100 hover:text-red-500 transition-opacity cursor-pointer"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
{session.provider === 'claude' ? 'Claude' : 'OpenCode'}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => deleteSession(session.provider, session.id)}
|
||||
className="shrink-0 p-1 rounded text-duck-dark/20 md:opacity-0 md:group-hover:opacity-100 hover:text-red-500 transition-opacity cursor-pointer"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</Widget>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import { useNavigate, Link } from 'react-router';
|
||||
import { Folder, Pin, PinOff, Search, Clock, FolderOpen, Loader2, X, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { Folder, Pin, PinOff, Search, Clock, FolderOpen, Loader2, X } from 'lucide-react';
|
||||
import { getIcon } from 'material-file-icons';
|
||||
import { useFiles, type DirEntry, Breadcrumb } from 'widgets/FileBrowser';
|
||||
import { useRecentFiles } from './state/useRecentFiles';
|
||||
import { usePinnedFiles } from './state/usePinnedFiles';
|
||||
import { useUserState } from '@/state/useUserState';
|
||||
import { Card } from '@/components/Card';
|
||||
import { Widget } from '@/components/Widget';
|
||||
|
||||
type Tab = 'browse' | 'recent' | 'pinned';
|
||||
|
||||
@@ -22,7 +21,6 @@ export const FileBrowser = () => {
|
||||
const { recents, addRecent } = useRecentFiles();
|
||||
const { pinned, togglePin, isPinned } = usePinnedFiles();
|
||||
|
||||
const [collapsed, setCollapsed] = useUserState('widget:fileBrowser:collapsed', true);
|
||||
const [tab, setTab] = useState<Tab>('browse');
|
||||
const [browsePath, setBrowsePath] = useState('/');
|
||||
const [entries, setEntries] = useState<DirEntry[]>([]);
|
||||
@@ -72,171 +70,154 @@ export const FileBrowser = () => {
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Card className="overflow-hidden">
|
||||
<div className={`flex items-center justify-between px-4 pt-3 ${collapsed ? 'pb-3' : 'pb-1'}`}>
|
||||
<Link to="/files" className="text-xs font-semibold text-duck-dark/60 uppercase tracking-wide hover:underline">
|
||||
File Browser
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => setCollapsed((c) => !c)}
|
||||
className="text-duck-dark/40 hover:text-duck-dark/70 cursor-pointer transition-colors"
|
||||
>
|
||||
{collapsed ? <ChevronDown className="h-4 w-4" /> : <ChevronUp className="h-4 w-4" />}
|
||||
</button>
|
||||
<Widget title="File Browser">
|
||||
{/* Tab bar + search */}
|
||||
<div className="flex items-center gap-2 px-4 pb-1">
|
||||
<div className="flex items-center gap-1">
|
||||
{TABS.map(({ key, label, icon: Icon }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setTab(key)}
|
||||
className={`flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-colors cursor-pointer ${
|
||||
!isSearching && tab === key
|
||||
? 'bg-duck-teal/10 text-duck-teal'
|
||||
: 'text-duck-dark/50 hover:text-duck-dark/70 hover:bg-duck-dark/5'
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<>
|
||||
{/* Tab bar + search */}
|
||||
<div className="flex items-center gap-2 px-4 pb-1">
|
||||
<div className="flex items-center gap-1">
|
||||
{TABS.map(({ key, label, icon: Icon }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setTab(key)}
|
||||
className={`flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-colors cursor-pointer ${
|
||||
!isSearching && tab === key
|
||||
? 'bg-duck-teal/10 text-duck-teal'
|
||||
: 'text-duck-dark/50 hover:text-duck-dark/70 hover:bg-duck-dark/5'
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="relative ml-auto w-28 md:w-44">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-duck-dark/30" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(ev) => setSearchQuery(ev.target.value)}
|
||||
placeholder="Search..."
|
||||
className="w-full rounded-lg border border-duck-dark/20 bg-white pl-8 pr-8 py-1 text-xs text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-2 focus:ring-duck-teal/30 focus:border-duck-teal/50"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => setSearchQuery('')}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-duck-dark/30 hover:text-duck-dark/60 cursor-pointer"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative ml-auto w-28 md:w-44">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-duck-dark/30" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(ev) => setSearchQuery(ev.target.value)}
|
||||
placeholder="Search..."
|
||||
className="w-full rounded-lg border border-duck-dark/20 bg-white pl-8 pr-8 py-1 text-xs text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-2 focus:ring-duck-teal/30 focus:border-duck-teal/50"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => setSearchQuery('')}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-duck-dark/30 hover:text-duck-dark/60 cursor-pointer"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-4 pb-3 max-h-72 overflow-y-auto">
|
||||
{isSearching ? (
|
||||
searching ? (
|
||||
{/* Content */}
|
||||
<div className="px-4 pb-3 max-h-72 overflow-y-auto">
|
||||
{isSearching ? (
|
||||
searching ? (
|
||||
<div className="flex items-center justify-center py-6 text-duck-dark/40">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
) : searchResults.length === 0 ? (
|
||||
<p className="text-xs text-duck-dark/40 py-4 text-center">No results</p>
|
||||
) : (
|
||||
<ul className="space-y-0.5">
|
||||
{searchResults.map((entry) => (
|
||||
<EntryRow
|
||||
key={entry.path}
|
||||
name={entry.name}
|
||||
subtitle={entry.path}
|
||||
type={entry.type}
|
||||
pinned={entry.type === 'file' && isPinned(entry.path!)}
|
||||
onPin={entry.type === 'file' ? () => togglePin(entry.path!, entry.name) : undefined}
|
||||
onClick={() => {
|
||||
if (entry.type === 'file') openFile(entry.path!, entry.name);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
{tab === 'browse' && (
|
||||
<div>
|
||||
<div className="py-2">
|
||||
<Breadcrumb path={browsePath} onNavigate={setBrowsePath} />
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-6 text-duck-dark/40">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
) : searchResults.length === 0 ? (
|
||||
<p className="text-xs text-duck-dark/40 py-4 text-center">No results</p>
|
||||
) : sorted.length === 0 ? (
|
||||
<p className="text-xs text-duck-dark/40 py-4 text-center">Empty directory</p>
|
||||
) : (
|
||||
<ul className="space-y-0.5">
|
||||
{searchResults.map((entry) => (
|
||||
{sorted.map((entry) => {
|
||||
const fullPath = browsePath === '/' ? `/${entry.name}` : `${browsePath}/${entry.name}`;
|
||||
return (
|
||||
<EntryRow
|
||||
key={entry.name}
|
||||
name={entry.name}
|
||||
type={entry.type}
|
||||
pinned={entry.type === 'file' && isPinned(fullPath)}
|
||||
onPin={entry.type === 'file' ? () => togglePin(fullPath, entry.name) : undefined}
|
||||
onClick={() => {
|
||||
if (entry.type === 'directory') setBrowsePath(fullPath);
|
||||
else openFile(fullPath, entry.name);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'recent' && (
|
||||
<div className="pt-2">
|
||||
{recents.length === 0 ? (
|
||||
<p className="text-xs text-duck-dark/40 py-4 text-center">No recent files</p>
|
||||
) : (
|
||||
<ul className="space-y-0.5">
|
||||
{recents.map((f) => (
|
||||
<EntryRow
|
||||
key={entry.path}
|
||||
name={entry.name}
|
||||
subtitle={entry.path}
|
||||
type={entry.type}
|
||||
pinned={entry.type === 'file' && isPinned(entry.path!)}
|
||||
onPin={entry.type === 'file' ? () => togglePin(entry.path!, entry.name) : undefined}
|
||||
onClick={() => {
|
||||
if (entry.type === 'file') openFile(entry.path!, entry.name);
|
||||
}}
|
||||
key={f.path}
|
||||
name={f.name}
|
||||
subtitle={f.path}
|
||||
type="file"
|
||||
pinned={isPinned(f.path)}
|
||||
onPin={() => togglePin(f.path, f.name)}
|
||||
onClick={() => openFile(f.path, f.name)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
{tab === 'browse' && (
|
||||
<div>
|
||||
<div className="py-2">
|
||||
<Breadcrumb path={browsePath} onNavigate={setBrowsePath} />
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-6 text-duck-dark/40">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
) : sorted.length === 0 ? (
|
||||
<p className="text-xs text-duck-dark/40 py-4 text-center">Empty directory</p>
|
||||
) : (
|
||||
<ul className="space-y-0.5">
|
||||
{sorted.map((entry) => {
|
||||
const fullPath = browsePath === '/' ? `/${entry.name}` : `${browsePath}/${entry.name}`;
|
||||
return (
|
||||
<EntryRow
|
||||
key={entry.name}
|
||||
name={entry.name}
|
||||
type={entry.type}
|
||||
pinned={entry.type === 'file' && isPinned(fullPath)}
|
||||
onPin={entry.type === 'file' ? () => togglePin(fullPath, entry.name) : undefined}
|
||||
onClick={() => {
|
||||
if (entry.type === 'directory') setBrowsePath(fullPath);
|
||||
else openFile(fullPath, entry.name);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'recent' && (
|
||||
<div className="pt-2">
|
||||
{recents.length === 0 ? (
|
||||
<p className="text-xs text-duck-dark/40 py-4 text-center">No recent files</p>
|
||||
) : (
|
||||
<ul className="space-y-0.5">
|
||||
{recents.map((f) => (
|
||||
<EntryRow
|
||||
key={f.path}
|
||||
name={f.name}
|
||||
subtitle={f.path}
|
||||
type="file"
|
||||
pinned={isPinned(f.path)}
|
||||
onPin={() => togglePin(f.path, f.name)}
|
||||
onClick={() => openFile(f.path, f.name)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'pinned' && (
|
||||
<div className="pt-2">
|
||||
{pinned.length === 0 ? (
|
||||
<p className="text-xs text-duck-dark/40 py-4 text-center">No pinned files</p>
|
||||
) : (
|
||||
<ul className="space-y-0.5">
|
||||
{pinned.map((f) => (
|
||||
<EntryRow
|
||||
key={f.path}
|
||||
name={f.name}
|
||||
subtitle={f.path}
|
||||
type="file"
|
||||
pinned
|
||||
onPin={() => togglePin(f.path, f.name)}
|
||||
onClick={() => openFile(f.path, f.name)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{tab === 'pinned' && (
|
||||
<div className="pt-2">
|
||||
{pinned.length === 0 ? (
|
||||
<p className="text-xs text-duck-dark/40 py-4 text-center">No pinned files</p>
|
||||
) : (
|
||||
<ul className="space-y-0.5">
|
||||
{pinned.map((f) => (
|
||||
<EntryRow
|
||||
key={f.path}
|
||||
name={f.name}
|
||||
subtitle={f.path}
|
||||
type="file"
|
||||
pinned
|
||||
onPin={() => togglePin(f.path, f.name)}
|
||||
onClick={() => openFile(f.path, f.name)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</Widget>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useState, useRef, useEffect, type KeyboardEvent } from 'react';
|
||||
import { useNavigate, Link } from 'react-router';
|
||||
import { useNavigate } from 'react-router';
|
||||
import {
|
||||
Send,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Check,
|
||||
Paperclip,
|
||||
Link as LinkIcon,
|
||||
@@ -14,9 +13,8 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useUserState } from '@/state/useUserState';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/Card';
|
||||
import { Widget } from '@/components/Widget';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -39,7 +37,6 @@ export const ChatLauncher = () => {
|
||||
const [model, setModel] = useState<string | null>(settings.chat.defaultModel);
|
||||
const [input, setInput] = useState('');
|
||||
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||||
const [collapsed, setCollapsed] = useUserState('widget:chatLauncher:collapsed', true);
|
||||
const [urlDialogOpen, setUrlDialogOpen] = useState(false);
|
||||
const [urlInput, setUrlInput] = useState('');
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
@@ -153,172 +150,154 @@ export const ChatLauncher = () => {
|
||||
}, [input]);
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Card className="overflow-hidden">
|
||||
<div className={`flex items-center justify-between px-4 pt-3 ${collapsed ? 'pb-3' : 'pb-1'}`}>
|
||||
<Link
|
||||
to="/chat/new"
|
||||
className="text-xs font-semibold text-duck-dark/60 uppercase tracking-wide hover:underline"
|
||||
>
|
||||
Start Chat
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => setCollapsed((c) => !c)}
|
||||
className="text-duck-dark/40 hover:text-duck-dark/70 cursor-pointer transition-colors"
|
||||
>
|
||||
{collapsed ? <ChevronDown className="h-4 w-4" /> : <ChevronUp className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<>
|
||||
<div className="p-4 pb-2 pt-1">
|
||||
{attachments.length > 0 && (
|
||||
<div className="mb-2 flex flex-wrap gap-1.5">
|
||||
{attachments.map((a, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="relative inline-flex items-center gap-1 px-2 py-1 text-xs bg-duck-teal/10 text-duck-teal rounded-md max-w-[240px] group"
|
||||
>
|
||||
{a.loading ? (
|
||||
<Loader2 className="h-3 w-3 shrink-0 animate-spin" />
|
||||
) : a.type === 'image' && a.dataUrl ? (
|
||||
<img src={a.dataUrl} alt={a.filename} className="h-8 w-8 shrink-0 rounded object-cover" />
|
||||
) : a.type === 'image' ? (
|
||||
<Image className="h-3 w-3 shrink-0" />
|
||||
) : (
|
||||
<LinkIcon className="h-3 w-3 shrink-0" />
|
||||
)}
|
||||
<span className="truncate">
|
||||
{a.type === 'image' ? a.filename : a.loading ? a.url : a.title || a.url}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAttachments((prev) => prev.filter((_, j) => j !== i))}
|
||||
className="shrink-0 hover:text-duck-dark cursor-pointer"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-end gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 h-10 w-10 flex items-center justify-center rounded-lg text-duck-dark/40 hover:text-duck-dark/70 hover:bg-duck-dark/5 transition-colors cursor-pointer"
|
||||
>
|
||||
<Paperclip className="h-4 w-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="top" align="start" className="z-[600]">
|
||||
<DropdownMenuItem className="cursor-pointer" onSelect={() => imageInputRef.current?.click()}>
|
||||
<Image className="mr-2 h-4 w-4" />
|
||||
Image
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="cursor-pointer">
|
||||
<FileText className="mr-2 h-4 w-4" />
|
||||
Text File
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="cursor-pointer">
|
||||
<FileText className="mr-2 h-4 w-4" />
|
||||
PDF
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="cursor-pointer" onSelect={() => setUrlDialogOpen(true)}>
|
||||
<LinkIcon className="mr-2 h-4 w-4" />
|
||||
Webpage URL
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<input
|
||||
ref={imageInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(ev) => {
|
||||
const file = ev.target.files?.[0];
|
||||
if (file) handleAttachImage(file);
|
||||
ev.target.value = '';
|
||||
}}
|
||||
/>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={(ev) => setInput(ev.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={(ev) => {
|
||||
const items = ev.clipboardData?.items;
|
||||
if (!items) return;
|
||||
for (const item of Array.from(items)) {
|
||||
if (item.type.startsWith('image/')) {
|
||||
ev.preventDefault();
|
||||
const file = item.getAsFile();
|
||||
if (file) handleAttachImage(file);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}}
|
||||
placeholder="What do you want to work on now?"
|
||||
rows={1}
|
||||
className="flex-1 resize-none bg-transparent px-2 py-2 text-duck-dark placeholder:text-duck-dark/30 focus:outline-none text-lg"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={!input.trim()}
|
||||
size="icon"
|
||||
className="shrink-0 h-10 w-10 bg-duck-teal hover:bg-duck-teal/90 cursor-pointer disabled:opacity-40"
|
||||
<>
|
||||
<Widget title="Start Chat">
|
||||
<div className="p-4 pb-2 pt-1">
|
||||
{attachments.length > 0 && (
|
||||
<div className="mb-2 flex flex-wrap gap-1.5">
|
||||
{attachments.map((a, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="relative inline-flex items-center gap-1 px-2 py-1 text-xs bg-duck-teal/10 text-duck-teal rounded-md max-w-[240px] group"
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between px-4 pb-3">
|
||||
<div className="flex items-center gap-1 rounded-lg bg-duck-dark/5 p-1">
|
||||
{(['claude', 'opencode'] as const).map((value) => (
|
||||
{a.loading ? (
|
||||
<Loader2 className="h-3 w-3 shrink-0 animate-spin" />
|
||||
) : a.type === 'image' && a.dataUrl ? (
|
||||
<img src={a.dataUrl} alt={a.filename} className="h-8 w-8 shrink-0 rounded object-cover" />
|
||||
) : a.type === 'image' ? (
|
||||
<Image className="h-3 w-3 shrink-0" />
|
||||
) : (
|
||||
<LinkIcon className="h-3 w-3 shrink-0" />
|
||||
)}
|
||||
<span className="truncate">
|
||||
{a.type === 'image' ? a.filename : a.loading ? a.url : a.title || a.url}
|
||||
</span>
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => {
|
||||
setProvider(value);
|
||||
setModel(null);
|
||||
}}
|
||||
className={`rounded-md px-3 py-1 text-xs font-medium transition-colors cursor-pointer ${
|
||||
provider === value
|
||||
? 'bg-white text-duck-dark shadow-sm'
|
||||
: 'text-duck-dark/50 hover:text-duck-dark/70'
|
||||
}`}
|
||||
type="button"
|
||||
onClick={() => setAttachments((prev) => prev.filter((_, j) => j !== i))}
|
||||
className="shrink-0 hover:text-duck-dark cursor-pointer"
|
||||
>
|
||||
{value === 'claude' ? 'Claude' : 'OpenCode'}
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{models.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="flex items-center gap-1 text-xs text-duck-dark/50 hover:text-duck-dark/70 cursor-pointer transition-colors">
|
||||
{models.find((m) => m.id === (model ?? models[0]?.id))?.name ?? models[0]?.name}
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="z-[600] max-h-64 overflow-y-auto">
|
||||
{models.map((m) => (
|
||||
<DropdownMenuItem key={m.id} onClick={() => setModel(m.id)} className="cursor-pointer">
|
||||
<Check
|
||||
className={`mr-2 h-3 w-3 ${(model ?? models[0]?.id) === m.id ? 'opacity-100' : 'opacity-0'}`}
|
||||
/>
|
||||
<span className="font-bold">{m.name}</span>
|
||||
{m.provider && <span className="text-duck-dark/50 ml-1">({m.provider})</span>}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
<div className="flex items-end gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 h-10 w-10 flex items-center justify-center rounded-lg text-duck-dark/40 hover:text-duck-dark/70 hover:bg-duck-dark/5 transition-colors cursor-pointer"
|
||||
>
|
||||
<Paperclip className="h-4 w-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="top" align="start" className="z-[600]">
|
||||
<DropdownMenuItem className="cursor-pointer" onSelect={() => imageInputRef.current?.click()}>
|
||||
<Image className="mr-2 h-4 w-4" />
|
||||
Image
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="cursor-pointer">
|
||||
<FileText className="mr-2 h-4 w-4" />
|
||||
Text File
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="cursor-pointer">
|
||||
<FileText className="mr-2 h-4 w-4" />
|
||||
PDF
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="cursor-pointer" onSelect={() => setUrlDialogOpen(true)}>
|
||||
<LinkIcon className="mr-2 h-4 w-4" />
|
||||
Webpage URL
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<input
|
||||
ref={imageInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(ev) => {
|
||||
const file = ev.target.files?.[0];
|
||||
if (file) handleAttachImage(file);
|
||||
ev.target.value = '';
|
||||
}}
|
||||
/>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={(ev) => setInput(ev.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={(ev) => {
|
||||
const items = ev.clipboardData?.items;
|
||||
if (!items) return;
|
||||
for (const item of Array.from(items)) {
|
||||
if (item.type.startsWith('image/')) {
|
||||
ev.preventDefault();
|
||||
const file = item.getAsFile();
|
||||
if (file) handleAttachImage(file);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}}
|
||||
placeholder="What do you want to work on now?"
|
||||
rows={1}
|
||||
className="flex-1 resize-none bg-transparent px-2 py-2 text-duck-dark placeholder:text-duck-dark/30 focus:outline-none text-lg"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={!input.trim()}
|
||||
size="icon"
|
||||
className="shrink-0 h-10 w-10 bg-duck-teal hover:bg-duck-teal/90 cursor-pointer disabled:opacity-40"
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between px-4 pb-3">
|
||||
<div className="flex items-center gap-1 rounded-lg bg-duck-dark/5 p-1">
|
||||
{(['claude', 'opencode'] as const).map((value) => (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => {
|
||||
setProvider(value);
|
||||
setModel(null);
|
||||
}}
|
||||
className={`rounded-md px-3 py-1 text-xs font-medium transition-colors cursor-pointer ${
|
||||
provider === value
|
||||
? 'bg-white text-duck-dark shadow-sm'
|
||||
: 'text-duck-dark/50 hover:text-duck-dark/70'
|
||||
}`}
|
||||
>
|
||||
{value === 'claude' ? 'Claude' : 'OpenCode'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{models.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="flex items-center gap-1 text-xs text-duck-dark/50 hover:text-duck-dark/70 cursor-pointer transition-colors">
|
||||
{models.find((m) => m.id === (model ?? models[0]?.id))?.name ?? models[0]?.name}
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="z-[600] max-h-64 overflow-y-auto">
|
||||
{models.map((m) => (
|
||||
<DropdownMenuItem key={m.id} onClick={() => setModel(m.id)} className="cursor-pointer">
|
||||
<Check
|
||||
className={`mr-2 h-3 w-3 ${(model ?? models[0]?.id) === m.id ? 'opacity-100' : 'opacity-0'}`}
|
||||
/>
|
||||
<span className="font-bold">{m.name}</span>
|
||||
{m.provider && <span className="text-duck-dark/50 ml-1">({m.provider})</span>}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</Widget>
|
||||
|
||||
<Dialog open={urlDialogOpen} onOpenChange={setUrlDialogOpen}>
|
||||
<DialogContent className="sm:max-w-md z-[700]">
|
||||
@@ -351,6 +330,6 @@ export const ChatLauncher = () => {
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,19 +1,26 @@
|
||||
import { Workspace, createWorkspaceDefaults } from '@/components/Workspace';
|
||||
import { useUserState } from '@/state/useUserState';
|
||||
import { ChatLauncher } from './ChatLauncher';
|
||||
import { FileBrowserWidget as FileBrowser } from '@/Screens/Dashboard/Files';
|
||||
import { ChatHistoryWidget as ChatHistory } from '@/Screens/Dashboard/ChatHistory';
|
||||
import { Catalog } from 'sounds';
|
||||
|
||||
const WIDGET_IDS = ['chat-launcher', 'file-browser', 'chat-history', 'sound-library'] as const;
|
||||
const DEFAULTS = createWorkspaceDefaults([...WIDGET_IDS]);
|
||||
|
||||
export const HomeScreen = () => {
|
||||
const [state, setState] = useUserState('home-layout', DEFAULTS);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col md:flex-row gap-4 md:gap-8 h-full p-4 pt-6 md:p-8 md:pt-12 overflow-y-auto">
|
||||
<div className="flex flex-col gap-8 flex-1 min-w-0">
|
||||
<ChatLauncher />
|
||||
<FileBrowser />
|
||||
</div>
|
||||
<div className="flex flex-col gap-8 flex-1 min-w-0">
|
||||
<ChatHistory />
|
||||
<Catalog />
|
||||
</div>
|
||||
</div>
|
||||
<Workspace
|
||||
widgets={{
|
||||
'chat-launcher': <ChatLauncher />,
|
||||
'file-browser': <FileBrowser />,
|
||||
'chat-history': <ChatHistory />,
|
||||
'sound-library': <Catalog />,
|
||||
}}
|
||||
state={state}
|
||||
onChange={setState}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import rehypeRaw from 'rehype-raw';
|
||||
import { toast } from 'sonner';
|
||||
import { ArrowLeft, Pencil, Plus, Check, X, Trash2, Search } from 'lucide-react';
|
||||
import { ArrowLeft, Pencil, Plus, Check, X, Trash2, Search, ChevronRight } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
@@ -22,6 +22,7 @@ type ResourceSummary = {
|
||||
|
||||
type ResourceDetail = ResourceSummary & {
|
||||
body: string;
|
||||
rawFrontmatter: string;
|
||||
filePath: string;
|
||||
chatSessionId: string | null;
|
||||
};
|
||||
@@ -109,6 +110,25 @@ const ResourceChat = ({
|
||||
);
|
||||
};
|
||||
|
||||
const FrontmatterBlock = ({ yaml }: { yaml: string }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="mb-4 rounded border border-duck-dark/10 bg-duck-dark/3 text-sm">
|
||||
<button
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="flex w-full items-center gap-1.5 px-3 py-1.5 text-duck-dark/50 hover:text-duck-dark/70 cursor-pointer transition-colors"
|
||||
>
|
||||
<ChevronRight className={`h-3.5 w-3.5 transition-transform ${open ? 'rotate-90' : ''}`} />
|
||||
<span className="text-xs font-medium">Frontmatter</span>
|
||||
</button>
|
||||
{open && (
|
||||
<pre className="px-4 pb-3 text-xs text-duck-dark/60 whitespace-pre-wrap font-mono leading-relaxed">{yaml}</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ResourcePage = ({ kind, endpoint, queryKey }: ResourcePageProps) => {
|
||||
const client = useClient();
|
||||
const qc = useQueryClient();
|
||||
@@ -315,6 +335,7 @@ export const ResourcePage = ({ kind, endpoint, queryKey }: ResourcePageProps) =>
|
||||
)}
|
||||
</div>
|
||||
<div className="overflow-y-auto flex-1 p-6">
|
||||
{detail?.rawFrontmatter && <FrontmatterBlock yaml={detail.rawFrontmatter} />}
|
||||
{detail?.body ? (
|
||||
<article className="skill-md">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>
|
||||
|
||||
@@ -8,9 +8,9 @@ type Frontmatter = {
|
||||
description: string;
|
||||
};
|
||||
|
||||
export function parseFrontmatter(raw: string): { frontmatter: Frontmatter; body: string } {
|
||||
export function parseFrontmatter(raw: string): { frontmatter: Frontmatter; body: string; rawYaml: string } {
|
||||
const match = raw.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
||||
if (!match) return { frontmatter: { name: '', description: '' }, body: raw };
|
||||
if (!match) return { frontmatter: { name: '', description: '' }, body: raw, rawYaml: '' };
|
||||
|
||||
const yaml = match[1]!;
|
||||
const body = match[2]!;
|
||||
@@ -18,7 +18,7 @@ export function parseFrontmatter(raw: string): { frontmatter: Frontmatter; body:
|
||||
const name = yaml.match(/^name:\s*(.+)$/m)?.[1]?.trim() ?? '';
|
||||
const description = yaml.match(/^description:\s*(.+)$/m)?.[1]?.trim() ?? '';
|
||||
|
||||
return { frontmatter: { name, description }, body };
|
||||
return { frontmatter: { name, description }, body, rawYaml: yaml };
|
||||
}
|
||||
|
||||
export async function readProcessDirs(dir: string): Promise<Map<string, string>> {
|
||||
@@ -89,7 +89,7 @@ processesRouter.get('/:name', async (ctx) => {
|
||||
if (!resolved) return ctx.text('Not found', 404);
|
||||
|
||||
const raw = await Bun.file(resolved.filePath).text();
|
||||
const { frontmatter, body } = parseFrontmatter(raw);
|
||||
const { frontmatter, body, rawYaml } = parseFrontmatter(raw);
|
||||
|
||||
const chatMeta = join(dirname(resolved.filePath), 'chat', 'meta.json');
|
||||
const chatSessionId = await Bun.file(chatMeta).json().then((m: { id: string }) => m.id).catch(() => null);
|
||||
@@ -99,6 +99,7 @@ processesRouter.get('/:name', async (ctx) => {
|
||||
description: frontmatter.description,
|
||||
scope: resolved.scope,
|
||||
body,
|
||||
rawFrontmatter: rawYaml,
|
||||
filePath: resolved.filePath,
|
||||
chatSessionId,
|
||||
});
|
||||
|
||||
@@ -8,9 +8,9 @@ type Frontmatter = {
|
||||
description: string;
|
||||
};
|
||||
|
||||
export function parseFrontmatter(raw: string): { frontmatter: Frontmatter; body: string } {
|
||||
export function parseFrontmatter(raw: string): { frontmatter: Frontmatter; body: string; rawYaml: string } {
|
||||
const match = raw.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
||||
if (!match) return { frontmatter: { name: '', description: '' }, body: raw };
|
||||
if (!match) return { frontmatter: { name: '', description: '' }, body: raw, rawYaml: '' };
|
||||
|
||||
const yaml = match[1]!;
|
||||
const body = match[2]!;
|
||||
@@ -18,7 +18,7 @@ export function parseFrontmatter(raw: string): { frontmatter: Frontmatter; body:
|
||||
const name = yaml.match(/^name:\s*(.+)$/m)?.[1]?.trim() ?? '';
|
||||
const description = yaml.match(/^description:\s*(.+)$/m)?.[1]?.trim() ?? '';
|
||||
|
||||
return { frontmatter: { name, description }, body };
|
||||
return { frontmatter: { name, description }, body, rawYaml: yaml };
|
||||
}
|
||||
|
||||
export async function readSkillDirs(dir: string): Promise<Map<string, string>> {
|
||||
@@ -89,7 +89,7 @@ skillsRouter.get('/:name', async (ctx) => {
|
||||
if (!resolved) return ctx.text('Not found', 404);
|
||||
|
||||
const raw = await Bun.file(resolved.filePath).text();
|
||||
const { frontmatter, body } = parseFrontmatter(raw);
|
||||
const { frontmatter, body, rawYaml } = parseFrontmatter(raw);
|
||||
|
||||
const chatMeta = join(dirname(resolved.filePath), 'chat', 'meta.json');
|
||||
const chatSessionId = await Bun.file(chatMeta).json().then((m: { id: string }) => m.id).catch(() => null);
|
||||
@@ -99,6 +99,7 @@ skillsRouter.get('/:name', async (ctx) => {
|
||||
description: frontmatter.description,
|
||||
scope: resolved.scope,
|
||||
body,
|
||||
rawFrontmatter: rawYaml,
|
||||
filePath: resolved.filePath,
|
||||
chatSessionId,
|
||||
});
|
||||
|
||||
@@ -11,9 +11,9 @@ type Frontmatter = {
|
||||
triggers: TriggerConfig[];
|
||||
};
|
||||
|
||||
export function parseFrontmatter(raw: string): { frontmatter: Frontmatter; body: string } {
|
||||
export function parseFrontmatter(raw: string): { frontmatter: Frontmatter; body: string; rawYaml: string } {
|
||||
const match = raw.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
||||
if (!match) return { frontmatter: { name: '', description: '', triggers: [] }, body: raw };
|
||||
if (!match) return { frontmatter: { name: '', description: '', triggers: [] }, body: raw, rawYaml: '' };
|
||||
|
||||
const yaml = match[1]!;
|
||||
const body = match[2]!;
|
||||
@@ -38,7 +38,7 @@ export function parseFrontmatter(raw: string): { frontmatter: Frontmatter; body:
|
||||
}
|
||||
}
|
||||
|
||||
return { frontmatter: { name, description, triggers }, body };
|
||||
return { frontmatter: { name, description, triggers }, body, rawYaml: yaml };
|
||||
}
|
||||
|
||||
export async function readTaskDirs(dir: string): Promise<Map<string, string>> {
|
||||
@@ -116,7 +116,7 @@ tasksRouter.get('/:name', async (ctx) => {
|
||||
if (!resolved) return ctx.text('Not found', 404);
|
||||
|
||||
const raw = await Bun.file(resolved.filePath).text();
|
||||
const { frontmatter, body } = parseFrontmatter(raw);
|
||||
const { frontmatter, body, rawYaml } = parseFrontmatter(raw);
|
||||
|
||||
const chatMeta = join(dirname(resolved.filePath), 'chat', 'meta.json');
|
||||
const chatSessionId = await Bun.file(chatMeta).json().then((m: { id: string }) => m.id).catch(() => null);
|
||||
@@ -126,6 +126,7 @@ tasksRouter.get('/:name', async (ctx) => {
|
||||
description: frontmatter.description,
|
||||
scope: resolved.scope,
|
||||
body,
|
||||
rawFrontmatter: rawYaml,
|
||||
filePath: resolved.filePath,
|
||||
chatSessionId,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,409 @@
|
||||
import type { ReactNode, PointerEvent as ReactPointerEvent, MouseEvent as ReactMouseEvent } from 'react';
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { Columns, Grid2x2, Move } from 'lucide-react';
|
||||
import { cn } from 'helpers/cn';
|
||||
|
||||
// --- Public Types ---
|
||||
|
||||
export type LayoutMode = 'free' | 'spectacle' | 'hyprland';
|
||||
export type FreePosition = { x: number; y: number; w: number; h: number };
|
||||
export type SpectacleZone = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
|
||||
|
||||
export type WorkspaceState = {
|
||||
mode: LayoutMode;
|
||||
free: Record<string, FreePosition>;
|
||||
spectacle: Record<string, SpectacleZone>;
|
||||
hyprland: { master: string; stack: string[] };
|
||||
};
|
||||
|
||||
type WorkspaceOnChange = (update: WorkspaceState | ((prev: WorkspaceState) => WorkspaceState)) => void;
|
||||
|
||||
type WorkspaceProps = {
|
||||
widgets: Record<string, ReactNode>;
|
||||
state: WorkspaceState;
|
||||
onChange: WorkspaceOnChange;
|
||||
};
|
||||
|
||||
// --- Defaults Helper ---
|
||||
|
||||
const SPECTACLE_ZONES: SpectacleZone[] = ['top-left', 'top-right', 'bottom-left', 'bottom-right'];
|
||||
|
||||
export function createWorkspaceDefaults(widgetIds: string[]): WorkspaceState {
|
||||
return {
|
||||
mode: 'spectacle',
|
||||
free: Object.fromEntries(
|
||||
widgetIds.map((id, i) => [
|
||||
id,
|
||||
{ x: 40 + (i % 2) * 440, y: 40 + Math.floor(i / 2) * 320, w: 420, h: 300 },
|
||||
]),
|
||||
),
|
||||
spectacle: Object.fromEntries(
|
||||
widgetIds.map((id, i) => [id, SPECTACLE_ZONES[i % SPECTACLE_ZONES.length]!]),
|
||||
),
|
||||
hyprland: {
|
||||
master: widgetIds[0]!,
|
||||
stack: widgetIds.slice(1),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// --- Internal Layout Hook ---
|
||||
|
||||
function useWorkspaceLayout(widgetIds: string[], state: WorkspaceState, onChange: WorkspaceOnChange) {
|
||||
const [zOrder, setZOrder] = useState<string[]>(() => [...widgetIds]);
|
||||
const [dragOverride, setDragOverride] = useState<{ id: string; pos: FreePosition } | null>(null);
|
||||
|
||||
const setMode = useCallback(
|
||||
(mode: LayoutMode) => onChange((prev) => ({ ...prev, mode })),
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const setFreePositions = useCallback(
|
||||
(positions: Record<string, FreePosition>) =>
|
||||
onChange((prev) => ({ ...prev, mode: 'free' as const, free: positions })),
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const updateFreePosition = useCallback(
|
||||
(id: string, pos: Partial<FreePosition>) =>
|
||||
onChange((prev) => {
|
||||
const base = prev.free[id] ?? { x: 0, y: 0, w: 420, h: 300 };
|
||||
const updated: FreePosition = { ...base, ...pos };
|
||||
return { ...prev, free: { ...prev.free, [id]: updated } };
|
||||
}),
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const swapZones = useCallback(
|
||||
(a: string, b: string) => {
|
||||
if (a === b) return;
|
||||
onChange((prev) => {
|
||||
const zoneA = prev.spectacle[a];
|
||||
const zoneB = prev.spectacle[b];
|
||||
if (!zoneA || !zoneB) return prev;
|
||||
return { ...prev, spectacle: { ...prev.spectacle, [a]: zoneB, [b]: zoneA } };
|
||||
});
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const promoteMaster = useCallback(
|
||||
(id: string) =>
|
||||
onChange((prev) => {
|
||||
if (prev.hyprland.master === id) return prev;
|
||||
const oldMaster = prev.hyprland.master;
|
||||
return {
|
||||
...prev,
|
||||
hyprland: {
|
||||
master: id,
|
||||
stack: prev.hyprland.stack.map((s) => (s === id ? oldMaster : s)),
|
||||
},
|
||||
};
|
||||
}),
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const bringToFront = useCallback(
|
||||
(id: string) =>
|
||||
setZOrder((prev) => {
|
||||
if (prev[prev.length - 1] === id) return prev;
|
||||
return [...prev.filter((w) => w !== id), id];
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
mode: state.mode,
|
||||
setMode,
|
||||
setFreePositions,
|
||||
free: state.free,
|
||||
updateFreePosition,
|
||||
spectacle: state.spectacle,
|
||||
swapZones,
|
||||
hyprland: state.hyprland,
|
||||
promoteMaster,
|
||||
zOrder,
|
||||
bringToFront,
|
||||
dragOverride,
|
||||
setDragOverride,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Workspace Component ---
|
||||
|
||||
export const Workspace = ({ widgets, state, onChange }: WorkspaceProps) => {
|
||||
const widgetIds = Object.keys(widgets);
|
||||
const layout = useWorkspaceLayout(widgetIds, state, onChange);
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleModeChange = (newMode: LayoutMode) => {
|
||||
if (newMode === 'free' && canvasRef.current) {
|
||||
const canvasRect = canvasRef.current.getBoundingClientRect();
|
||||
const positions: Record<string, FreePosition> = {};
|
||||
for (const id of widgetIds) {
|
||||
const wrapper = canvasRef.current.querySelector<HTMLElement>(`[data-widget-id="${id}"]`);
|
||||
const el = (wrapper?.firstElementChild as HTMLElement | null) ?? wrapper;
|
||||
if (el) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
positions[id] = {
|
||||
x: rect.left - canvasRect.left,
|
||||
y: rect.top - canvasRect.top,
|
||||
w: rect.width,
|
||||
h: rect.height,
|
||||
};
|
||||
} else {
|
||||
positions[id] = layout.free[id] ?? { x: 0, y: 0, w: 420, h: 300 };
|
||||
}
|
||||
}
|
||||
layout.setFreePositions(positions);
|
||||
} else {
|
||||
layout.setMode(newMode);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={canvasRef} className="relative h-full w-full overflow-hidden">
|
||||
{layout.mode === 'free' && (
|
||||
<FreeLayout widgets={widgets} widgetIds={widgetIds} layout={layout} canvasRef={canvasRef} />
|
||||
)}
|
||||
{layout.mode === 'spectacle' && (
|
||||
<SpectacleLayout widgets={widgets} widgetIds={widgetIds} layout={layout} canvasRef={canvasRef} />
|
||||
)}
|
||||
{layout.mode === 'hyprland' && <HyprlandLayout widgets={widgets} layout={layout} />}
|
||||
<ModeSwitcher mode={layout.mode} onChange={handleModeChange} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Sub-component Props ---
|
||||
|
||||
type LayoutProps = {
|
||||
widgets: Record<string, ReactNode>;
|
||||
widgetIds: string[];
|
||||
layout: ReturnType<typeof useWorkspaceLayout>;
|
||||
canvasRef: React.RefObject<HTMLDivElement | null>;
|
||||
};
|
||||
|
||||
// --- Free Mode ---
|
||||
|
||||
const FreeLayout = ({ widgets, widgetIds, layout, canvasRef }: LayoutProps) => {
|
||||
const dragRef = useRef<{
|
||||
id: string;
|
||||
startX: number;
|
||||
startY: number;
|
||||
originX: number;
|
||||
originY: number;
|
||||
w: number;
|
||||
h: number;
|
||||
} | null>(null);
|
||||
|
||||
const onPointerDown = (id: string, ev: ReactPointerEvent<HTMLDivElement>) => {
|
||||
layout.bringToFront(id);
|
||||
|
||||
const target = ev.target as HTMLElement;
|
||||
if (!target.closest('[data-widget-header]') || target.closest('button')) return;
|
||||
if (ev.detail === 2) return;
|
||||
|
||||
const pos = layout.free[id];
|
||||
if (!pos) return;
|
||||
dragRef.current = {
|
||||
id,
|
||||
startX: ev.clientX,
|
||||
startY: ev.clientY,
|
||||
originX: pos.x,
|
||||
originY: pos.y,
|
||||
w: pos.w,
|
||||
h: pos.h,
|
||||
};
|
||||
(ev.currentTarget as HTMLElement).setPointerCapture(ev.pointerId);
|
||||
ev.preventDefault();
|
||||
};
|
||||
|
||||
const onPointerMove = (ev: ReactPointerEvent<HTMLDivElement>) => {
|
||||
const d = dragRef.current;
|
||||
if (!d || !canvasRef.current) return;
|
||||
|
||||
const canvas = canvasRef.current.getBoundingClientRect();
|
||||
const newX = Math.max(0, Math.min(d.originX + ev.clientX - d.startX, canvas.width - d.w));
|
||||
const newY = Math.max(0, Math.min(d.originY + ev.clientY - d.startY, canvas.height - 40));
|
||||
layout.setDragOverride({ id: d.id, pos: { x: newX, y: newY, w: d.w, h: d.h } });
|
||||
};
|
||||
|
||||
const onPointerUp = () => {
|
||||
const d = dragRef.current;
|
||||
if (!d) return;
|
||||
|
||||
if (layout.dragOverride?.id === d.id) {
|
||||
layout.updateFreePosition(d.id, layout.dragOverride.pos);
|
||||
layout.setDragOverride(null);
|
||||
}
|
||||
dragRef.current = null;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{widgetIds.map((id) => {
|
||||
const pos = layout.dragOverride?.id === id ? layout.dragOverride.pos : layout.free[id];
|
||||
if (!pos) return null;
|
||||
const zIndex = layout.zOrder.indexOf(id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
data-widget-id={id}
|
||||
className="absolute"
|
||||
style={{ left: pos.x, top: pos.y, width: pos.w, height: pos.h, zIndex }}
|
||||
onPointerDown={(ev) => onPointerDown(id, ev)}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
>
|
||||
{widgets[id]}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Spectacle Mode ---
|
||||
|
||||
const ZONE_GRID_AREA: Record<SpectacleZone, string> = {
|
||||
'top-left': '1 / 1 / 2 / 2',
|
||||
'top-right': '1 / 2 / 2 / 3',
|
||||
'bottom-left': '2 / 1 / 3 / 2',
|
||||
'bottom-right': '2 / 2 / 3 / 3',
|
||||
};
|
||||
|
||||
const SpectacleLayout = ({ widgets, widgetIds, layout, canvasRef }: LayoutProps) => {
|
||||
const [dragTarget, setDragTarget] = useState<SpectacleZone | null>(null);
|
||||
const dragRef = useRef<{ id: string } | null>(null);
|
||||
|
||||
const getZoneFromPoint = (clientX: number, clientY: number): SpectacleZone | null => {
|
||||
if (!canvasRef.current) return null;
|
||||
const rect = canvasRef.current.getBoundingClientRect();
|
||||
const x = clientX - rect.left;
|
||||
const y = clientY - rect.top;
|
||||
if (x < 0 || y < 0 || x > rect.width || y > rect.height) return null;
|
||||
const col = x < rect.width / 2 ? 'left' : 'right';
|
||||
const row = y < rect.height / 2 ? 'top' : 'bottom';
|
||||
return `${row}-${col}` as SpectacleZone;
|
||||
};
|
||||
|
||||
const widgetByZone: Record<string, string> = {};
|
||||
for (const id of widgetIds) {
|
||||
const zone = layout.spectacle[id];
|
||||
if (zone) widgetByZone[zone] = id;
|
||||
}
|
||||
|
||||
const onPointerDown = (id: string, ev: ReactPointerEvent<HTMLDivElement>) => {
|
||||
const target = ev.target as HTMLElement;
|
||||
if (!target.closest('[data-widget-header]') || target.closest('button')) return;
|
||||
if (ev.detail === 2) return;
|
||||
|
||||
dragRef.current = { id };
|
||||
(ev.currentTarget as HTMLElement).setPointerCapture(ev.pointerId);
|
||||
ev.preventDefault();
|
||||
};
|
||||
|
||||
const onPointerMove = (ev: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (!dragRef.current) return;
|
||||
const zone = getZoneFromPoint(ev.clientX, ev.clientY);
|
||||
setDragTarget(zone && zone !== layout.spectacle[dragRef.current.id] ? zone : null);
|
||||
};
|
||||
|
||||
const onPointerUp = () => {
|
||||
const d = dragRef.current;
|
||||
if (d && dragTarget) {
|
||||
const occupant = widgetByZone[dragTarget];
|
||||
if (occupant && occupant !== d.id) {
|
||||
layout.swapZones(d.id, occupant);
|
||||
}
|
||||
}
|
||||
setDragTarget(null);
|
||||
dragRef.current = null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="grid h-full w-full gap-2 p-2"
|
||||
style={{ gridTemplateColumns: '1fr 1fr', gridTemplateRows: '1fr 1fr' }}
|
||||
>
|
||||
{widgetIds.map((id) => {
|
||||
const zone = layout.spectacle[id];
|
||||
if (!zone) return null;
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
data-widget-id={id}
|
||||
className="h-full w-full overflow-hidden"
|
||||
style={{ gridArea: ZONE_GRID_AREA[zone] }}
|
||||
onPointerDown={(ev) => onPointerDown(id, ev)}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
>
|
||||
{widgets[id]}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{dragTarget && (
|
||||
<div
|
||||
className="pointer-events-none z-10 rounded-lg border-2 border-duck-teal/30 bg-duck-teal/10"
|
||||
style={{ gridArea: ZONE_GRID_AREA[dragTarget] }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Hyprland Mode ---
|
||||
|
||||
const HyprlandLayout = ({ widgets, layout }: Omit<LayoutProps, 'canvasRef' | 'widgetIds'>) => {
|
||||
const onDoubleClick = (id: string, ev: ReactMouseEvent<HTMLDivElement>) => {
|
||||
const target = ev.target as HTMLElement;
|
||||
if (!target.closest('[data-widget-header]') || target.closest('button')) return;
|
||||
layout.promoteMaster(id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full gap-2 p-2">
|
||||
<div className="min-w-0 flex-1" data-widget-id={layout.hyprland.master}>
|
||||
{widgets[layout.hyprland.master]}
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-2">
|
||||
{layout.hyprland.stack.map((id) => (
|
||||
<div key={id} className="min-h-0 flex-1" data-widget-id={id} onDoubleClick={(ev) => onDoubleClick(id, ev)}>
|
||||
{widgets[id]}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Mode Switcher ---
|
||||
|
||||
const MODE_OPTIONS: { mode: LayoutMode; icon: typeof Move; label: string }[] = [
|
||||
{ mode: 'spectacle', icon: Grid2x2, label: 'Snap' },
|
||||
{ mode: 'free', icon: Move, label: 'Free' },
|
||||
{ mode: 'hyprland', icon: Columns, label: 'Auto' },
|
||||
];
|
||||
|
||||
const ModeSwitcher = ({ mode, onChange }: { mode: LayoutMode; onChange: (m: LayoutMode) => void }) => (
|
||||
<div className="absolute bottom-4 right-4 z-10 flex items-center gap-1 rounded-full border border-border bg-background/80 p-1 backdrop-blur-sm">
|
||||
{MODE_OPTIONS.map(({ mode: m, icon: Icon, label }) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
onClick={() => onChange(m)}
|
||||
title={label}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition-all cursor-pointer',
|
||||
mode === m ? 'bg-white text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useState, useRef, useMemo } from 'react';
|
||||
import { Search, Play, Square, AudioLines, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { Card } from '@/components/Card';
|
||||
import { Search, Play, Square, AudioLines } from 'lucide-react';
|
||||
import { Widget } from '@/components/Widget';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useUserState } from '@/state/useUserState';
|
||||
import { categories, allSounds } from './catalog-data';
|
||||
import type { SoundAsset } from './types';
|
||||
|
||||
@@ -50,7 +49,6 @@ const SoundCard = ({
|
||||
);
|
||||
|
||||
export const Catalog = () => {
|
||||
const [collapsed, setCollapsed] = useUserState('widget:soundLibrary:collapsed', true);
|
||||
const [search, setSearch] = useState('');
|
||||
const [activeCategory, setActiveCategory] = useState<ActiveCategory>('all');
|
||||
const [playingId, setPlayingId] = useState<string | null>(null);
|
||||
@@ -90,90 +88,77 @@ export const Catalog = () => {
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Card className="overflow-hidden">
|
||||
<div className={`flex items-center justify-between px-4 pt-3 ${collapsed ? 'pb-3' : 'pb-1'}`}>
|
||||
<span className="text-xs font-semibold text-duck-dark/60 uppercase tracking-wide">Sound Library</span>
|
||||
<button
|
||||
onClick={() => setCollapsed((c) => !c)}
|
||||
className="text-duck-dark/40 hover:text-duck-dark/70 cursor-pointer transition-colors"
|
||||
>
|
||||
{collapsed ? <ChevronDown className="h-4 w-4" /> : <ChevronUp className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div className="flex flex-col gap-4 px-4 pb-4">
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3">
|
||||
<div className="relative w-full sm:w-56">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-duck-dark/40" />
|
||||
<Input
|
||||
placeholder="Search sounds..."
|
||||
value={search}
|
||||
onChange={(ev) => setSearch(ev.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => setActiveCategory(tab.id)}
|
||||
className={`px-2.5 py-1 rounded-full text-xs font-medium cursor-pointer transition-colors border ${
|
||||
activeCategory === tab.id
|
||||
? 'border-duck-teal bg-duck-teal/10 text-duck-teal'
|
||||
: 'border-duck-dark/15 text-duck-dark/50 hover:border-duck-dark/30'
|
||||
}`}
|
||||
>
|
||||
{tab.label} <span className="opacity-60">{tab.count}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-xs text-duck-dark/40 ml-auto hidden sm:block">{filtered.length} sounds</span>
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 && <p className="text-sm text-duck-dark/40 py-8 text-center">No sounds found.</p>}
|
||||
|
||||
<div className="max-h-[400px] overflow-y-auto">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
{filtered.map((sound) => (
|
||||
<SoundCard
|
||||
key={sound.name}
|
||||
sound={sound}
|
||||
isPlaying={playingId === sound.name}
|
||||
onPlay={() => play(sound)}
|
||||
onStop={stop}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-1">
|
||||
<span className="text-[11px] text-duck-dark/70">
|
||||
CC0 licensed · Original audio by{' '}
|
||||
<a
|
||||
href="https://kenney.nl/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:text-duck-dark"
|
||||
>
|
||||
Kenney
|
||||
</a>
|
||||
</span>
|
||||
<a
|
||||
href="https://www.soundcn.xyz/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<span className="text-xs text-duck-dark/40">powered by</span>
|
||||
<AudioLines className="h-5 w-5" style={{ color: '#F5A32F' }} />
|
||||
<span className="text-sm font-bold text-duck-dark">soundcn</span>
|
||||
</a>
|
||||
</div>
|
||||
<Widget title="Sound Library">
|
||||
<div className="flex flex-col gap-4 px-4 pb-4">
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3">
|
||||
<div className="relative w-full sm:w-56">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-duck-dark/40" />
|
||||
<Input
|
||||
placeholder="Search sounds..."
|
||||
value={search}
|
||||
onChange={(ev) => setSearch(ev.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => setActiveCategory(tab.id)}
|
||||
className={`px-2.5 py-1 rounded-full text-xs font-medium cursor-pointer transition-colors border ${
|
||||
activeCategory === tab.id
|
||||
? 'border-duck-teal bg-duck-teal/10 text-duck-teal'
|
||||
: 'border-duck-dark/15 text-duck-dark/50 hover:border-duck-dark/30'
|
||||
}`}
|
||||
>
|
||||
{tab.label} <span className="opacity-60">{tab.count}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-xs text-duck-dark/40 ml-auto hidden sm:block">{filtered.length} sounds</span>
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 && <p className="text-sm text-duck-dark/40 py-8 text-center">No sounds found.</p>}
|
||||
|
||||
<div className="max-h-[400px] overflow-y-auto">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
{filtered.map((sound) => (
|
||||
<SoundCard
|
||||
key={sound.name}
|
||||
sound={sound}
|
||||
isPlaying={playingId === sound.name}
|
||||
onPlay={() => play(sound)}
|
||||
onStop={stop}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-1">
|
||||
<span className="text-[11px] text-duck-dark/70">
|
||||
CC0 licensed · Original audio by{' '}
|
||||
<a
|
||||
href="https://kenney.nl/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:text-duck-dark"
|
||||
>
|
||||
Kenney
|
||||
</a>
|
||||
</span>
|
||||
<a
|
||||
href="https://www.soundcn.xyz/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<span className="text-xs text-duck-dark/40">powered by</span>
|
||||
<AudioLines className="h-5 w-5" style={{ color: '#F5A32F' }} />
|
||||
<span className="text-sm font-bold text-duck-dark">soundcn</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</Widget>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user