Update stuff
This commit is contained in:
@@ -1,49 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { GitBranch, LayoutDashboard, RefreshCw, Settings } 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: '/settings/profile', label: 'Settings', icon: Settings },
|
||||
];
|
||||
|
||||
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 className='container mx-auto min-w-0 px-4 py-6 md:px-6'>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
import Image from 'next/image';
|
||||
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',
|
||||
'inline-flex size-9 items-center justify-center overflow-hidden rounded-md',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Utensils className='size-5' />
|
||||
<Image
|
||||
src='/favicon.png'
|
||||
alt=''
|
||||
width={36}
|
||||
height={36}
|
||||
className='size-full object-cover'
|
||||
priority
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
|
||||
|
||||
@@ -1,27 +1,35 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useConvexAuth } from 'convex/react';
|
||||
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>
|
||||
export const CTA = () => {
|
||||
const { isAuthenticated } = useConvexAuth();
|
||||
|
||||
return (
|
||||
<section className='container mx-auto px-4 py-20'>
|
||||
<div className='bg-primary text-primary-foreground rounded-lg px-6 py-10 md:px-10'>
|
||||
<div className='flex flex-col items-start justify-between gap-8 md:flex-row md:items-center'>
|
||||
<div>
|
||||
<h2 className='text-2xl font-semibold tracking-normal md:text-3xl'>
|
||||
Keep the fork. Lose the maintenance dread.
|
||||
</h2>
|
||||
<p className='text-primary-foreground/80 mt-3 max-w-2xl leading-7'>
|
||||
Create your first Spoon, connect GitHub, and make upstream drift
|
||||
something you can see, review, and act on.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant='secondary' size='lg' asChild>
|
||||
<Link href={isAuthenticated ? '/spoons/new' : '/sign-in'}>
|
||||
{isAuthenticated ? 'New Spoon' : 'Start with Spoon'}
|
||||
<ArrowRight className='size-4' />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Button asChild>
|
||||
<Link href='/spoons/new'>
|
||||
New Spoon
|
||||
<ArrowRight className='size-4' />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,100 +1,195 @@
|
||||
import {
|
||||
Bot,
|
||||
GitMerge,
|
||||
Code2,
|
||||
GitBranch,
|
||||
GitCompare,
|
||||
GitPullRequest,
|
||||
History,
|
||||
SearchCheck,
|
||||
ShieldCheck,
|
||||
TriangleAlert,
|
||||
KeyRound,
|
||||
LockKeyhole,
|
||||
RefreshCw,
|
||||
ServerCog,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@spoon/ui';
|
||||
import { Badge } from '@spoon/ui';
|
||||
|
||||
const maintenance = [
|
||||
const workflow = [
|
||||
{
|
||||
title: 'Upstream security fixes',
|
||||
title: 'Connect GitHub',
|
||||
description:
|
||||
'Track the changes that land upstream so important fixes do not disappear into fork drift.',
|
||||
icon: ShieldCheck,
|
||||
'Install the Spoon GitHub App so Spoon can read forks, compare branches, push agent branches, and open draft pull requests.',
|
||||
},
|
||||
{
|
||||
title: 'Conflict detection',
|
||||
title: 'Create a Spoon',
|
||||
description:
|
||||
'Make update risk visible before a merge request reaches the fork you actually maintain.',
|
||||
icon: TriangleAlert,
|
||||
'Register a managed fork with its upstream project, fork repository, default branches, sync cadence, and additional remote URLs.',
|
||||
},
|
||||
{
|
||||
title: 'AI-reviewed changes',
|
||||
title: 'Watch drift',
|
||||
description:
|
||||
'Prepare for agent-assisted analysis that explains whether upstream changes affect your custom work.',
|
||||
icon: SearchCheck,
|
||||
'Refresh GitHub state to see upstream commits waiting, fork-only commits, open pull requests, sync health, and merge history.',
|
||||
},
|
||||
{
|
||||
title: 'Merge request history',
|
||||
title: 'Review safely',
|
||||
description:
|
||||
'Keep a durable timeline of upstream checks, review outcomes, and merge request decisions.',
|
||||
'Use AI compatibility reviews to summarize upstream changes, flag important files, and decide when a manual review is needed.',
|
||||
},
|
||||
{
|
||||
title: 'Ship through PRs',
|
||||
description:
|
||||
'Queue agent jobs that work on fresh branches and open draft pull requests, while GitHub remains the source of truth.',
|
||||
},
|
||||
];
|
||||
|
||||
const features = [
|
||||
{
|
||||
title: 'Project dashboards',
|
||||
description:
|
||||
'Each Spoon gets a focused dashboard with upstream drift, fork-only changes, pull requests, AI reviews, activity, settings, clone URLs, and extra remotes.',
|
||||
icon: GitCompare,
|
||||
},
|
||||
{
|
||||
title: 'Upstream maintenance queue',
|
||||
description:
|
||||
'The global dashboard makes it obvious which forks are up to date, behind, ahead, diverged, or waiting for review.',
|
||||
icon: RefreshCw,
|
||||
},
|
||||
{
|
||||
title: 'Pull request visibility',
|
||||
description:
|
||||
'Spoon caches fork pull requests and relevant upstream pull requests so maintenance decisions are tied to real GitHub activity.',
|
||||
icon: GitPullRequest,
|
||||
},
|
||||
{
|
||||
title: 'AI compatibility review',
|
||||
description:
|
||||
'OpenAI reviews upstream changes against fork-only commits and returns structured risk, summary, recommended action, and conflict signals.',
|
||||
icon: Sparkles,
|
||||
},
|
||||
{
|
||||
title: 'Per-user AI settings',
|
||||
description:
|
||||
'Users bring their own OpenAI API key, choose a review model, and set reasoning effort. Keys are encrypted before storage.',
|
||||
icon: KeyRound,
|
||||
},
|
||||
{
|
||||
title: 'Agent job foundation',
|
||||
description:
|
||||
'Spoon can queue coding-agent work per project with selected secrets, job settings, event logs, artifacts, and draft PR targets.',
|
||||
icon: Bot,
|
||||
},
|
||||
];
|
||||
|
||||
const builtFor = [
|
||||
{
|
||||
title: 'Self-hosted by design',
|
||||
description:
|
||||
'Run the app, Convex backend, Postgres, and optional agent worker on your own server so code automation stays under your control.',
|
||||
icon: ServerCog,
|
||||
},
|
||||
{
|
||||
title: 'Secrets stay deliberate',
|
||||
description:
|
||||
'Project secrets are per Spoon and selected per job. The agent runtime never receives every secret by default.',
|
||||
icon: LockKeyhole,
|
||||
},
|
||||
{
|
||||
title: 'Outside work is expected',
|
||||
description:
|
||||
'Spoon assumes you may push to GitHub directly, edit locally, or use another CI system. Refresh always starts from current GitHub state.',
|
||||
icon: Code2,
|
||||
},
|
||||
{
|
||||
title: 'History stays inspectable',
|
||||
description:
|
||||
'Sync runs, AI reviews, job logs, PR URLs, errors, and artifacts are stored so maintenance work is reviewable after the fact.',
|
||||
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.
|
||||
export const Workflow = () => (
|
||||
<section id='workflow' className='border-border/60 bg-muted/30 border-y'>
|
||||
<div className='container mx-auto px-4 py-20'>
|
||||
<div className='mb-12 max-w-3xl'>
|
||||
<Badge variant='outline' className='mb-4'>
|
||||
Workflow
|
||||
</Badge>
|
||||
<h2 className='text-3xl font-semibold tracking-normal md:text-4xl'>
|
||||
Forking should not mean drifting alone.
|
||||
</h2>
|
||||
<p className='text-muted-foreground mt-4 text-lg leading-8'>
|
||||
Spoon treats a fork as an ongoing relationship with upstream. The
|
||||
product keeps the original project, your custom work, and future
|
||||
automation visible in one place.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-8 lg:grid-cols-[0.8fr_1.2fr]'>
|
||||
<div className='border-border bg-background rounded-lg border p-6'>
|
||||
<GitBranch className='text-primary size-6' />
|
||||
<h3 className='mt-5 text-xl font-semibold'>
|
||||
A Spoon is a managed fork
|
||||
</h3>
|
||||
<p className='text-muted-foreground mt-3 leading-7'>
|
||||
It knows where upstream lives, where your fork lives, which branch
|
||||
matters, what extra remotes you care about, and what rules should
|
||||
govern updates. That gives maintenance a durable home instead of a
|
||||
pile of one-off Git commands.
|
||||
</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'>
|
||||
|
||||
<ol className='grid gap-3'>
|
||||
{workflow.map((step, index) => (
|
||||
<li
|
||||
key={step.title}
|
||||
className='border-border bg-background grid gap-4 rounded-lg border p-5 sm:grid-cols-[4rem_1fr]'
|
||||
>
|
||||
<span 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>
|
||||
</span>
|
||||
<div>
|
||||
<h3 className='font-semibold'>{step.title}</h3>
|
||||
<p className='text-muted-foreground mt-1 text-sm leading-6'>
|
||||
{step.description}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</div>
|
||||
</ol>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
</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.
|
||||
<section id='features' className='container mx-auto px-4 py-24'>
|
||||
<div className='mb-12 flex flex-col justify-between gap-6 lg:flex-row lg:items-end'>
|
||||
<div className='max-w-3xl'>
|
||||
<Badge variant='outline' className='mb-4'>
|
||||
Product surface
|
||||
</Badge>
|
||||
<h2 className='text-3xl font-semibold tracking-normal md:text-4xl'>
|
||||
Everything important about a fork, without opening six tabs.
|
||||
</h2>
|
||||
</div>
|
||||
<p className='text-muted-foreground max-w-xl leading-7'>
|
||||
Spoon is not trying to replace GitHub. It is the layer that explains how
|
||||
your fork relates to upstream and what should happen next.
|
||||
</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 className='grid gap-px overflow-hidden rounded-lg border md:grid-cols-2 lg:grid-cols-3'>
|
||||
{features.map(({ title, description, icon: Icon }) => (
|
||||
<div key={title} className='bg-card p-6'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<Icon className='text-primary size-5 shrink-0' />
|
||||
<h3 className='font-semibold'>{title}</h3>
|
||||
</div>
|
||||
<p className='text-muted-foreground mt-3 text-sm leading-6'>
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
@@ -102,41 +197,92 @@ export const Features = () => (
|
||||
|
||||
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 className='container mx-auto grid gap-10 px-4 py-24 lg:grid-cols-[0.95fr_1.05fr]'>
|
||||
<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
|
||||
<Badge variant='outline' className='mb-4'>
|
||||
Agent work
|
||||
</Badge>
|
||||
<h2 className='text-3xl font-semibold tracking-normal md:text-4xl'>
|
||||
The agent belongs inside the fork dashboard.
|
||||
</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 className='text-muted-foreground mt-4 text-lg leading-8'>
|
||||
The goal is simple: ask for a change, let a worker clone the current
|
||||
fork, expose only the secrets you selected, run checks, push a branch,
|
||||
and open a draft pull request. The first pieces are already modeled:
|
||||
encrypted Spoon secrets, agent settings, queued jobs, logs, and
|
||||
artifacts.
|
||||
</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 className='border-border bg-background rounded-lg border'>
|
||||
<div className='border-border border-b p-5'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<span className='bg-primary/10 text-primary flex size-9 items-center justify-center rounded-md'>
|
||||
<Bot className='size-4' />
|
||||
</span>
|
||||
<div>
|
||||
<p className='font-medium'>Draft PR agent flow</p>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
Built for review, not automatic merge.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</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 className='divide-border divide-y'>
|
||||
{[
|
||||
['Clone', 'Start from the current GitHub fork state.'],
|
||||
['Branch', 'Create a short-lived agent branch.'],
|
||||
['Edit', 'Apply focused changes with selected project context.'],
|
||||
[
|
||||
'Check',
|
||||
'Run configured install, lint, typecheck, or test steps.',
|
||||
],
|
||||
['Review', 'Open a draft pull request with logs and artifacts.'],
|
||||
].map(([phase, detail]) => (
|
||||
<div key={phase} className='grid gap-3 p-5 sm:grid-cols-[8rem_1fr]'>
|
||||
<p className='text-sm font-semibold'>{phase}</p>
|
||||
<p className='text-muted-foreground text-sm leading-6'>
|
||||
{detail}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
export const Security = () => (
|
||||
<section id='security' className='container mx-auto px-4 py-24'>
|
||||
<div className='grid gap-10 lg:grid-cols-[0.8fr_1.2fr]'>
|
||||
<div>
|
||||
<Badge variant='outline' className='mb-4'>
|
||||
Ownership
|
||||
</Badge>
|
||||
<h2 className='text-3xl font-semibold tracking-normal md:text-4xl'>
|
||||
Useful because it respects how forks are really maintained.
|
||||
</h2>
|
||||
<p className='text-muted-foreground mt-4 text-lg leading-8'>
|
||||
A fork can have local experiments, CI changes, private deployment
|
||||
settings, and emergency upstream fixes all happening at once. Spoon
|
||||
keeps those threads visible without pretending every change must come
|
||||
through the app.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-4 sm:grid-cols-2'>
|
||||
{builtFor.map(({ title, description, icon: Icon }) => (
|
||||
<div key={title} className='border-border rounded-lg border p-5'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<Icon className='text-primary size-5 shrink-0' />
|
||||
<h3 className='font-semibold'>{title}</h3>
|
||||
</div>
|
||||
<p className='text-muted-foreground mt-2 text-sm leading-6'>
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -6,8 +6,10 @@ import {
|
||||
ArrowRight,
|
||||
Bot,
|
||||
CheckCircle2,
|
||||
CircleDot,
|
||||
GitBranch,
|
||||
GitPullRequest,
|
||||
KeyRound,
|
||||
ShieldCheck,
|
||||
} from 'lucide-react';
|
||||
|
||||
@@ -15,23 +17,23 @@ import { Badge, Button } from '@spoon/ui';
|
||||
|
||||
const previewRows = [
|
||||
{
|
||||
name: 'editor-spoon',
|
||||
upstream: 'upstream/main',
|
||||
status: 'Clean update',
|
||||
name: 'gibsend',
|
||||
upstream: 'usesend/usesend',
|
||||
status: '3 upstream commits',
|
||||
icon: CheckCircle2,
|
||||
tone: 'text-emerald-600',
|
||||
},
|
||||
{
|
||||
name: 'billing-fork',
|
||||
upstream: 'release/2026.06',
|
||||
status: 'AI review queued',
|
||||
name: 'internal-docs',
|
||||
upstream: 'platform/docs',
|
||||
status: 'AI review ready',
|
||||
icon: Bot,
|
||||
tone: 'text-teal-600',
|
||||
},
|
||||
{
|
||||
name: 'docs-platform',
|
||||
upstream: 'main',
|
||||
status: 'Needs review',
|
||||
name: 'ops-console',
|
||||
upstream: 'console/main',
|
||||
status: 'fork-only changes',
|
||||
icon: GitPullRequest,
|
||||
tone: 'text-amber-600',
|
||||
},
|
||||
@@ -41,19 +43,21 @@ 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='grid items-center gap-12 lg:grid-cols-[0.9fr_1.1fr]'>
|
||||
<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
|
||||
Self-hostable fork maintenance cockpit
|
||||
</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.
|
||||
Make your forks <em className='text-primary'>intimately</em> 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.
|
||||
Spoon gives every important fork a living maintenance dashboard.
|
||||
Track upstream drift, preserve your custom commits, review pull
|
||||
requests, and queue AI-assisted work without losing sight of the
|
||||
project you forked from.
|
||||
</p>
|
||||
<div className='mt-8 flex flex-col gap-3 sm:flex-row'>
|
||||
<Button size='lg' asChild>
|
||||
@@ -66,29 +70,41 @@ export const Hero = () => {
|
||||
<Link href='#workflow'>See how it works</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<div className='text-muted-foreground mt-8 grid max-w-xl gap-3 text-sm sm:grid-cols-3'>
|
||||
{[
|
||||
'GitHub App backed',
|
||||
'OpenAI key per user',
|
||||
'Draft PR workflow',
|
||||
].map((item) => (
|
||||
<span key={item} className='flex items-center gap-2'>
|
||||
<CircleDot className='text-primary size-3.5' />
|
||||
{item}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='border-border bg-card border shadow-sm'>
|
||||
<div className='border-border bg-card overflow-hidden rounded-lg 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-sm font-medium'>Fork health</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Upstream status across managed forks
|
||||
Current state across managed Spoons
|
||||
</p>
|
||||
</div>
|
||||
<Badge className='bg-primary/10 text-primary hover:bg-primary/10'>
|
||||
3 active Spoons
|
||||
Live GitHub sync
|
||||
</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'],
|
||||
['Behind', '3', 'upstream commits'],
|
||||
['Fork-only', '12', 'custom changes'],
|
||||
['AI risk', 'Low', 'reviewed'],
|
||||
].map(([label, value, note]) => (
|
||||
<div
|
||||
key={label}
|
||||
className='border-border bg-background border p-4'
|
||||
className='border-border bg-background rounded-md border p-4'
|
||||
>
|
||||
<p className='text-muted-foreground text-xs'>{label}</p>
|
||||
<p className='mt-2 text-2xl font-semibold'>{value}</p>
|
||||
@@ -100,7 +116,7 @@ export const Hero = () => {
|
||||
{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'
|
||||
className='border-border bg-background flex items-center justify-between gap-4 rounded-md border p-4'
|
||||
>
|
||||
<div className='flex items-center gap-3'>
|
||||
<span className='bg-muted flex size-9 items-center justify-center rounded-md'>
|
||||
@@ -118,6 +134,20 @@ export const Hero = () => {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className='border-border bg-muted/30 grid gap-3 border-t p-5 text-sm sm:grid-cols-2'>
|
||||
<div className='flex items-start gap-3'>
|
||||
<KeyRound className='text-primary mt-0.5 size-4' />
|
||||
<p className='text-muted-foreground'>
|
||||
User-owned OpenAI keys stay encrypted and selectable.
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex items-start gap-3'>
|
||||
<GitPullRequest className='text-primary mt-0.5 size-4' />
|
||||
<p className='text-muted-foreground'>
|
||||
Agent jobs are shaped around draft pull requests.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export { Hero } from './hero';
|
||||
export { Agents, Features, Workflow } from './features';
|
||||
export { Agents, Features, Security, Workflow } from './features';
|
||||
export { CTA } from './cta';
|
||||
|
||||
@@ -4,7 +4,14 @@ 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 {
|
||||
GitBranch,
|
||||
LayoutDashboard,
|
||||
RefreshCw,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Button } from '@spoon/ui';
|
||||
|
||||
@@ -14,31 +21,46 @@ 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',
|
||||
});
|
||||
}
|
||||
const navItems: NavItem[] = isAuthenticated
|
||||
? [
|
||||
{
|
||||
href: '/dashboard',
|
||||
icon: LayoutDashboard,
|
||||
label: 'Dashboard',
|
||||
},
|
||||
{
|
||||
href: '/spoons',
|
||||
icon: GitBranch,
|
||||
label: 'My Spoons',
|
||||
},
|
||||
{
|
||||
href: '/updates',
|
||||
icon: RefreshCw,
|
||||
label: 'Updates',
|
||||
},
|
||||
{
|
||||
href: '/settings/profile',
|
||||
icon: Settings,
|
||||
label: 'Settings',
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
href: '/#workflow',
|
||||
icon: GitBranch,
|
||||
label: 'Workflow',
|
||||
},
|
||||
{
|
||||
href: '/#features',
|
||||
icon: Sparkles,
|
||||
label: 'Features',
|
||||
},
|
||||
{
|
||||
href: '/#security',
|
||||
icon: ShieldCheck,
|
||||
label: 'Security',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<header
|
||||
@@ -53,7 +75,7 @@ const Header = (headerProps: ComponentProps<'header'>) => {
|
||||
<div className='flex items-center gap-2'>
|
||||
{isAuthenticated ? (
|
||||
<Button size='sm' variant='outline' asChild>
|
||||
<Link href='/spoons'>My Spoons</Link>
|
||||
<Link href='/spoons/new'>New Spoon</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button size='sm' className='hidden sm:inline-flex' asChild>
|
||||
|
||||
@@ -24,6 +24,14 @@ import {
|
||||
} from '@spoon/ui';
|
||||
|
||||
const efforts = ['minimal', 'low', 'medium', 'high', 'xhigh'] as const;
|
||||
const modelOptions = [
|
||||
{ value: 'gpt-5.1-codex', label: 'GPT-5.1 Codex' },
|
||||
{ value: 'gpt-5.5', label: 'GPT-5.5' },
|
||||
{ value: 'gpt-5.5-pro', label: 'GPT-5.5 Pro' },
|
||||
{ value: 'gpt-5.4', label: 'GPT-5.4' },
|
||||
{ value: 'gpt-5.4-mini', label: 'GPT-5.4 Mini' },
|
||||
] as const;
|
||||
type AgentModel = (typeof modelOptions)[number]['value'];
|
||||
|
||||
type AgentSettings = {
|
||||
enabled: boolean;
|
||||
@@ -36,6 +44,11 @@ type AgentSettings = {
|
||||
reasoningEffort: 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
|
||||
};
|
||||
|
||||
const toAgentModel = (value?: string): AgentModel =>
|
||||
modelOptions.some((option) => option.value === value)
|
||||
? (value as AgentModel)
|
||||
: 'gpt-5.1-codex';
|
||||
|
||||
export const SpoonAgentSettingsForm = ({
|
||||
spoon,
|
||||
settings,
|
||||
@@ -60,8 +73,8 @@ export const SpoonAgentSettingsForm = ({
|
||||
settings?.checkCommand ?? '',
|
||||
);
|
||||
const [testCommand, setTestCommand] = useState(settings?.testCommand ?? '');
|
||||
const [agentModel, setAgentModel] = useState(
|
||||
settings?.agentModel ?? 'gpt-5.1-codex',
|
||||
const [agentModel, setAgentModel] = useState<AgentModel>(
|
||||
toAgentModel(settings?.agentModel),
|
||||
);
|
||||
const [reasoningEffort, setReasoningEffort] = useState<
|
||||
'minimal' | 'low' | 'medium' | 'high' | 'xhigh'
|
||||
@@ -127,11 +140,21 @@ export const SpoonAgentSettingsForm = ({
|
||||
</div>
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor='agentModel'>Model</Label>
|
||||
<Input
|
||||
id='agentModel'
|
||||
<Select
|
||||
value={agentModel}
|
||||
onChange={(event) => setAgentModel(event.target.value)}
|
||||
/>
|
||||
onValueChange={(value) => setAgentModel(value as AgentModel)}
|
||||
>
|
||||
<SelectTrigger id='agentModel'>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{modelOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className='grid gap-2'>
|
||||
<Label>Reasoning effort</Label>
|
||||
@@ -160,27 +183,37 @@ export const SpoonAgentSettingsForm = ({
|
||||
<Input
|
||||
id='installCommand'
|
||||
value={installCommand}
|
||||
placeholder='bun install'
|
||||
placeholder='Auto-detect from lockfile'
|
||||
onChange={(event) => setInstallCommand(event.target.value)}
|
||||
/>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Leave blank to inspect the repository and choose bun, pnpm, yarn,
|
||||
or npm.
|
||||
</p>
|
||||
</div>
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor='checkCommand'>Check command</Label>
|
||||
<Input
|
||||
id='checkCommand'
|
||||
value={checkCommand}
|
||||
placeholder='bun typecheck'
|
||||
placeholder='Auto-detect typecheck or lint'
|
||||
onChange={(event) => setCheckCommand(event.target.value)}
|
||||
/>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Leave blank to read package.json scripts after cloning.
|
||||
</p>
|
||||
</div>
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor='testCommand'>Test command</Label>
|
||||
<Input
|
||||
id='testCommand'
|
||||
value={testCommand}
|
||||
placeholder='bun test'
|
||||
placeholder='Auto-detect test script'
|
||||
onChange={(event) => setTestCommand(event.target.value)}
|
||||
/>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Leave blank to run the detected test script when one exists.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button type='button' onClick={save}>
|
||||
|
||||
@@ -10,49 +10,49 @@ const formatDate = (value?: number) =>
|
||||
: 'Never';
|
||||
|
||||
export const SpoonCard = ({ spoon }: { spoon: Doc<'spoons'> }) => (
|
||||
<Card className='hover:border-primary/50 shadow-none transition-colors'>
|
||||
<CardHeader className='flex-row items-start justify-between gap-4'>
|
||||
<div>
|
||||
<CardTitle className='text-lg'>
|
||||
<Link href={`/spoons/${spoon._id}`} className='hover:underline'>
|
||||
<Link href={`/spoons/${spoon._id}`} className='group/spoon-card block'>
|
||||
<Card className='group-hover/spoon-card:border-primary/50 group-hover/spoon-card:bg-muted/20 shadow-none transition-colors'>
|
||||
<CardHeader className='flex-row items-start justify-between gap-4'>
|
||||
<div>
|
||||
<CardTitle className='group-hover/spoon-card:text-primary text-lg transition-colors'>
|
||||
{spoon.name}
|
||||
</Link>
|
||||
</CardTitle>
|
||||
<p className='text-muted-foreground mt-1 text-sm'>
|
||||
{spoon.upstreamOwner}/{spoon.upstreamRepo}
|
||||
</p>
|
||||
</div>
|
||||
<SpoonStatusBadge status={spoon.syncStatus ?? spoon.status} />
|
||||
</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>
|
||||
<div>
|
||||
<p className='text-muted-foreground'>Upstream waiting</p>
|
||||
<p className='font-medium'>{spoon.upstreamAheadBy ?? 0}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-muted-foreground'>Fork-only commits</p>
|
||||
<p className='font-medium'>{spoon.forkAheadBy ?? 0}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CardTitle>
|
||||
<p className='text-muted-foreground mt-1 text-sm'>
|
||||
{spoon.upstreamOwner}/{spoon.upstreamRepo}
|
||||
</p>
|
||||
</div>
|
||||
<SpoonStatusBadge status={spoon.syncStatus ?? spoon.status} />
|
||||
</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>
|
||||
<div>
|
||||
<p className='text-muted-foreground'>Upstream waiting</p>
|
||||
<p className='font-medium'>{spoon.upstreamAheadBy ?? 0}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-muted-foreground'>Fork-only commits</p>
|
||||
<p className='font-medium'>{spoon.forkAheadBy ?? 0}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
|
||||
@@ -13,6 +13,13 @@ import {
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
Input,
|
||||
Label,
|
||||
} from '@spoon/ui';
|
||||
@@ -102,6 +109,7 @@ export const SpoonClonePanel = ({ spoon }: { spoon: Doc<'spoons'> }) => {
|
||||
const [remoteName, setRemoteName] = useState('');
|
||||
const [url, setUrl] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [remoteDialogOpen, setRemoteDialogOpen] = useState(false);
|
||||
const cloneUrl = spoon.forkUrl;
|
||||
|
||||
const addRemote = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
@@ -117,6 +125,7 @@ export const SpoonClonePanel = ({ spoon }: { spoon: Doc<'spoons'> }) => {
|
||||
setLabel('');
|
||||
setRemoteName('');
|
||||
setUrl('');
|
||||
setRemoteDialogOpen(false);
|
||||
toast.success('Remote added.');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
@@ -128,8 +137,64 @@ export const SpoonClonePanel = ({ spoon }: { spoon: Doc<'spoons'> }) => {
|
||||
|
||||
return (
|
||||
<Card className='shadow-none'>
|
||||
<CardHeader className='pb-3'>
|
||||
<CardHeader className='flex-row items-center justify-between gap-4 pb-3'>
|
||||
<CardTitle className='text-base'>Clone your fork</CardTitle>
|
||||
<Dialog open={remoteDialogOpen} onOpenChange={setRemoteDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button type='button' variant='outline' size='sm'>
|
||||
<Plus className='size-4' />
|
||||
Add remote
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className='sm:max-w-lg'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add git remote</DialogTitle>
|
||||
<DialogDescription>
|
||||
Store another repository URL to copy from this Spoon. GitHub
|
||||
remains the source of truth for upstream maintenance.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={addRemote} className='space-y-4'>
|
||||
<div className='grid gap-3 md:grid-cols-[1fr_0.7fr]'>
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor='remote-label'>Label</Label>
|
||||
<Input
|
||||
id='remote-label'
|
||||
value={label}
|
||||
placeholder='Gitea mirror'
|
||||
required
|
||||
onChange={(event) => setLabel(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor='remote-name'>Git remote name</Label>
|
||||
<Input
|
||||
id='remote-name'
|
||||
value={remoteName}
|
||||
placeholder='gitea'
|
||||
onChange={(event) => setRemoteName(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor='remote-url'>Repository URL</Label>
|
||||
<Input
|
||||
id='remote-url'
|
||||
value={url}
|
||||
placeholder='https://git.example.com/you/project.git'
|
||||
required
|
||||
onChange={(event) => setUrl(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter className='mt-2'>
|
||||
<Button type='submit' disabled={submitting}>
|
||||
<Plus className='size-4' />
|
||||
{submitting ? 'Adding...' : 'Add remote'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-3'>
|
||||
{cloneUrl ? (
|
||||
@@ -165,46 +230,6 @@ export const SpoonClonePanel = ({ spoon }: { spoon: Doc<'spoons'> }) => {
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<form
|
||||
onSubmit={addRemote}
|
||||
className='border-border space-y-3 border-t pt-4'
|
||||
>
|
||||
<div className='grid gap-3 md:grid-cols-[1fr_0.7fr]'>
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor='remote-label'>Label</Label>
|
||||
<Input
|
||||
id='remote-label'
|
||||
value={label}
|
||||
placeholder='Gitea mirror'
|
||||
required
|
||||
onChange={(event) => setLabel(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor='remote-name'>Git remote name</Label>
|
||||
<Input
|
||||
id='remote-name'
|
||||
value={remoteName}
|
||||
placeholder='gitea'
|
||||
onChange={(event) => setRemoteName(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor='remote-url'>Repository URL</Label>
|
||||
<Input
|
||||
id='remote-url'
|
||||
value={url}
|
||||
placeholder='https://git.example.com/you/project.git'
|
||||
required
|
||||
onChange={(event) => setUrl(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button type='submit' variant='outline' disabled={submitting}>
|
||||
<Plus className='size-4' />
|
||||
{submitting ? 'Adding...' : 'Add remote'}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -95,6 +95,11 @@ export const SpoonDetailHeader = ({
|
||||
<span>Fork metadata missing</span>
|
||||
)}
|
||||
</div>
|
||||
{spoon.description ? (
|
||||
<p className='text-muted-foreground max-w-3xl text-sm leading-6'>
|
||||
{spoon.description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className='flex shrink-0 flex-wrap gap-2'>
|
||||
<Button
|
||||
|
||||
@@ -13,9 +13,21 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Label,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Switch,
|
||||
} from '@spoon/ui';
|
||||
|
||||
type MaintenanceMode = 'watch' | 'auto_pr' | 'paused';
|
||||
type SyncCadence = 'daily' | 'weekly' | 'manual';
|
||||
type ProductionRefStrategy =
|
||||
| 'default_branch'
|
||||
| 'latest_release'
|
||||
| 'tag_pattern';
|
||||
|
||||
export const SpoonSettingsForm = ({
|
||||
spoon,
|
||||
settings,
|
||||
@@ -24,6 +36,15 @@ export const SpoonSettingsForm = ({
|
||||
settings?: Doc<'spoonSettings'> | null;
|
||||
}) => {
|
||||
const update = useMutation(api.spoonSettings.update);
|
||||
const updateSpoonSettings = useMutation(api.spoons.updateSettings);
|
||||
const [syncCadence, setSyncCadence] = useState<SyncCadence>(
|
||||
spoon.syncCadence,
|
||||
);
|
||||
const [maintenanceMode, setMaintenanceMode] = useState<MaintenanceMode>(
|
||||
spoon.maintenanceMode,
|
||||
);
|
||||
const [productionRefStrategy, setProductionRefStrategy] =
|
||||
useState<ProductionRefStrategy>(spoon.productionRefStrategy);
|
||||
const [autoRefreshEnabled, setAutoRefreshEnabled] = useState(
|
||||
settings?.autoRefreshEnabled ?? true,
|
||||
);
|
||||
@@ -42,14 +63,22 @@ export const SpoonSettingsForm = ({
|
||||
|
||||
const save = async () => {
|
||||
try {
|
||||
await update({
|
||||
spoonId: spoon._id,
|
||||
autoRefreshEnabled,
|
||||
autoReviewEnabled,
|
||||
autoSyncEnabled,
|
||||
requireAiLowRiskForSync,
|
||||
requireCleanCompareForSync,
|
||||
});
|
||||
await Promise.all([
|
||||
updateSpoonSettings({
|
||||
spoonId: spoon._id,
|
||||
syncCadence,
|
||||
maintenanceMode,
|
||||
productionRefStrategy,
|
||||
}),
|
||||
update({
|
||||
spoonId: spoon._id,
|
||||
autoRefreshEnabled,
|
||||
autoReviewEnabled,
|
||||
autoSyncEnabled,
|
||||
requireAiLowRiskForSync,
|
||||
requireCleanCompareForSync,
|
||||
}),
|
||||
]);
|
||||
toast.success('Spoon settings saved.');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
@@ -92,21 +121,57 @@ export const SpoonSettingsForm = ({
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
<div className='grid gap-3 md:grid-cols-2'>
|
||||
<div>
|
||||
<p className='text-muted-foreground text-xs'>Sync cadence</p>
|
||||
<p className='font-medium'>{spoon.syncCadence}</p>
|
||||
<div className='grid gap-2'>
|
||||
<Label>Sync cadence</Label>
|
||||
<Select
|
||||
value={syncCadence}
|
||||
onValueChange={(value) => setSyncCadence(value as SyncCadence)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='daily'>Daily</SelectItem>
|
||||
<SelectItem value='weekly'>Weekly</SelectItem>
|
||||
<SelectItem value='manual'>Manual</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-muted-foreground text-xs'>Maintenance mode</p>
|
||||
<p className='font-medium'>
|
||||
{spoon.maintenanceMode.replaceAll('_', ' ')}
|
||||
</p>
|
||||
<div className='grid gap-2'>
|
||||
<Label>Maintenance mode</Label>
|
||||
<Select
|
||||
value={maintenanceMode}
|
||||
onValueChange={(value) =>
|
||||
setMaintenanceMode(value as MaintenanceMode)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='watch'>Watch only</SelectItem>
|
||||
<SelectItem value='auto_pr'>Prepare merge requests</SelectItem>
|
||||
<SelectItem value='paused'>Paused</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-muted-foreground text-xs'>Production ref</p>
|
||||
<p className='font-medium'>
|
||||
{spoon.productionRefStrategy.replaceAll('_', ' ')}
|
||||
</p>
|
||||
<div className='grid gap-2'>
|
||||
<Label>Production ref</Label>
|
||||
<Select
|
||||
value={productionRefStrategy}
|
||||
onValueChange={(value) =>
|
||||
setProductionRefStrategy(value as ProductionRefStrategy)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='default_branch'>Default branch</SelectItem>
|
||||
<SelectItem value='latest_release'>Latest release</SelectItem>
|
||||
<SelectItem value='tag_pattern'>Tag pattern</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className='space-y-3'>
|
||||
|
||||
Reference in New Issue
Block a user