245 lines
7.5 KiB
TypeScript
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'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>
|
|
);
|
|
};
|