feat(forms): visible public link + one-click Copy on member and admin lists
- shared components/forms/copy-link-button.tsx (one control, no drift): copies the absolute /f/<space>/<slug> URL, check-mark + toast feedback - admin /forms list: Copy link button on every published public form - member /my-forms list: shows the /f/<space>/<slug> path and a Copy button for public forms; row restructured so the button is not nested in the row link - members-only forms show no public link (correct: they have none) 337 tests; build verified. Local commit (deploy on your word).
This commit is contained in:
@@ -6,6 +6,7 @@ import { PageHeader, PageTitle } from '@/components/ui/page-title'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from '@/components/ui/empty'
|
||||
import { CopyLinkButton } from '@/components/forms/copy-link-button'
|
||||
|
||||
type FormRow = {
|
||||
id: string
|
||||
@@ -76,6 +77,9 @@ export function FormsClient({ forms, spaceSlug }: { forms: FormRow[]; spaceSlug:
|
||||
{f.status}
|
||||
</Badge>
|
||||
<div className="flex gap-2">
|
||||
{f.visibility !== 'members' && f.status === 'published' && (
|
||||
<CopyLinkButton path={`/f/${spaceSlug}/${f.slug}`} />
|
||||
)}
|
||||
<Button asChild size="sm" variant="ghost">
|
||||
<Link href={`/forms/${f.id}/results`}>Results</Link>
|
||||
</Button>
|
||||
|
||||
+33
-18
@@ -5,6 +5,7 @@ import { PageHeader, PageTitle } from '@/components/ui/page-title'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from '@/components/ui/empty'
|
||||
import { ClipboardList } from 'lucide-react'
|
||||
import { CopyLinkButton } from '@/components/forms/copy-link-button'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -20,15 +21,17 @@ export default async function MyFormsPage() {
|
||||
|
||||
const { data: member } = await supabase
|
||||
.from('space_members')
|
||||
.select('space_id')
|
||||
.select('space_id, spaces(slug)')
|
||||
.eq('user_id', user.id)
|
||||
.in('status', ['current', 'unverified', 'late'])
|
||||
.single()
|
||||
if (!member) redirect('/login')
|
||||
|
||||
const spaceSlug = (member.spaces as { slug?: string } | null)?.slug ?? ''
|
||||
|
||||
const { data: forms } = await supabase
|
||||
.from('forms')
|
||||
.select('id, title, description, kind, status')
|
||||
.select('id, title, description, kind, status, slug, visibility')
|
||||
.eq('space_id', member.space_id)
|
||||
.eq('status', 'published')
|
||||
.order('created_at', { ascending: false })
|
||||
@@ -55,24 +58,36 @@ export default async function MyFormsPage() {
|
||||
</Empty>
|
||||
) : (
|
||||
<div className="divide-y rounded-lg border">
|
||||
{list.map(f => (
|
||||
<Link
|
||||
key={f.id}
|
||||
href={`/my-forms/${f.id}`}
|
||||
className="flex items-center gap-3 p-4 hover:bg-muted/40"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{f.title}</span>
|
||||
{f.kind === 'waiver' && <Badge variant="secondary">Waiver</Badge>}
|
||||
{list.map(f => {
|
||||
const isPublic = f.visibility !== 'members'
|
||||
return (
|
||||
<div key={f.id} className="flex flex-wrap items-center gap-3 p-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link href={`/my-forms/${f.id}`} className="font-medium hover:underline">
|
||||
{f.title}
|
||||
</Link>
|
||||
{f.kind === 'waiver' && <Badge variant="secondary">Waiver</Badge>}
|
||||
</div>
|
||||
{f.description && (
|
||||
<p className="truncate text-sm text-muted-foreground">{f.description}</p>
|
||||
)}
|
||||
{isPublic && (
|
||||
<p className="mt-0.5 font-mono text-xs text-muted-foreground">
|
||||
/f/{spaceSlug}/{f.slug}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{f.description && (
|
||||
<p className="truncate text-sm text-muted-foreground">{f.description}</p>
|
||||
)}
|
||||
{isPublic && <CopyLinkButton path={`/f/${spaceSlug}/${f.slug}`} />}
|
||||
<Link
|
||||
href={`/my-forms/${f.id}`}
|
||||
className="text-sm text-primary hover:underline"
|
||||
>
|
||||
Open →
|
||||
</Link>
|
||||
</div>
|
||||
<span className="text-sm text-primary">Open →</span>
|
||||
</Link>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Check, Copy } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
// One reusable copy-to-clipboard control for a form's public link. Shared by
|
||||
// the admin forms list and the member forms list so the behaviour and styling
|
||||
// never drift.
|
||||
export function CopyLinkButton({ path, label = 'Copy link' }: { path: string; label?: string }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
async function copy() {
|
||||
const url =
|
||||
typeof window !== 'undefined' ? `${window.location.origin}${path}` : path
|
||||
try {
|
||||
await navigator.clipboard?.writeText(url)
|
||||
setCopied(true)
|
||||
toast.success('Public link copied')
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
} catch {
|
||||
toast.error('Could not copy — copy it manually')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Button size="sm" variant="secondary" onClick={copy} aria-label={label}>
|
||||
{copied ? <Check className="size-4" /> : <Copy className="size-4" />}
|
||||
{label}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user