Files
spoon/apps/next/src/components/spoons/spoon-clone-panel.tsx
T

245 lines
7.5 KiB
TypeScript

'use client';
import { useState } from 'react';
import { useMutation, useQuery } from 'convex/react';
import { Check, Copy, ExternalLink, Plus, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
import { api } from '@spoon/backend/convex/_generated/api.js';
import {
Button,
Card,
CardContent,
CardHeader,
CardTitle,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
Input,
Label,
} from '@spoon/ui';
const RemoteRow = ({
label,
url,
remoteName,
onRemove,
}: {
label: string;
url: string;
remoteName?: string;
onRemove?: () => Promise<void>;
}) => {
const [copied, setCopied] = useState(false);
const copy = async () => {
try {
await navigator.clipboard.writeText(url);
setCopied(true);
toast.success(`${label} URL copied.`);
window.setTimeout(() => setCopied(false), 1800);
} catch (error) {
console.error(error);
toast.error('Could not copy remote URL.');
}
};
return (
<div className='border-border space-y-2 border-t pt-3 first:border-t-0 first:pt-0'>
<div className='flex flex-wrap items-center justify-between gap-2'>
<div>
<p className='text-sm font-medium'>{label}</p>
{remoteName ? (
<p className='text-muted-foreground text-xs'>
git remote: {remoteName}
</p>
) : null}
</div>
<div className='flex gap-2'>
<Button type='button' variant='outline' size='icon' onClick={copy}>
{copied ? (
<Check className='size-4' />
) : (
<Copy className='size-4' />
)}
</Button>
<Button type='button' variant='outline' size='icon' asChild>
<a
href={url}
target='_blank'
rel='noreferrer'
aria-label={`Open ${label} repository`}
>
<ExternalLink className='size-4' />
</a>
</Button>
{onRemove ? (
<Button
type='button'
variant='outline'
size='icon'
onClick={() => void onRemove()}
>
<Trash2 className='size-4' />
</Button>
) : null}
</div>
</div>
<Input
readOnly
value={url}
className='font-mono text-xs'
onFocus={(event) => event.currentTarget.select()}
/>
</div>
);
};
export const SpoonClonePanel = ({ spoon }: { spoon: Doc<'spoons'> }) => {
const remotes =
useQuery(api.spoonRemotes.listForSpoon, { spoonId: spoon._id }) ?? [];
const createRemote = useMutation(api.spoonRemotes.create);
const removeRemote = useMutation(api.spoonRemotes.remove);
const [label, setLabel] = useState('');
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>) => {
event.preventDefault();
setSubmitting(true);
try {
await createRemote({
spoonId: spoon._id,
label,
url,
remoteName: remoteName || undefined,
});
setLabel('');
setRemoteName('');
setUrl('');
setRemoteDialogOpen(false);
toast.success('Remote added.');
} catch (error) {
console.error(error);
toast.error('Could not add remote.');
} finally {
setSubmitting(false);
}
};
return (
<Card className='shadow-none'>
<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 ? (
<>
<RemoteRow
label='Primary fork'
remoteName='origin'
url={cloneUrl}
/>
<p className='text-muted-foreground text-xs'>
This GitHub fork remains Spoon&apos;s source of truth for upstream
maintenance.
</p>
</>
) : (
<p className='text-muted-foreground text-sm'>
Add fork metadata before Spoon can show a clone URL.
</p>
)}
{remotes.length ? (
<div className='space-y-3 pt-2'>
{remotes.map((remote) => (
<RemoteRow
key={remote._id}
label={remote.label}
remoteName={remote.remoteName}
url={remote.url}
onRemove={async () => {
try {
await removeRemote({ remoteId: remote._id });
toast.success('Remote removed.');
} catch (error) {
toast.error(
error instanceof Error
? error.message
: 'Could not remove remote.',
);
}
}}
/>
))}
</div>
) : null}
</CardContent>
</Card>
);
};