← alle entries

source-picker

FC-0123Dialogen & panelensource-pickerblockv1.0.0

Kaart (of dialooginhoud) om iets te maken uit een bron: kop met icoon, titel, toelichting en sluitknop; drie tabbladen — bestand uploaden, link plakken (met hint-chips en voorvertoning) of een bestaand item kiezen (zoekveld + lijst met status); een raster instellingen (selects); een voet met tweede actie en hoofdknop. Meldt onSubmit met bron én instellingen. Componeert tabs, file-dropzone, field, search-field, select en button.

zelfde component — andere tokens

Maak je audioshow

Zet een document neer of plak een link — het wordt een ingesproken aflevering die je kunt beluisteren, bijwerken en downloaden.

Sleep bestanden hierheen of klik om te kiezenElk bestandstype
Vorm
Standaard
Stem gastheer
Stem gast
Taal
Kwaliteit

Drie tabbladen, vijf instellingen; Maken meldt bron + waarden.

blocks/source-picker/source-picker.tsx
/**
 * SourcePicker — een kaart (of dialooginhoud) om iets te maken uit een
 * bron: kop met icoon, titel en toelichting, drie tabbladen — bestand
 * uploaden, link plakken (met hint-chips en een voorvertoning van de link),
 * of een bestaand item kiezen (zoekveld + lijst met status) — daaronder
 * een raster instellingen (selects) en een voet met een tweede actie en de
 * hoofdknop. Meldt `onSubmit` met bron én instellingen.
 *
 * Geoogst uit 21st.dev Input Modal (arihantcodes): acht shadcn-delen met
 * radix (Tabs, Select, Avatar, Label…) → tabs (FC-0120), file-dropzone
 * (FC-0042), field (FC-0005), search-field (FC-0048), select (FC-0074),
 * button (FC-0003); zestien lucide-iconen → slots en inline paden;
 * green/yellow/neutral-statusbadges → --success/--warning/--muted; de
 * GenFM-podcastteksten en de zes vaste keuzelijsten → props.
 */

"use client";

import { useMemo, useState } from "react";
import type * as React from "react";
import { Button } from "../../primitives/ui/button";
import { FileDropzone } from "../../primitives/ui/file-dropzone";
import { Field } from "../../primitives/ui/input";
import { SearchField } from "../../primitives/ui/search-field";
import { Select } from "../../primitives/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../../primitives/ui/tabs";
import { usePress } from "../../primitives/ui/press";

export interface SourceItem {
  id: string;
  name: string;
  icon?: React.ReactNode;
  meta?: readonly string[];
  status?: { label: string; tone?: "success" | "warning" | "muted" };
}
export interface SourceOption {
  id: string;
  label: string;
  options: readonly { value: string; label: string }[];
  defaultValue?: string;
  /** Kleine badge in het veld, bv. "Standaard". */
  hint?: string;
  /** Volle breedte (standaard halve breedte op breed). */
  wide?: boolean;
}
export type SourceChoice = { kind: "file"; files: File[] } | { kind: "url"; url: string } | { kind: "existing"; itemId: string };

export interface SourcePickerProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "title" | "onSubmit" | "children"> {
  icon?: React.ReactNode;
  title: string;
  description?: string;
  items?: readonly SourceItem[];
  options?: readonly SourceOption[];
  /** Chips onder het URL-veld (bv. "Artikelen", "Video"). */
  urlHints?: readonly { icon?: React.ReactNode; label: string }[];
  accept?: string;
  /** Tweede knop links in de voet (slot). */
  secondaryAction?: React.ReactNode;
  labels?: Partial<Record<"upload" | "url" | "existing" | "urlLabel" | "urlPlaceholder" | "urlReady" | "search" | "submit" | "close" | "empty" | "dropHint", string>>;
  onSubmit?: (choice: SourceChoice | null, values: Record<string, string>) => void;
  onClose?: () => void;
}

const Icoon = ({ d }: { d: string }) => <svg aria-hidden="true" viewBox="0 0 24 24" width="1em" height="1em" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><path d={d} /></svg>;
function Sluit({ label, onPress }: { label: string; onPress?: () => void }) {
  const { pressed, pressProps } = usePress({ onPress });
  return <button {...pressProps} type="button" className="fp-source__sluit" aria-label={label} data-pressed={pressed ? "" : undefined}><Icoon d="M6 6l12 12M18 6L6 18" /></button>;
}
function Item({ item, gekozen, onPress }: { item: SourceItem; gekozen: boolean; onPress: () => void }) {
  const { pressed, pressProps } = usePress({ onPress });
  return (
    <button {...pressProps} type="button" className="fp-source__item" aria-pressed={gekozen} data-pressed={pressed ? "" : undefined}>
      <span className="fp-source__item-icoon">{item.icon ?? <Icoon d="M6 3h8l5 5v13H6zM14 3v5h5" />}</span>
      <span className="fp-source__item-tekst">
        <span className="fp-source__item-naam">
          {item.name}
          {item.status && <span className="fp-source__status" data-tone={item.status.tone ?? "muted"}>{item.status.label}</span>}
        </span>
        {item.meta?.length ? <span className="fp-source__item-meta">{item.meta.join(" · ")}</span> : null}
      </span>
    </button>
  );
}

export function SourcePicker({ icon, title, description, items = [], options = [], urlHints = [], accept, secondaryAction, labels, onSubmit, onClose, className, ...rest }: SourcePickerProps) {
  const t = { upload: "Bestand", url: "Link", existing: "Bestaand", urlLabel: "Link naar de inhoud", urlPlaceholder: "https://voorbeeld.nl/artikel", urlReady: "Klaar om te importeren", search: "Zoek in je projecten", submit: "Maken", close: "Sluiten", empty: "Niets gevonden.", dropHint: "PDF, DOCX of TXT tot 10 MB", ...labels };
  const [tab, setTab] = useState("upload");
  const [bestanden, setBestanden] = useState<File[]>([]);
  const [url, setUrl] = useState("");
  const [zoek, setZoek] = useState("");
  const [gekozen, setGekozen] = useState<string | null>(null);
  const [waarden, setWaarden] = useState<Record<string, string>>(() => Object.fromEntries(options.map((o) => [o.id, o.defaultValue ?? o.options[0]?.value ?? ""])));
  const lijst = useMemo(() => items.filter((i) => i.name.toLowerCase().includes(zoek.toLowerCase())), [items, zoek]);

  const keuze = (): SourceChoice | null => {
    if (tab === "upload" && bestanden.length) return { kind: "file", files: bestanden };
    if (tab === "url" && url.trim()) return { kind: "url", url: url.trim() };
    if (tab === "existing" && gekozen) return { kind: "existing", itemId: gekozen };
    return null;
  };

  return (
    <div {...rest} className={["fp-source", className].filter(Boolean).join(" ")}>
      <div className="fp-source__kop">
        {icon && <span className="fp-source__icoon">{icon}</span>}
        <div className="fp-source__kop-tekst">
          <h2 className="fp-source__titel">{title}</h2>
          {description && <p className="fp-source__toelichting">{description}</p>}
        </div>
        {onClose && <Sluit label={t.close} onPress={onClose} />}
      </div>

      <Tabs value={tab} onChange={setTab} className="fp-source__tabs">
        <TabsList className="fp-source__tablist">
          <TabsTrigger value="upload"><Icoon d="M12 16V4M6 10l6-6 6 6M4 20h16" /> {t.upload}</TabsTrigger>
          <TabsTrigger value="url"><Icoon d="M10 14a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1 1M14 10a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1-1" /> {t.url}</TabsTrigger>
          <TabsTrigger value="existing"><Icoon d="M3 7h6l2 2h10v11H3z" /> {t.existing}</TabsTrigger>
        </TabsList>
        <TabsContent value="upload">
          <FileDropzone onFilesChange={setBestanden} accept={accept} labels={{ area: t.dropHint }} />
        </TabsContent>
        <TabsContent value="url">
          <div className="fp-source__url">
            <Field label={t.urlLabel} type="url" placeholder={t.urlPlaceholder} value={url} onChange={(e) => setUrl(e.target.value)} />
            {urlHints.length > 0 && (
              <div className="fp-source__chips">
                {urlHints.map((h) => (
                  <span key={h.label} className="fp-source__chip">{h.icon}{h.label}</span>
                ))}
              </div>
            )}
            {url && (
              <div className="fp-source__preview">
                <span className="fp-source__preview-icoon"><Icoon d="M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zM2 12h20M12 2a15 15 0 0 1 0 20M12 2a15 15 0 0 0 0 20" /></span>
                <span>
                  <span className="fp-source__preview-titel">{t.urlReady}</span>
                  <span className="fp-source__preview-url">{url}</span>
                </span>
              </div>
            )}
          </div>
        </TabsContent>
        <TabsContent value="existing">
          <div className="fp-source__bestaand">
            <SearchField label={t.search} placeholder={t.search} value={zoek} onChange={(e) => setZoek(e.target.value)} />
            <div className="fp-source__lijst">
              {lijst.length === 0 && <p className="fp-source__leeg">{t.empty}</p>}
              {lijst.map((i) => (
                <Item key={i.id} item={i} gekozen={gekozen === i.id} onPress={() => setGekozen(i.id)} />
              ))}
            </div>
          </div>
        </TabsContent>
      </Tabs>

      {options.length > 0 && (
        <div className="fp-source__opties">
          {options.map((o) => (
            <div key={o.id} className={o.wide ? "fp-source__optie fp-source__optie--breed" : "fp-source__optie"}>
              <Select label={o.label} options={o.options} value={waarden[o.id] ?? null} onChange={(v) => setWaarden((w) => ({ ...w, [o.id]: v ?? "" }))} />
              {o.hint && <span className="fp-source__hint">{o.hint}</span>}
            </div>
          ))}
        </div>
      )}

      <div className="fp-source__voet">
        <div>{secondaryAction}</div>
        <Button onPress={() => onSubmit?.(keuze(), waarden)}>{t.submit}</Button>
      </div>
    </div>
  );
}
blocks/source-picker/source-picker.css
/** SourcePicker — importeren in de app-root (registry-site: app/globals.css). */

.fp-source { width: 100%; max-width: 36rem; box-sizing: border-box; padding: clamp(1rem, 3vw, 2rem); border-radius: calc(var(--radius) * 3); border: 1px solid var(--border); background: var(--card); color: var(--card-foreground, var(--foreground)); box-shadow: var(--shadow-overlay); }
.fp-source__kop { display: flex; align-items: flex-start; gap: 1rem; margin-bottom: 1.5rem; }
.fp-source__icoon { display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; width: 3rem; height: 3rem; border-radius: calc(var(--radius) * 2); background: var(--foreground); color: var(--background); font-size: 1.25rem; }
.fp-source__kop-tekst { flex: 1 1 auto; min-width: 0; }
.fp-source__titel { margin: 0 0 0.375rem; font: inherit; font-size: 1.125rem; font-weight: 600; }
.fp-source__toelichting { margin: 0; font: inherit; font-size: 0.875rem; line-height: 1.5; color: var(--muted-foreground); }
.fp-source__sluit { display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; width: 2rem; height: 2rem; margin: -0.25rem -0.25rem 0 0; appearance: none; border: 0; border-radius: var(--radius); background: transparent; color: var(--muted-foreground); cursor: pointer; transition: color var(--motion-instant-duration) var(--motion-instant), background-color var(--motion-instant-duration) var(--motion-instant); }
.fp-source__sluit:hover { color: var(--foreground); background: var(--muted); }
.fp-source__sluit:focus-visible { outline: 2px solid var(--ring, var(--info)); outline-offset: 1px; }
.fp-source__tabs { margin-bottom: 1.5rem; }
.fp-source__tablist { width: 100%; }
.fp-source__tablist .fp-tabs__tab { flex: 1 1 0; }
.fp-source__url { display: grid; gap: 1rem; }
.fp-source__chips { display: flex; flex-wrap: wrap; gap: 0.5rem; }
.fp-source__chip { display: inline-flex; align-items: center; gap: 0.375rem; padding: 0.375rem 0.75rem; border-radius: 999px; border: 1px solid var(--border); font: inherit; font-size: 0.75rem; color: var(--muted-foreground); }
.fp-source__preview { display: flex; align-items: center; gap: 0.75rem; padding: 0.75rem; border-radius: calc(var(--radius) * 1.5); background: var(--muted); }
.fp-source__preview-icoon { display: inline-flex; align-items: center; justify-content: center; width: 2.25rem; height: 2.25rem; border-radius: var(--radius); background: var(--background); color: var(--muted-foreground); }
.fp-source__preview-titel { display: block; font: inherit; font-size: 0.875rem; font-weight: 500; }
.fp-source__preview-url { display: block; font: inherit; font-size: 0.75rem; color: var(--muted-foreground); overflow-wrap: anywhere; }
.fp-source__bestaand { display: grid; gap: 0.75rem; }
.fp-source__lijst { display: grid; gap: 0.5rem; max-height: 14rem; overflow: auto; }
.fp-source__leeg { margin: 0; padding: 1rem; text-align: center; font: inherit; font-size: 0.875rem; color: var(--muted-foreground); }
.fp-source__item { display: flex; align-items: flex-start; gap: 0.75rem; width: 100%; padding: 0.875rem 1rem; box-sizing: border-box; appearance: none; text-align: left; cursor: pointer; border: 1px solid var(--border); border-radius: calc(var(--radius) * 1.5); background: var(--background); color: inherit; font: inherit; transition: border-color var(--motion-instant-duration) var(--motion-instant), background-color var(--motion-instant-duration) var(--motion-instant); }
.fp-source__item:hover { border-color: color-mix(in srgb, var(--foreground) 30%, var(--border)); }
.fp-source__item[aria-pressed="true"] { border-color: color-mix(in srgb, var(--foreground) 45%, var(--border)); background: var(--muted); }
.fp-source__item:focus-visible { outline: 2px solid var(--ring, var(--info)); outline-offset: 1px; }
.fp-source__item-icoon { display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; width: 2rem; height: 2rem; border-radius: var(--radius); background: var(--muted); color: var(--muted-foreground); }
.fp-source__item-tekst { display: flex; flex-direction: column; gap: 0.25rem; min-width: 0; }
.fp-source__item-naam { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; font-size: 0.875rem; font-weight: 500; }
.fp-source__item-meta { font-size: 0.75rem; color: var(--muted-foreground); }
.fp-source__status { padding: 0.125rem 0.5rem; border-radius: 999px; font-size: 0.6875rem; font-weight: 500; background: var(--muted); color: var(--muted-foreground); }
.fp-source__status[data-tone="success"] { background: color-mix(in srgb, var(--success) 15%, transparent); color: var(--success); }
.fp-source__status[data-tone="warning"] { background: color-mix(in srgb, var(--warning) 18%, transparent); color: var(--warning); }
.fp-source__opties { display: grid; grid-template-columns: 1fr; gap: 1rem; margin-bottom: 1.5rem; }
@media (min-width: 32rem) { .fp-source__opties { grid-template-columns: 1fr 1fr; } .fp-source__optie--breed { grid-column: span 2; } }
.fp-source__optie { position: relative; display: grid; gap: 0.25rem; }
.fp-source__hint { font: inherit; font-size: 0.6875rem; color: var(--muted-foreground); }
.fp-source__voet { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding-top: 1rem; border-top: 1px solid var(--border); }