Self-hosted multi-platform broadcast newsroom for activist collectives: publishing pipeline with group approval and DST-proof scheduling, TweetDeck-style column wall with hostile-content-safe rendering, live control room with Auto Director, bridged chat governance with polls and quorum, distributed sandboxed transcode workers, encrypted content-addressed archive, monthly analytics. MIT licensed. See README.md and docs/.
24 lines
1.7 KiB
TypeScript
24 lines
1.7 KiB
TypeScript
"use client";
|
|
|
|
import { useRef, type TouchEvent } from "react";
|
|
|
|
export interface SourceChip { id: string; label: string; dot?: string; count?: number; }
|
|
|
|
export function SourceChips({ sources, value, onChange }: { sources: SourceChip[]; value: string; onChange: (value: string) => void }): React.ReactElement {
|
|
const start = useRef<{ x: number; y: number } | null>(null);
|
|
function end(event: TouchEvent<HTMLDivElement>): void {
|
|
if (start.current === null) return;
|
|
const dx = (event.changedTouches[0]?.clientX ?? start.current.x) - start.current.x;
|
|
const dy = (event.changedTouches[0]?.clientY ?? start.current.y) - start.current.y;
|
|
start.current = null;
|
|
if (Math.abs(dx) < 50 || Math.abs(dx) < Math.abs(dy) * 1.5) return;
|
|
const index = sources.findIndex((source) => source.id === value);
|
|
const next = sources[Math.max(0, Math.min(sources.length - 1, index + (dx < 0 ? 1 : -1)))];
|
|
if (next) onChange(next.id);
|
|
}
|
|
return <div className="flex min-w-0 gap-2 overflow-x-auto px-4 py-2 scrollbar-thin" role="tablist" aria-label="Feed sources" onTouchStart={(event) => { start.current = { x: event.touches[0]?.clientX ?? 0, y: event.touches[0]?.clientY ?? 0 }; }} onTouchEnd={end}>
|
|
{sources.map((source) => <button key={source.id} type="button" role="tab" aria-selected={value === source.id} onClick={() => onChange(source.id)} className={`flex min-h-11 shrink-0 items-center gap-2 rounded-full border px-4 text-xs font-semibold ${value === source.id ? "border-accent bg-accent-dim text-accent" : "border-border text-text-dim"}`}>
|
|
{source.dot && <span className="h-2 w-2 rounded-full bg-accent" aria-hidden="true" />}{source.label}{source.count !== undefined && <span className="text-text-faint">{source.count}</span>}
|
|
</button>)}
|
|
</div>;
|
|
}
|