Initial commit for project Spoon!
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
'use client';
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { Bot, GitBranch, LayoutDashboard, RefreshCw, User } from 'lucide-react';
|
||||
|
||||
import { cn } from '@spoon/ui';
|
||||
|
||||
const navItems = [
|
||||
{ href: '/dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
||||
{ href: '/spoons', label: 'My Spoons', icon: GitBranch },
|
||||
{ href: '/updates', label: 'Updates', icon: RefreshCw },
|
||||
{ href: '/agents', label: 'Agents', icon: Bot },
|
||||
{ href: '/profile', label: 'Profile', icon: User },
|
||||
];
|
||||
|
||||
export const AppShell = ({ children }: { children: ReactNode }) => {
|
||||
const pathname = usePathname();
|
||||
return (
|
||||
<div className='bg-muted/20 flex-1 border-t'>
|
||||
<div className='container mx-auto grid gap-6 px-4 py-6 lg:grid-cols-[14rem_1fr]'>
|
||||
<aside className='lg:sticky lg:top-20 lg:self-start'>
|
||||
<nav className='border-border bg-card flex gap-1 overflow-x-auto border p-2 lg:flex-col'>
|
||||
{navItems.map(({ href, label, icon: Icon }) => {
|
||||
const active =
|
||||
pathname === href ||
|
||||
(href !== '/dashboard' && pathname.startsWith(href));
|
||||
return (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
className={cn(
|
||||
'hover:bg-muted flex min-w-fit items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors',
|
||||
active
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<Icon className='size-4' />
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
<div className='min-w-0'>{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import Link from 'next/link';
|
||||
import { Utensils } from 'lucide-react';
|
||||
|
||||
import { cn } from '@spoon/ui';
|
||||
|
||||
export const LogoMark = ({ className }: { className?: string }) => (
|
||||
<span
|
||||
className={cn(
|
||||
'bg-primary text-primary-foreground inline-flex size-9 items-center justify-center rounded-md',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Utensils className='size-5' />
|
||||
</span>
|
||||
);
|
||||
|
||||
export const SpoonLogo = ({ className }: { className?: string }) => (
|
||||
<Link href='/' className={cn('flex items-center gap-2', className)}>
|
||||
<LogoMark />
|
||||
<span className='text-xl font-semibold tracking-normal'>Spoon</span>
|
||||
</Link>
|
||||
);
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
|
||||
import { Card, CardContent } from '@spoon/ui';
|
||||
|
||||
export const MetricCard = ({
|
||||
label,
|
||||
value,
|
||||
note,
|
||||
icon: Icon,
|
||||
}: {
|
||||
label: string;
|
||||
value: number | string;
|
||||
note: string;
|
||||
icon: LucideIcon;
|
||||
}) => (
|
||||
<Card className='shadow-none'>
|
||||
<CardContent className='p-5'>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<p className='text-muted-foreground text-sm'>{label}</p>
|
||||
<Icon className='text-primary size-4' />
|
||||
</div>
|
||||
<p className='mt-3 text-3xl font-semibold'>{value}</p>
|
||||
<p className='text-muted-foreground mt-1 text-xs'>{note}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
@@ -0,0 +1,27 @@
|
||||
import Link from 'next/link';
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
|
||||
import { Button } from '@spoon/ui';
|
||||
|
||||
export const CTA = () => (
|
||||
<section className='container mx-auto px-4 py-20'>
|
||||
<div className='border-border bg-card flex flex-col items-start justify-between gap-6 border p-8 md:flex-row md:items-center'>
|
||||
<div>
|
||||
<h2 className='text-2xl font-semibold tracking-normal'>
|
||||
Start your first Spoon
|
||||
</h2>
|
||||
<p className='text-muted-foreground mt-2 max-w-2xl'>
|
||||
Create a manual managed fork record today. Provider connections,
|
||||
scheduled checks, and AI merge request automation can build on the
|
||||
same foundation.
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild>
|
||||
<Link href='/spoons/new'>
|
||||
New Spoon
|
||||
<ArrowRight className='size-4' />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
@@ -0,0 +1,142 @@
|
||||
import {
|
||||
Bot,
|
||||
GitMerge,
|
||||
History,
|
||||
SearchCheck,
|
||||
ShieldCheck,
|
||||
TriangleAlert,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@spoon/ui';
|
||||
|
||||
const maintenance = [
|
||||
{
|
||||
title: 'Upstream security fixes',
|
||||
description:
|
||||
'Track the changes that land upstream so important fixes do not disappear into fork drift.',
|
||||
icon: ShieldCheck,
|
||||
},
|
||||
{
|
||||
title: 'Conflict detection',
|
||||
description:
|
||||
'Make update risk visible before a merge request reaches the fork you actually maintain.',
|
||||
icon: TriangleAlert,
|
||||
},
|
||||
{
|
||||
title: 'AI-reviewed changes',
|
||||
description:
|
||||
'Prepare for agent-assisted analysis that explains whether upstream changes affect your custom work.',
|
||||
icon: SearchCheck,
|
||||
},
|
||||
{
|
||||
title: 'Merge request history',
|
||||
description:
|
||||
'Keep a durable timeline of upstream checks, review outcomes, and merge request decisions.',
|
||||
icon: History,
|
||||
},
|
||||
];
|
||||
|
||||
export const Workflow = () => {
|
||||
const steps = [
|
||||
'Choose upstream',
|
||||
'Create a Spoon',
|
||||
'Customize your fork',
|
||||
'Track upstream',
|
||||
'Review and merge updates',
|
||||
];
|
||||
return (
|
||||
<section id='workflow' className='border-border/60 bg-muted/30 border-y'>
|
||||
<div className='container mx-auto px-4 py-16'>
|
||||
<div className='mb-10 max-w-2xl'>
|
||||
<h2 className='text-3xl font-semibold tracking-normal'>
|
||||
A fork workflow that keeps moving
|
||||
</h2>
|
||||
<p className='text-muted-foreground mt-3'>
|
||||
Spoon starts with a provider-neutral model: upstream project,
|
||||
managed fork, update checks, and reviewable merge requests.
|
||||
</p>
|
||||
</div>
|
||||
<div className='grid gap-3 md:grid-cols-5'>
|
||||
{steps.map((step, index) => (
|
||||
<div key={step} className='border-border bg-card border p-4'>
|
||||
<p className='text-primary text-sm font-semibold'>
|
||||
{String(index + 1).padStart(2, '0')}
|
||||
</p>
|
||||
<p className='mt-4 text-sm font-medium'>{step}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export const Features = () => (
|
||||
<section id='maintenance' className='container mx-auto px-4 py-20'>
|
||||
<div className='mb-10 max-w-2xl'>
|
||||
<h2 className='text-3xl font-semibold tracking-normal'>
|
||||
Maintenance is the product
|
||||
</h2>
|
||||
<p className='text-muted-foreground mt-3'>
|
||||
The first version establishes the dashboard surfaces and records that
|
||||
future Git provider integrations and AI review jobs will use.
|
||||
</p>
|
||||
</div>
|
||||
<div className='grid gap-4 md:grid-cols-2 lg:grid-cols-4'>
|
||||
{maintenance.map(({ title, description, icon: Icon }) => (
|
||||
<Card key={title} className='border-border/70 shadow-none'>
|
||||
<CardHeader>
|
||||
<Icon className='text-primary size-5' />
|
||||
<CardTitle className='text-base'>{title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className='text-muted-foreground text-sm leading-6'>
|
||||
{description}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
export const Agents = () => (
|
||||
<section id='agents' className='border-border/60 bg-muted/30 border-y'>
|
||||
<div className='container mx-auto grid gap-8 px-4 py-20 lg:grid-cols-2'>
|
||||
<div>
|
||||
<div className='bg-primary/10 text-primary mb-4 flex size-10 items-center justify-center rounded-md'>
|
||||
<Bot className='size-5' />
|
||||
</div>
|
||||
<h2 className='text-3xl font-semibold tracking-normal'>
|
||||
Agent requests belong next to fork maintenance
|
||||
</h2>
|
||||
<p className='text-muted-foreground mt-4 leading-7'>
|
||||
Spoon is being shaped so a user can ask an agent to implement a
|
||||
change, open a merge request against the managed fork, and still keep
|
||||
upstream updates in view. This pass stores those requests without
|
||||
running automation yet.
|
||||
</p>
|
||||
</div>
|
||||
<div className='border-border bg-card border p-5'>
|
||||
<div className='mb-4 flex items-center gap-3'>
|
||||
<GitMerge className='text-primary size-5' />
|
||||
<p className='font-medium'>Queued agent request</p>
|
||||
</div>
|
||||
<p className='text-muted-foreground text-sm leading-6'>
|
||||
“Add a project-specific onboarding flow, open a merge request, and
|
||||
flag any upstream files this may affect.”
|
||||
</p>
|
||||
<div className='mt-5 grid gap-2 text-sm'>
|
||||
<div className='flex justify-between'>
|
||||
<span className='text-muted-foreground'>Target</span>
|
||||
<span>feature/onboarding</span>
|
||||
</div>
|
||||
<div className='flex justify-between'>
|
||||
<span className='text-muted-foreground'>Status</span>
|
||||
<span>Queued</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
@@ -0,0 +1,125 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useConvexAuth } from 'convex/react';
|
||||
import {
|
||||
ArrowRight,
|
||||
Bot,
|
||||
CheckCircle2,
|
||||
GitBranch,
|
||||
GitPullRequest,
|
||||
ShieldCheck,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Badge, Button } from '@spoon/ui';
|
||||
|
||||
const previewRows = [
|
||||
{
|
||||
name: 'editor-spoon',
|
||||
upstream: 'upstream/main',
|
||||
status: 'Clean update',
|
||||
icon: CheckCircle2,
|
||||
tone: 'text-emerald-600',
|
||||
},
|
||||
{
|
||||
name: 'billing-fork',
|
||||
upstream: 'release/2026.06',
|
||||
status: 'AI review queued',
|
||||
icon: Bot,
|
||||
tone: 'text-teal-600',
|
||||
},
|
||||
{
|
||||
name: 'docs-platform',
|
||||
upstream: 'main',
|
||||
status: 'Needs review',
|
||||
icon: GitPullRequest,
|
||||
tone: 'text-amber-600',
|
||||
},
|
||||
];
|
||||
|
||||
export const Hero = () => {
|
||||
const { isAuthenticated } = useConvexAuth();
|
||||
return (
|
||||
<section className='container mx-auto px-4 py-16 md:py-24'>
|
||||
<div className='grid items-center gap-10 lg:grid-cols-[0.92fr_1.08fr]'>
|
||||
<div className='max-w-3xl'>
|
||||
<Badge variant='outline' className='mb-5 gap-2'>
|
||||
<ShieldCheck className='size-3.5 text-emerald-600' />
|
||||
Self-hostable fork maintenance
|
||||
</Badge>
|
||||
<h1 className='max-w-4xl text-4xl font-semibold tracking-normal text-balance sm:text-5xl md:text-6xl'>
|
||||
Fork freely. Stay close to upstream.
|
||||
</h1>
|
||||
<p className='text-muted-foreground mt-6 max-w-2xl text-lg leading-8'>
|
||||
Spoon helps you customize upstream projects without inheriting the
|
||||
full maintenance burden. Track drift, review update risk, and keep
|
||||
managed forks ready for merge requests.
|
||||
</p>
|
||||
<div className='mt-8 flex flex-col gap-3 sm:flex-row'>
|
||||
<Button size='lg' asChild>
|
||||
<Link href={isAuthenticated ? '/dashboard' : '/sign-in'}>
|
||||
{isAuthenticated ? 'Open dashboard' : 'Start with Spoon'}
|
||||
<ArrowRight className='size-4' />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button size='lg' variant='outline' asChild>
|
||||
<Link href='#workflow'>See how it works</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='border-border bg-card border shadow-sm'>
|
||||
<div className='border-border flex items-center justify-between border-b px-5 py-4'>
|
||||
<div>
|
||||
<p className='text-sm font-medium'>Spoon dashboard</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Upstream status across managed forks
|
||||
</p>
|
||||
</div>
|
||||
<Badge className='bg-primary/10 text-primary hover:bg-primary/10'>
|
||||
3 active Spoons
|
||||
</Badge>
|
||||
</div>
|
||||
<div className='grid gap-4 p-5 md:grid-cols-3'>
|
||||
{[
|
||||
['Updates', '4', '2 clean'],
|
||||
['Needs review', '1', 'conflict risk'],
|
||||
['Agents', '2', 'queued'],
|
||||
].map(([label, value, note]) => (
|
||||
<div
|
||||
key={label}
|
||||
className='border-border bg-background border p-4'
|
||||
>
|
||||
<p className='text-muted-foreground text-xs'>{label}</p>
|
||||
<p className='mt-2 text-2xl font-semibold'>{value}</p>
|
||||
<p className='text-muted-foreground mt-1 text-xs'>{note}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className='space-y-3 px-5 pb-5'>
|
||||
{previewRows.map(({ name, upstream, status, icon: Icon, tone }) => (
|
||||
<div
|
||||
key={name}
|
||||
className='border-border bg-background flex items-center justify-between gap-4 border p-4'
|
||||
>
|
||||
<div className='flex items-center gap-3'>
|
||||
<span className='bg-muted flex size-9 items-center justify-center rounded-md'>
|
||||
<GitBranch className='size-4' />
|
||||
</span>
|
||||
<div>
|
||||
<p className='text-sm font-medium'>{name}</p>
|
||||
<p className='text-muted-foreground text-xs'>{upstream}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className='flex items-center gap-2 text-sm'>
|
||||
<Icon className={`size-4 ${tone}`} />
|
||||
{status}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export { Hero } from './hero';
|
||||
export { Agents, Features, Workflow } from './features';
|
||||
export { CTA } from './cta';
|
||||
@@ -0,0 +1,75 @@
|
||||
const techStack = [
|
||||
{
|
||||
category: 'Frontend',
|
||||
technologies: [
|
||||
{ name: 'Next.js 16', description: 'React framework with App Router' },
|
||||
{ name: 'Expo 54', description: 'React Native framework' },
|
||||
{ name: 'React 19', description: 'Latest React with Server Components' },
|
||||
{
|
||||
name: 'Tailwind CSS v4',
|
||||
description: 'Utility-first CSS framework',
|
||||
},
|
||||
{ name: 'shadcn/ui', description: 'Beautiful component library' },
|
||||
],
|
||||
},
|
||||
{
|
||||
category: 'Backend',
|
||||
technologies: [
|
||||
{ name: 'Convex', description: 'Self-hosted reactive backend' },
|
||||
{
|
||||
name: '@convex-dev/auth',
|
||||
description: 'Multi-provider authentication',
|
||||
},
|
||||
{ name: 'UseSend', description: 'Self-hosted email service' },
|
||||
{ name: 'File Storage', description: 'Built-in file uploads' },
|
||||
],
|
||||
},
|
||||
{
|
||||
category: 'Developer Tools',
|
||||
technologies: [
|
||||
{ name: 'Turborepo', description: 'High-performance build system' },
|
||||
{ name: 'TypeScript', description: 'Type-safe development' },
|
||||
{ name: 'Bun', description: 'Fast package manager and runtime' },
|
||||
{ name: 'ESLint + Prettier', description: 'Code quality tools' },
|
||||
{ name: 'Docker', description: 'Containerized deployment' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const TechStack = () => (
|
||||
<section id='tech-stack' className='border-border/40 bg-muted/30 border-t'>
|
||||
<div className='container mx-auto px-4 py-24'>
|
||||
<div className='mx-auto max-w-6xl'>
|
||||
<div className='mb-16 text-center'>
|
||||
<h2 className='mb-4 text-3xl font-bold tracking-tight sm:text-4xl md:text-5xl'>
|
||||
Modern Tech Stack
|
||||
</h2>
|
||||
<p className='text-muted-foreground mx-auto max-w-2xl text-lg'>
|
||||
Built with the latest and greatest tools for maximum productivity
|
||||
and performance.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-12 md:grid-cols-3'>
|
||||
{techStack.map((stack) => (
|
||||
<div key={stack.category}>
|
||||
<h3 className='mb-6 text-xl font-semibold'>{stack.category}</h3>
|
||||
<ul className='space-y-4'>
|
||||
{stack.technologies.map((tech) => (
|
||||
<li key={tech.name}>
|
||||
<div className='text-foreground font-medium'>
|
||||
{tech.name}
|
||||
</div>
|
||||
<div className='text-muted-foreground text-sm'>
|
||||
{tech.description}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { VariantProps } from 'class-variance-authority';
|
||||
import type { ComponentProps } from 'react';
|
||||
import { useAuthActions } from '@convex-dev/auth/react';
|
||||
import { KeyRound } from 'lucide-react';
|
||||
|
||||
import type { buttonVariants } from '@spoon/ui';
|
||||
import { Button } from '@spoon/ui';
|
||||
|
||||
interface Props {
|
||||
buttonProps?: Omit<ComponentProps<'button'>, 'onClick'> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
};
|
||||
type?: 'signIn' | 'signUp';
|
||||
}
|
||||
|
||||
export const AuthentikSignInButton = ({
|
||||
buttonProps,
|
||||
type = 'signIn',
|
||||
}: Props) => {
|
||||
const { signIn } = useAuthActions();
|
||||
return (
|
||||
<Button
|
||||
size='lg'
|
||||
onClick={() => signIn('authentik')}
|
||||
className='text-lg font-semibold'
|
||||
{...buttonProps}
|
||||
>
|
||||
<div className='my-auto flex flex-row items-center gap-2'>
|
||||
<KeyRound className='size-5' />
|
||||
<p>{type === 'signIn' ? 'Continue' : 'Sign up'} with Authentik</p>
|
||||
</div>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { AuthentikSignInButton } from './gibs-auth';
|
||||
@@ -0,0 +1,218 @@
|
||||
'use client';
|
||||
|
||||
import type { Preloaded } from 'convex/react';
|
||||
import type { ChangeEvent } from 'react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useMutation, usePreloadedQuery, useQuery } from 'convex/react';
|
||||
import { Loader2, Pencil, Upload, XIcon } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
import {
|
||||
Avatar,
|
||||
AvatarImage,
|
||||
BasedAvatar,
|
||||
Button,
|
||||
CardContent,
|
||||
ImageCrop,
|
||||
ImageCropApply,
|
||||
ImageCropContent,
|
||||
Input,
|
||||
} from '@spoon/ui';
|
||||
|
||||
type AvatarUploadProps = {
|
||||
preloadedUser: Preloaded<typeof api.auth.getUser>;
|
||||
};
|
||||
|
||||
const dataUrlToBlob = async (
|
||||
dataUrl: string,
|
||||
): Promise<{ blob: Blob; type: string }> => {
|
||||
const re = /^data:([^;,]+)[;,]/;
|
||||
const m = re.exec(dataUrl);
|
||||
const type = m?.[1] ?? 'image/png';
|
||||
|
||||
const res = await fetch(dataUrl);
|
||||
const blob = await res.blob();
|
||||
return { blob, type };
|
||||
};
|
||||
|
||||
export const AvatarUpload = ({ preloadedUser }: AvatarUploadProps) => {
|
||||
const user = usePreloadedQuery(preloadedUser);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [croppedImage, setCroppedImage] = useState<string | null>(null);
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const generateUploadUrl = useMutation(api.files.generateUploadUrl);
|
||||
const updateUser = useMutation(api.auth.updateUser);
|
||||
|
||||
const currentImageUrl = useQuery(
|
||||
api.files.getImageUrl,
|
||||
user?.image ? { storageId: user.image as Id<'_storage'> } : 'skip',
|
||||
);
|
||||
|
||||
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0] ?? null;
|
||||
if (!file) return;
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast.error('Please select an image file.');
|
||||
if (inputRef.current) inputRef.current.value = '';
|
||||
return;
|
||||
}
|
||||
setSelectedFile(file);
|
||||
setCroppedImage(null);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setSelectedFile(null);
|
||||
setCroppedImage(null);
|
||||
if (inputRef.current) inputRef.current.value = '';
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!croppedImage) {
|
||||
toast.error('Please apply a crop first.');
|
||||
return;
|
||||
}
|
||||
setIsUploading(true);
|
||||
try {
|
||||
const { blob, type } = await dataUrlToBlob(croppedImage);
|
||||
const postUrl = await generateUploadUrl();
|
||||
|
||||
const result = await fetch(postUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': type },
|
||||
body: blob,
|
||||
});
|
||||
if (!result.ok) {
|
||||
const msg = await result.text().catch(() => 'Upload failed.');
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
const uploadResponse = (await result.json()) as {
|
||||
storageId: Id<'_storage'>;
|
||||
};
|
||||
|
||||
await updateUser({ image: uploadResponse.storageId });
|
||||
|
||||
toast.success('Profile picture updated.');
|
||||
handleReset();
|
||||
} catch (error) {
|
||||
console.error('Upload failed:', error);
|
||||
toast.error('Upload failed. Please try again.');
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<CardContent>
|
||||
<div className='flex flex-col items-center gap-4'>
|
||||
{/* Current avatar + trigger (hidden when cropping) */}
|
||||
{!selectedFile && (
|
||||
<div
|
||||
className='group relative cursor-pointer'
|
||||
onClick={() => inputRef.current?.click()}
|
||||
>
|
||||
<BasedAvatar
|
||||
src={currentImageUrl ?? undefined}
|
||||
fullName={user?.name}
|
||||
className='h-42 w-42 text-6xl font-semibold'
|
||||
userIconProps={{ size: 100 }}
|
||||
/>
|
||||
<div className='absolute inset-0 flex items-center justify-center rounded-full bg-black/0 transition-all group-hover:bg-black/50'>
|
||||
<Upload
|
||||
className='text-white opacity-0 transition-opacity group-hover:opacity-100'
|
||||
size={24}
|
||||
/>
|
||||
</div>
|
||||
<div className='absolute inset-1 flex items-end justify-end transition-all'>
|
||||
<Pencil
|
||||
className='text-white opacity-100 transition-opacity group-hover:opacity-0'
|
||||
size={24}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File input (hidden) */}
|
||||
<Input
|
||||
ref={inputRef}
|
||||
id='avatar-upload'
|
||||
type='file'
|
||||
accept='image/*'
|
||||
className='hidden'
|
||||
onChange={handleFileChange}
|
||||
disabled={isUploading}
|
||||
/>
|
||||
|
||||
{/* Crop UI */}
|
||||
{selectedFile && !croppedImage && (
|
||||
<div className='flex flex-col items-center gap-3'>
|
||||
<ImageCrop
|
||||
aspect={1}
|
||||
circularCrop
|
||||
file={selectedFile}
|
||||
maxImageSize={3 * 1024 * 1024} // 3MB guard
|
||||
onCrop={setCroppedImage}
|
||||
>
|
||||
<ImageCropContent className='max-w-sm' />
|
||||
<div className='flex items-center gap-2'>
|
||||
<Button size='icon' variant='outline'>
|
||||
<ImageCropApply className='h-full w-full scale-150' />
|
||||
</Button>
|
||||
<Button onClick={handleReset} size='icon' variant='destructive'>
|
||||
<XIcon className='scale-150' />
|
||||
</Button>
|
||||
</div>
|
||||
</ImageCrop>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cropped preview + actions */}
|
||||
{croppedImage && (
|
||||
<div className='flex flex-col items-center gap-3'>
|
||||
<Avatar className='h-42 w-42'>
|
||||
<AvatarImage alt='Cropped preview' src={croppedImage} />
|
||||
</Avatar>
|
||||
<div className='flex items-center gap-1'>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={isUploading}
|
||||
variant='secondary'
|
||||
className='px-4'
|
||||
>
|
||||
{isUploading ? (
|
||||
<span className='inline-flex items-center gap-2'>
|
||||
<Loader2 className='h-4 w-4 animate-spin' />
|
||||
Saving...
|
||||
</span>
|
||||
) : (
|
||||
'Save Avatar'
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleReset}
|
||||
size='icon'
|
||||
type='button'
|
||||
variant='destructive'
|
||||
>
|
||||
<XIcon className='size-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Uploading indicator */}
|
||||
{isUploading && !croppedImage && (
|
||||
<div className='mt-2 flex items-center text-sm text-gray-500'>
|
||||
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
||||
Uploading...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import { CardDescription, CardHeader, CardTitle } from '@spoon/ui';
|
||||
|
||||
const ProfileHeader = () => {
|
||||
return (
|
||||
<CardHeader>
|
||||
<CardTitle className='text-xl'>Account Settings</CardTitle>
|
||||
<CardDescription>
|
||||
Update your profile information and manage your account preferences
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
);
|
||||
};
|
||||
|
||||
export { ProfileHeader };
|
||||
@@ -0,0 +1,5 @@
|
||||
export { AvatarUpload } from './avatar-upload';
|
||||
export { ProfileHeader } from './header';
|
||||
export { ResetPasswordForm } from './reset-password';
|
||||
export { SignOutForm } from './sign-out';
|
||||
export { UserInfoForm } from './user-info';
|
||||
@@ -0,0 +1,195 @@
|
||||
'use client';
|
||||
|
||||
import type { Preloaded } from 'convex/react';
|
||||
import { useState } from 'react';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useAction, usePreloadedQuery } from 'convex/react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
import {
|
||||
PASSWORD_MAX,
|
||||
PASSWORD_MIN,
|
||||
PASSWORD_REGEX,
|
||||
} from '@spoon/backend/types';
|
||||
import {
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
Separator,
|
||||
SubmitButton,
|
||||
} from '@spoon/ui';
|
||||
|
||||
const formSchema = z
|
||||
.object({
|
||||
currentPassword: z.string().regex(PASSWORD_REGEX, {
|
||||
message: 'Incorrect current password. Does not meet requirements.',
|
||||
}),
|
||||
newPassword: z
|
||||
.string()
|
||||
.min(PASSWORD_MIN, {
|
||||
message: 'New password must be at least 8 characters.',
|
||||
})
|
||||
.max(PASSWORD_MAX, {
|
||||
message: 'New password must be less than 100 characters.',
|
||||
})
|
||||
.regex(/^\S+$/, {
|
||||
message: 'Password must not contain whitespace.',
|
||||
})
|
||||
.regex(/[0-9]/, {
|
||||
message: 'Password must contain at least one digit.',
|
||||
})
|
||||
.regex(/[a-z]/, {
|
||||
message: 'Password must contain at least one lowercase letter.',
|
||||
})
|
||||
.regex(/[A-Z]/, {
|
||||
message: 'Password must contain at least one uppercase letter.',
|
||||
})
|
||||
.regex(/[\p{P}\p{S}]/u, {
|
||||
message: 'Password must contain at least one symbol.',
|
||||
}),
|
||||
confirmPassword: z.string(),
|
||||
})
|
||||
.refine((data) => data.currentPassword !== data.newPassword, {
|
||||
message: 'New password must be different from current password.',
|
||||
path: ['newPassword'],
|
||||
})
|
||||
.refine((data) => data.newPassword === data.confirmPassword, {
|
||||
message: 'Passwords do not match.',
|
||||
path: ['confirmPassword'],
|
||||
});
|
||||
|
||||
interface ResetFormProps {
|
||||
preloadedProvider: Preloaded<typeof api.auth.getUserProvider>;
|
||||
}
|
||||
|
||||
export const ResetPasswordForm = ({ preloadedProvider }: ResetFormProps) => {
|
||||
const userProvider = usePreloadedQuery(preloadedProvider);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const changePassword = useAction(api.auth.updateUserPassword);
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: '',
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = async (values: z.infer<typeof formSchema>) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await changePassword({
|
||||
currentPassword: values.currentPassword,
|
||||
newPassword: values.newPassword,
|
||||
});
|
||||
if (result.success) {
|
||||
form.reset();
|
||||
toast.success('Password updated successfully.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating password:', error);
|
||||
toast.error('Error updating password.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
// Only show password reset for email/password auth users
|
||||
if (userProvider !== 'email') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Separator />
|
||||
<CardHeader>
|
||||
<CardTitle>Change Password</CardTitle>
|
||||
<CardDescription>
|
||||
Update your password to keep your account secure
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
className='space-y-4'
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='currentPassword'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Current Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='password'
|
||||
{...field}
|
||||
placeholder='Enter current password'
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='newPassword'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>New Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='password'
|
||||
{...field}
|
||||
placeholder='Enter new password'
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Must be at least 8 characters with uppercase, lowercase,
|
||||
number, and symbol
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='confirmPassword'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Confirm New Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='password'
|
||||
{...field}
|
||||
placeholder='Confirm new password'
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className='flex justify-end pt-2'>
|
||||
<SubmitButton disabled={loading} pendingText='Updating...'>
|
||||
Update Password
|
||||
</SubmitButton>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuthActions } from '@convex-dev/auth/react';
|
||||
import { LogOut } from 'lucide-react';
|
||||
|
||||
import {
|
||||
Button,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@spoon/ui';
|
||||
|
||||
export const SignOutForm = () => {
|
||||
const { signOut } = useAuthActions();
|
||||
const router = useRouter();
|
||||
const [isSigningOut, setIsSigningOut] = useState(false);
|
||||
|
||||
const handleSignOut = async () => {
|
||||
setIsSigningOut(true);
|
||||
try {
|
||||
await signOut();
|
||||
router.push('/');
|
||||
} catch (error) {
|
||||
console.error('Sign out error:', error);
|
||||
setIsSigningOut(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<CardHeader>
|
||||
<CardTitle>Sign Out</CardTitle>
|
||||
<CardDescription>
|
||||
End your current session and return to the home page
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button
|
||||
variant='destructive'
|
||||
className='w-full'
|
||||
onClick={handleSignOut}
|
||||
disabled={isSigningOut}
|
||||
>
|
||||
<LogOut className='mr-2 h-4 w-4' />
|
||||
{isSigningOut ? 'Signing Out...' : 'Sign Out'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,171 @@
|
||||
'use client';
|
||||
|
||||
import type { Preloaded } from 'convex/react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useMutation, usePreloadedQuery } from 'convex/react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
import {
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
SubmitButton,
|
||||
} from '@spoon/ui';
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(5, {
|
||||
message: 'Full name is required & must be at least 5 characters.',
|
||||
})
|
||||
.max(50, {
|
||||
message: 'Full name must be less than 50 characters.',
|
||||
}),
|
||||
email: z.email({
|
||||
message: 'Please enter a valid email address.',
|
||||
}),
|
||||
});
|
||||
|
||||
interface UserInfoFormProps {
|
||||
preloadedUser: Preloaded<typeof api.auth.getUser>;
|
||||
preloadedProvider: Preloaded<typeof api.auth.getUserProvider>;
|
||||
}
|
||||
|
||||
export const UserInfoForm = ({
|
||||
preloadedUser,
|
||||
preloadedProvider,
|
||||
}: UserInfoFormProps) => {
|
||||
const user = usePreloadedQuery(preloadedUser);
|
||||
const userProvider = usePreloadedQuery(preloadedProvider);
|
||||
const providerMap: Record<string, string> = {
|
||||
unknown: 'Provider',
|
||||
authentik: 'Authentik',
|
||||
};
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const updateUser = useMutation(api.auth.updateUser);
|
||||
|
||||
const initialValues = useMemo<z.infer<typeof formSchema>>(
|
||||
() => ({
|
||||
name: user?.name ?? '',
|
||||
email: user?.email ?? '',
|
||||
}),
|
||||
[user?.name, user?.email],
|
||||
);
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
values: initialValues,
|
||||
});
|
||||
|
||||
const handleSubmit = async (values: z.infer<typeof formSchema>) => {
|
||||
if (!user) {
|
||||
toast.error('User not found.');
|
||||
return;
|
||||
}
|
||||
|
||||
const name = values.name.trim();
|
||||
const email = values.email.trim().toLowerCase();
|
||||
const patch: Partial<{
|
||||
name: string;
|
||||
email: string;
|
||||
}> = {};
|
||||
if (name !== (user.name ?? '')) patch.name = name;
|
||||
if (email !== (user.email ?? '')) patch.email = email;
|
||||
if (Object.keys(patch).length === 0) {
|
||||
toast.info('No changes to save.');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
await updateUser(patch);
|
||||
form.reset(patch);
|
||||
toast.success('Profile updated successfully.');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('Error updating profile.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<CardHeader>
|
||||
<CardTitle>Account Information</CardTitle>
|
||||
<CardDescription>Update your name and email address</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
className='space-y-4'
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='name'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Full Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder='John Doe' />
|
||||
</FormControl>
|
||||
<FormDescription>Your public display name</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='email'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type='email'
|
||||
placeholder='john@example.com'
|
||||
disabled={userProvider !== 'password'}
|
||||
/>
|
||||
</FormControl>
|
||||
{userProvider === 'password' ? (
|
||||
<FormDescription>
|
||||
Your email address for account notifications
|
||||
</FormDescription>
|
||||
) : (
|
||||
<FormDescription>
|
||||
Email is managed through your{' '}
|
||||
{providerMap[userProvider ?? 'unknown']} account
|
||||
</FormDescription>
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className='flex justify-end pt-2'>
|
||||
<SubmitButton disabled={loading} pendingText='Saving...'>
|
||||
Save Changes
|
||||
</SubmitButton>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
import Link from 'next/link';
|
||||
import { SpoonLogo } from '@/components/brand/logo';
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer className='border-border/40 bg-muted/30 border-t'>
|
||||
<div className='container mx-auto px-4 py-12'>
|
||||
<div className='grid gap-8 md:grid-cols-4'>
|
||||
<div className='md:col-span-2'>
|
||||
<SpoonLogo className='mb-4' />
|
||||
<p className='text-muted-foreground max-w-md text-sm'>
|
||||
Spoon is a self-hostable fork maintenance dashboard for teams who
|
||||
want to customize upstream projects without drifting away from
|
||||
security fixes, product updates, and merge history.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className='mb-4 text-sm font-semibold'>Product</h4>
|
||||
<ul className='space-y-2 text-sm'>
|
||||
<li>
|
||||
<Link
|
||||
href='/dashboard'
|
||||
className='text-muted-foreground hover:text-foreground transition-colors'
|
||||
>
|
||||
Dashboard
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href='/spoons'
|
||||
className='text-muted-foreground hover:text-foreground transition-colors'
|
||||
>
|
||||
Spoons
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href='/updates'
|
||||
className='text-muted-foreground hover:text-foreground transition-colors'
|
||||
>
|
||||
Updates
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className='mb-4 text-sm font-semibold'>Workspace</h4>
|
||||
<ul className='space-y-2 text-sm'>
|
||||
<li>
|
||||
<Link
|
||||
href='/agents'
|
||||
className='text-muted-foreground hover:text-foreground transition-colors'
|
||||
>
|
||||
Agents
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href='/profile'
|
||||
className='text-muted-foreground hover:text-foreground transition-colors'
|
||||
>
|
||||
Profile
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href='https://git.gbrown.org/gib/spoon'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-muted-foreground hover:text-foreground transition-colors'
|
||||
>
|
||||
Source
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='border-border/40 text-muted-foreground mt-12 border-t pt-8 text-center text-sm'>
|
||||
<p>
|
||||
Self-hostable fork maintenance for teams that stay close to
|
||||
upstream.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuthActions } from '@convex-dev/auth/react';
|
||||
import { useConvexAuth, useQuery } from 'convex/react';
|
||||
|
||||
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
import {
|
||||
BasedAvatar,
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@spoon/ui';
|
||||
|
||||
export const AvatarDropdown = () => {
|
||||
const router = useRouter();
|
||||
const { isLoading, isAuthenticated } = useConvexAuth();
|
||||
const { signOut } = useAuthActions();
|
||||
const user = useQuery(api.auth.getUser, {});
|
||||
const currentImageUrl = useQuery(
|
||||
api.files.getImageUrl,
|
||||
user?.image ? { storageId: user.image as Id<'_storage'> } : 'skip',
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='bg-muted h-8 w-16 animate-pulse rounded-md' />
|
||||
<div className='bg-muted h-9 w-9 animate-pulse rounded-full' />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<div className='flex items-center gap-2'>
|
||||
<Button size='sm' asChild>
|
||||
<Link href='/sign-in'>Sign In</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<BasedAvatar
|
||||
src={currentImageUrl}
|
||||
fullName={user?.name}
|
||||
className='h-9 w-9'
|
||||
fallbackProps={{ className: 'text-sm font-semibold' }}
|
||||
userIconProps={{ size: 20 }}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
{(user?.name ?? user?.email) && (
|
||||
<>
|
||||
<DropdownMenuLabel className='text-center font-bold'>
|
||||
{user.name?.trim() ?? user.email?.trim()}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href='/profile' className='w-full cursor-pointer'>
|
||||
Edit Profile
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<button
|
||||
onClick={() =>
|
||||
void signOut().then(() => {
|
||||
router.push('/');
|
||||
})
|
||||
}
|
||||
className='w-full cursor-pointer'
|
||||
>
|
||||
Sign Out
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import type { ThemeToggleProps } from '@spoon/ui';
|
||||
import { ThemeToggle } from '@spoon/ui';
|
||||
|
||||
import { AvatarDropdown } from './AvatarDropdown';
|
||||
|
||||
export const Controls = (themeToggleProps?: ThemeToggleProps) => {
|
||||
return (
|
||||
<div className='flex items-center gap-3'>
|
||||
<ThemeToggle
|
||||
size={1.1}
|
||||
buttonProps={{
|
||||
variant: 'ghost',
|
||||
size: 'sm',
|
||||
...themeToggleProps?.buttonProps,
|
||||
}}
|
||||
/>
|
||||
<AvatarDropdown />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
'use client';
|
||||
|
||||
import type { ComponentProps } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { SpoonLogo } from '@/components/brand/logo';
|
||||
import { useConvexAuth } from 'convex/react';
|
||||
import { Bot, GitBranch, LayoutDashboard, ShieldCheck } from 'lucide-react';
|
||||
|
||||
import { Button } from '@spoon/ui';
|
||||
|
||||
import type { NavItem } from './navigation';
|
||||
import { Controls } from './controls';
|
||||
import { DesktopNavigation, MobileNavigation } from './navigation';
|
||||
|
||||
const Header = (headerProps: ComponentProps<'header'>) => {
|
||||
const { isAuthenticated } = useConvexAuth();
|
||||
const navItems: NavItem[] = [
|
||||
{
|
||||
href: '/#workflow',
|
||||
icon: GitBranch,
|
||||
label: 'How it works',
|
||||
},
|
||||
{
|
||||
href: '/#maintenance',
|
||||
icon: ShieldCheck,
|
||||
label: 'Maintenance',
|
||||
},
|
||||
{
|
||||
href: '/#agents',
|
||||
icon: Bot,
|
||||
label: 'Agents',
|
||||
},
|
||||
];
|
||||
|
||||
if (isAuthenticated) {
|
||||
navItems.push({
|
||||
href: '/dashboard',
|
||||
icon: LayoutDashboard,
|
||||
label: 'Dashboard',
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<header
|
||||
className='border-border/40 bg-background/95 supports-backdrop-filter:bg-background/60 sticky top-0 z-50 w-full border-b backdrop-blur'
|
||||
{...headerProps}
|
||||
>
|
||||
<div className='container mx-auto flex h-16 items-center justify-between px-4 md:px-6'>
|
||||
<SpoonLogo />
|
||||
|
||||
<DesktopNavigation items={navItems} />
|
||||
|
||||
<div className='flex items-center gap-2'>
|
||||
{isAuthenticated ? (
|
||||
<Button size='sm' variant='outline' asChild>
|
||||
<Link href='/spoons'>My Spoons</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button size='sm' className='hidden sm:inline-flex' asChild>
|
||||
<Link href='/sign-in'>Get started</Link>
|
||||
</Button>
|
||||
)}
|
||||
<Controls />
|
||||
<MobileNavigation items={navItems} />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
@@ -0,0 +1,97 @@
|
||||
'use client';
|
||||
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { ExternalLink, Menu } from 'lucide-react';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Sheet,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from '@spoon/ui';
|
||||
|
||||
export type NavItem = {
|
||||
href: string;
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
external?: boolean;
|
||||
};
|
||||
|
||||
type NavigationProps = {
|
||||
items: NavItem[];
|
||||
};
|
||||
|
||||
const DesktopNavigation = ({ items }: NavigationProps) => {
|
||||
return (
|
||||
<nav className='hidden items-center gap-4 text-xs font-medium sm:flex md:gap-6 lg:text-base'>
|
||||
{items.map(({ href, icon: Icon, label, external }) => (
|
||||
<Link
|
||||
key={label}
|
||||
href={href}
|
||||
target={external ? '_blank' : undefined}
|
||||
rel={external ? 'noopener noreferrer' : undefined}
|
||||
className='text-foreground/60 hover:text-foreground flex items-center gap-2 transition-colors'
|
||||
>
|
||||
<Icon width={18} height={18} />
|
||||
{label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
const MobileNavigation = ({ items }: NavigationProps) => {
|
||||
return (
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='icon-sm'
|
||||
className='sm:hidden'
|
||||
aria-label='Open navigation menu'
|
||||
>
|
||||
<Menu className='size-4.5' />
|
||||
<span className='sr-only'>Open navigation menu</span>
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side='right' className='w-[min(88vw,22rem)] px-0'>
|
||||
<SheetHeader className='border-border/60 from-background to-muted/40 border-b bg-linear-to-br from-35% px-5 py-5 text-left'>
|
||||
<SheetTitle className='text-left text-lg'>Navigation</SheetTitle>
|
||||
<SheetDescription className='text-left'>
|
||||
Quick access to the links that collapse out of the header.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className='flex flex-col gap-3 px-4 py-5'>
|
||||
{items.map(({ href, icon: Icon, label, external }) => (
|
||||
<SheetClose asChild key={label}>
|
||||
<Link
|
||||
href={href}
|
||||
target={external ? '_blank' : undefined}
|
||||
rel={external ? 'noopener noreferrer' : undefined}
|
||||
className='bg-card hover:bg-muted/70 border-border/60 text-card-foreground flex items-center justify-between rounded-md border px-4 py-3 transition-colors'
|
||||
>
|
||||
<span className='flex items-center gap-3'>
|
||||
<span className='bg-muted text-foreground flex h-9 w-9 items-center justify-center rounded-md'>
|
||||
<Icon className='size-4.5' />
|
||||
</span>
|
||||
<span className='text-sm font-medium'>{label}</span>
|
||||
</span>
|
||||
{external ? (
|
||||
<ExternalLink className='text-muted-foreground size-4' />
|
||||
) : null}
|
||||
</Link>
|
||||
</SheetClose>
|
||||
))}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
};
|
||||
|
||||
export { DesktopNavigation, MobileNavigation };
|
||||
@@ -0,0 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import { env } from '@/env';
|
||||
import { ConvexAuthNextjsProvider } from '@convex-dev/auth/nextjs';
|
||||
import { ConvexReactClient } from 'convex/react';
|
||||
|
||||
const convex = new ConvexReactClient(env.NEXT_PUBLIC_CONVEX_URL);
|
||||
|
||||
export const ConvexClientProvider = ({ children }: { children: ReactNode }) => (
|
||||
<ConvexAuthNextjsProvider client={convex}>
|
||||
{children}
|
||||
</ConvexAuthNextjsProvider>
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
export { ConvexClientProvider } from './ConvexClientProvider';
|
||||
@@ -0,0 +1,245 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useMutation } from 'convex/react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Label,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Textarea,
|
||||
} from '@spoon/ui';
|
||||
|
||||
const options = {
|
||||
provider: ['github', 'gitea', 'gitlab', 'other'],
|
||||
visibility: ['unknown', 'public', 'private', 'internal'],
|
||||
maintenanceMode: ['watch', 'auto_pr', 'paused'],
|
||||
syncCadence: ['daily', 'weekly', 'manual'],
|
||||
productionRefStrategy: ['default_branch', 'latest_release', 'tag_pattern'],
|
||||
} as const;
|
||||
|
||||
type FormState = {
|
||||
name: string;
|
||||
description: string;
|
||||
provider: (typeof options.provider)[number];
|
||||
upstreamOwner: string;
|
||||
upstreamRepo: string;
|
||||
upstreamDefaultBranch: string;
|
||||
upstreamUrl: string;
|
||||
forkOwner: string;
|
||||
forkRepo: string;
|
||||
forkUrl: string;
|
||||
visibility: (typeof options.visibility)[number];
|
||||
maintenanceMode: (typeof options.maintenanceMode)[number];
|
||||
syncCadence: (typeof options.syncCadence)[number];
|
||||
productionRefStrategy: (typeof options.productionRefStrategy)[number];
|
||||
};
|
||||
|
||||
const initialState: FormState = {
|
||||
name: '',
|
||||
description: '',
|
||||
provider: 'github',
|
||||
upstreamOwner: '',
|
||||
upstreamRepo: '',
|
||||
upstreamDefaultBranch: 'main',
|
||||
upstreamUrl: '',
|
||||
forkOwner: '',
|
||||
forkRepo: '',
|
||||
forkUrl: '',
|
||||
visibility: 'unknown',
|
||||
maintenanceMode: 'watch',
|
||||
syncCadence: 'daily',
|
||||
productionRefStrategy: 'default_branch',
|
||||
};
|
||||
|
||||
const TextField = ({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
required,
|
||||
}: {
|
||||
id: keyof FormState;
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
required?: boolean;
|
||||
}) => (
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
<Input
|
||||
id={id}
|
||||
value={value}
|
||||
required={required}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const NewSpoonForm = () => {
|
||||
const router = useRouter();
|
||||
const createManual = useMutation(api.spoons.createManual);
|
||||
const [form, setForm] = useState<FormState>(initialState);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const update = <K extends keyof FormState>(key: K, value: FormState[K]) => {
|
||||
setForm((current) => ({ ...current, [key]: value }));
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await createManual({
|
||||
...form,
|
||||
description: form.description || undefined,
|
||||
forkOwner: form.forkOwner || undefined,
|
||||
forkRepo: form.forkRepo || undefined,
|
||||
forkUrl: form.forkUrl || undefined,
|
||||
});
|
||||
toast.success('Spoon created.');
|
||||
router.push('/spoons');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('Could not create Spoon.');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className='border-border bg-card grid gap-6 border p-5'
|
||||
>
|
||||
<div className='grid gap-4 md:grid-cols-2'>
|
||||
<TextField
|
||||
id='name'
|
||||
label='Spoon name'
|
||||
value={form.name}
|
||||
required
|
||||
onChange={(value) => update('name', value)}
|
||||
/>
|
||||
<Select
|
||||
value={form.provider}
|
||||
onValueChange={(value) =>
|
||||
update('provider', value as FormState['provider'])
|
||||
}
|
||||
>
|
||||
<div className='grid gap-2'>
|
||||
<Label>Provider</Label>
|
||||
<SelectTrigger className='w-full'>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</div>
|
||||
<SelectContent>
|
||||
{options.provider.map((value) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{value}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor='description'>Description</Label>
|
||||
<Textarea
|
||||
id='description'
|
||||
value={form.description}
|
||||
onChange={(event) => update('description', event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className='grid gap-4 md:grid-cols-2'>
|
||||
<TextField
|
||||
id='upstreamOwner'
|
||||
label='Upstream owner'
|
||||
value={form.upstreamOwner}
|
||||
required
|
||||
onChange={(value) => update('upstreamOwner', value)}
|
||||
/>
|
||||
<TextField
|
||||
id='upstreamRepo'
|
||||
label='Upstream repository'
|
||||
value={form.upstreamRepo}
|
||||
required
|
||||
onChange={(value) => update('upstreamRepo', value)}
|
||||
/>
|
||||
<TextField
|
||||
id='upstreamDefaultBranch'
|
||||
label='Upstream default branch'
|
||||
value={form.upstreamDefaultBranch}
|
||||
required
|
||||
onChange={(value) => update('upstreamDefaultBranch', value)}
|
||||
/>
|
||||
<TextField
|
||||
id='upstreamUrl'
|
||||
label='Upstream URL'
|
||||
value={form.upstreamUrl}
|
||||
required
|
||||
onChange={(value) => update('upstreamUrl', value)}
|
||||
/>
|
||||
<TextField
|
||||
id='forkOwner'
|
||||
label='Fork owner'
|
||||
value={form.forkOwner}
|
||||
onChange={(value) => update('forkOwner', value)}
|
||||
/>
|
||||
<TextField
|
||||
id='forkRepo'
|
||||
label='Fork repository'
|
||||
value={form.forkRepo}
|
||||
onChange={(value) => update('forkRepo', value)}
|
||||
/>
|
||||
<TextField
|
||||
id='forkUrl'
|
||||
label='Fork URL'
|
||||
value={form.forkUrl}
|
||||
onChange={(value) => update('forkUrl', value)}
|
||||
/>
|
||||
</div>
|
||||
<div className='grid gap-4 md:grid-cols-4'>
|
||||
{(
|
||||
[
|
||||
'visibility',
|
||||
'maintenanceMode',
|
||||
'syncCadence',
|
||||
'productionRefStrategy',
|
||||
] as const
|
||||
).map((key) => (
|
||||
<Select
|
||||
key={key}
|
||||
value={form[key]}
|
||||
onValueChange={(value) => update(key, value as never)}
|
||||
>
|
||||
<div className='grid gap-2'>
|
||||
<Label>{key.replace(/([A-Z])/g, ' $1')}</Label>
|
||||
<SelectTrigger className='w-full'>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</div>
|
||||
<SelectContent>
|
||||
{options[key].map((value) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{value.replaceAll('_', ' ')}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
))}
|
||||
</div>
|
||||
<div className='flex justify-end'>
|
||||
<Button type='submit' disabled={submitting}>
|
||||
{submitting ? 'Creating...' : 'Create Spoon'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
import { Badge, Card, CardContent, CardHeader, CardTitle } from '@spoon/ui';
|
||||
|
||||
const formatDate = (value?: number) =>
|
||||
value
|
||||
? new Intl.DateTimeFormat('en', { dateStyle: 'medium' }).format(value)
|
||||
: 'Never';
|
||||
|
||||
export const SpoonCard = ({ spoon }: { spoon: Doc<'spoons'> }) => (
|
||||
<Card className='shadow-none'>
|
||||
<CardHeader className='flex-row items-start justify-between gap-4'>
|
||||
<div>
|
||||
<CardTitle className='text-lg'>{spoon.name}</CardTitle>
|
||||
<p className='text-muted-foreground mt-1 text-sm'>
|
||||
{spoon.upstreamOwner}/{spoon.upstreamRepo}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant='outline'>{spoon.status.replaceAll('_', ' ')}</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className='grid gap-3 text-sm md:grid-cols-2'>
|
||||
<div>
|
||||
<p className='text-muted-foreground'>Provider</p>
|
||||
<p className='font-medium'>{spoon.provider}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-muted-foreground'>Cadence</p>
|
||||
<p className='font-medium'>{spoon.syncCadence}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-muted-foreground'>Fork</p>
|
||||
<p className='font-medium'>
|
||||
{spoon.forkOwner && spoon.forkRepo
|
||||
? `${spoon.forkOwner}/${spoon.forkRepo}`
|
||||
: 'Not connected'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-muted-foreground'>Last checked</p>
|
||||
<p className='font-medium'>{formatDate(spoon.lastCheckedAt)}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
Reference in New Issue
Block a user