)
diff --git a/src/components/MemberCard.tsx b/src/components/MemberCard.tsx
index e31b91e..1d4a48b 100644
--- a/src/components/MemberCard.tsx
+++ b/src/components/MemberCard.tsx
@@ -1,8 +1,4 @@
-
-
-export default function MemberCard({
- memberId = 'unknown',
-}: Readonly<{ memberId?: string }>) {
+export default function MemberCard() {
return (
- typeof window !== 'undefined' ? window.location.pathname : '/'
- )
+ const [menuOpen, setMenuOpen] = useState(false)
+ const location = useLocation()
+ const viewerQuery = useQuery({
+ queryKey: ['viewer'],
+ queryFn: fetchViewerState,
+ staleTime: 60_000,
+ })
+ const viewer = viewerQuery.data
+ // Close the mobile menu on route change.
useEffect(() => {
- const handleLocationChange = () => setPathname(window.location.pathname)
-
- // catch back/forward
- window.addEventListener('popstate', handleLocationChange)
-
- // also patch pushState to detect client-side navigations that don't trigger popstate
- const _pushState = history.pushState
- ;(history as any).pushState = function (...args: any[]) {
- _pushState.call(this, args[0], args[1], args[2])
- handleLocationChange()
- }
-
- return () => {
- window.removeEventListener('popstate', handleLocationChange)
- // restore
- ;(history as any).pushState = _pushState
- }
- }, [])
+ setMenuOpen(false)
+ }, [location.href])
return (
- {/* center: main links */}
-
+
+
+
+
+
+
+
+ {/* desktop links */}
+
-
-
-
-
- Videók
-
-
- Események
-
-
- Tagok
-
-
- Tanfolyamok
-
-
- Mivel foglalkozunk?
-
+ {NAV_LINKS.map((item) => (
+
+ {item.label}
+
+ ))}
{/* right: utilities */}
-
+
-
+
+
+ {viewer !== undefined && viewer.loggedIn ? (
+
+ ) : (
+
+ )}
+
setMenuOpen((open) => !open)}
>
-
-
+ {menuOpen ? (
+
+ ) : (
+
+ )}
-
+
+
+ {/* mobile menu */}
+ {menuOpen && (
+
+
+ {NAV_LINKS.map((item) => (
+
+ {item.label}
+
+ ))}
+
+ Keresés
+
+
+
+ )}
)
-}
\ No newline at end of file
+}
+
+/** Logs in, returning to the current page; logout lives in the profile menu. */
+function LoginButton() {
+ const location = useLocation()
+ const returnTo = `${location.pathname}${location.searchStr}`
+ return (
+
+ Belépés
+
+ )
+}
diff --git a/src/components/PageStates.tsx b/src/components/PageStates.tsx
new file mode 100644
index 0000000..1db225d
--- /dev/null
+++ b/src/components/PageStates.tsx
@@ -0,0 +1,145 @@
+import { Link } from '@tanstack/react-router'
+
+/**
+ * Shared page states with Hungarian copy (BSS-019): distinct loading, empty,
+ * and error states for every list and view (spec 18).
+ */
+
+export function LoadingState({ label = 'Betöltés…' }: { label?: string }) {
+ return (
+
+ )
+}
+
+export function EmptyState({
+ title,
+ description,
+}: {
+ title: string
+ description?: string
+}) {
+ return (
+
+
{title}
+ {description !== undefined && (
+
{description}
+ )}
+
+ )
+}
+
+export function ErrorState({
+ label = 'Hiba történt az adatok betöltése közben. Próbáld újra később.',
+}: {
+ label?: string
+}) {
+ return (
+
+ )
+}
+
+export function NotFoundContent() {
+ return (
+
+ 404
+
+ Az oldal nem található
+
+
+ A keresett tartalom nem létezik, még nem jelent meg, vagy már nem
+ elérhető.
+
+
+ Vissza a főoldalra
+
+
+ )
+}
+
+export function ForbiddenContent() {
+ return (
+
+ 403
+
+ Hozzáférés megtagadva
+
+
+ Ehhez az oldalhoz nincs jogosultságod.
+
+
+ Vissza a főoldalra
+
+
+ )
+}
+
+/* --- Loading placeholders ------------------------------------------------- */
+
+/** Placeholder for a line of text; the caller supplies the height. */
+export function SkeletonLine({ className = '' }: { className?: string }) {
+ return
+}
+
+/**
+ * Placeholder for a video or event card: 16:9 thumbnail plus a heading. Its
+ * sizes match the real card's, so the layout doesn't jump when content
+ * appears.
+ */
+export function ThumbnailCardSkeleton({
+ lines = 1,
+ className = '',
+}: {
+ lines?: number
+ className?: string
+}) {
+ return (
+
+
+
+ {Array.from({ length: lines }, (_, index) => (
+
+ ))}
+
+
+ )
+}
+
+/**
+ * Placeholder for a card grid. The `className` must carry the real grid's
+ * column settings so the placeholders render with the same number of columns.
+ */
+export function ThumbnailGridSkeleton({
+ count,
+ className = '',
+ lines = 1,
+ label = 'Betöltés…',
+}: {
+ count: number
+ className?: string
+ lines?: number
+ label?: string
+}) {
+ return (
+
+
{label}
+
+ {Array.from({ length: count }, (_, index) => (
+
+ ))}
+
+
+ )
+}
diff --git a/src/components/SearchBox.tsx b/src/components/SearchBox.tsx
new file mode 100644
index 0000000..59664c8
--- /dev/null
+++ b/src/components/SearchBox.tsx
@@ -0,0 +1,229 @@
+'use client'
+
+import { useEffect, useRef, useState } from 'react'
+import { useNavigate } from '@tanstack/react-router'
+
+interface SearchResults {
+ videos: Array<{ slug: string; title: string }>
+ events: Array<{ slug: string; title: string }>
+ members: Array<{ username: string; fullName: string }>
+ tags: Array<{ name: string }>
+}
+
+const EMPTY_RESULTS: SearchResults = {
+ videos: [],
+ events: [],
+ members: [],
+ tags: [],
+}
+
+/**
+ * Global search popover (spec 11.1): from two characters on, with a 250 ms
+ * delay; at most five hits per group; also keyboard-operable.
+ */
+export default function SearchBox() {
+ const navigate = useNavigate()
+ const [query, setQuery] = useState('')
+ const [open, setOpen] = useState(false)
+ const [results, setResults] = useState
(EMPTY_RESULTS)
+ const [activeIndex, setActiveIndex] = useState(-1)
+ const boxRef = useRef(null)
+ const abortRef = useRef(null)
+
+ useEffect(() => {
+ function onDocClick(event: MouseEvent) {
+ if (boxRef.current === null) return
+ if (
+ event.target instanceof Node &&
+ boxRef.current.contains(event.target)
+ ) {
+ return
+ }
+ setOpen(false)
+ }
+ document.addEventListener('click', onDocClick)
+ return () => document.removeEventListener('click', onDocClick)
+ }, [])
+
+ useEffect(() => {
+ const trimmed = query.trim()
+ if (trimmed.length < 2) {
+ setResults(EMPTY_RESULTS)
+ setActiveIndex(-1)
+ return
+ }
+ const timer = setTimeout(() => {
+ abortRef.current?.abort()
+ const controller = new AbortController()
+ abortRef.current = controller
+ void fetch(`/api/search?q=${encodeURIComponent(trimmed)}&limit=5`, {
+ signal: controller.signal,
+ })
+ .then((response) => (response.ok ? response.json() : EMPTY_RESULTS))
+ .then((data: SearchResults) => {
+ setResults(data)
+ setOpen(true)
+ })
+ .catch(() => {})
+ }, 250)
+ return () => clearTimeout(timer)
+ }, [query])
+
+ type Hit =
+ | { kind: 'video'; label: string; href: string }
+ | { kind: 'event'; label: string; href: string }
+ | { kind: 'member'; label: string; href: string }
+ | { kind: 'tag'; label: string; href: string }
+
+ const hits: Array = [
+ ...results.videos.map((video) => ({
+ kind: 'video' as const,
+ label: video.title,
+ href: `/videos/${video.slug}`,
+ })),
+ ...results.events.map((event) => ({
+ kind: 'event' as const,
+ label: event.title,
+ href: `/events/${event.slug}`,
+ })),
+ ...results.members.map((member) => ({
+ kind: 'member' as const,
+ label: `${member.fullName} (Tag)`,
+ href: `/members/${member.username}`,
+ })),
+ ...results.tags.map((tag) => ({
+ kind: 'tag' as const,
+ label: `${tag.name} (Címke)`,
+ href: `/videos?tags=${encodeURIComponent(tag.name)}`,
+ })),
+ ]
+
+ function openHit(hit: Hit) {
+ setOpen(false)
+ if (hit.href.startsWith('/videos?')) {
+ const tagName = decodeURIComponent(hit.href.replace('/videos?tags=', ''))
+ void navigate({
+ to: '/videos',
+ search: { tags: [tagName] },
+ })
+ return
+ }
+ window.location.assign(hit.href)
+ }
+
+ function onKeyDown(event: React.KeyboardEvent) {
+ if (event.key === 'Escape') {
+ setOpen(false)
+ setActiveIndex(-1)
+ return
+ }
+ if (!open || hits.length === 0) {
+ return
+ }
+ if (event.key === 'ArrowDown') {
+ event.preventDefault()
+ setActiveIndex((index) => (index + 1) % hits.length)
+ } else if (event.key === 'ArrowUp') {
+ event.preventDefault()
+ setActiveIndex((index) => (index <= 0 ? hits.length - 1 : index - 1))
+ } else if (event.key === 'Enter') {
+ const hit = hits.at(activeIndex)
+ if (hit === undefined) {
+ return
+ }
+ event.preventDefault()
+ openHit(hit)
+ }
+ }
+
+ const kindLabels: Record = {
+ video: 'Videó',
+ event: 'Esemény',
+ member: 'Tag',
+ tag: 'Címke',
+ }
+
+ return (
+
+
+
setQuery(event.target.value)}
+ onFocus={() => query.trim().length >= 2 && setOpen(true)}
+ onKeyDown={onKeyDown}
+ placeholder="Keresés..."
+ aria-label="Keresés"
+ aria-expanded={open}
+ role="combobox"
+ aria-controls="global-search-results"
+ className="w-40 border-0 text-(--nav-search-placeholder) outline-none focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 lg:w-56"
+ />
+
+
+
+
+
+ {open && (
+
+ {hits.length === 0 ? (
+
+ Nincs találat.
+
+ ) : (
+
+ {hits.map((hit, index) => (
+
+ openHit(hit)}
+ onMouseEnter={() => setActiveIndex(index)}
+ className={`flex w-full items-center justify-between px-3 py-2 text-left text-sm hover:bg-(--nav-search-bg) hover:text-(--orange) ${
+ index === activeIndex
+ ? 'bg-(--nav-search-bg) text-(--orange)'
+ : 'text-(--bss-text-secondary)'
+ }`}
+ >
+ {hit.label}
+
+ {kindLabels[hit.kind]}
+
+
+
+ ))}
+
+ )}
+
{
+ setOpen(false)
+ void navigate({
+ to: '/search',
+ search: { q: query.trim(), tab: 'all' },
+ })
+ }}
+ className="ctrl-btn w-full border-t border-t-(--nav-border-b) px-3 py-2 text-left text-sm font-bold text-(--orange)"
+ >
+ Teljes keresőoldal megnyitása
+
+
+ )}
+
+ )
+}
diff --git a/src/components/ThemeToggle.tsx b/src/components/ThemeToggle.tsx
index 5cdc9b3..01522f7 100644
--- a/src/components/ThemeToggle.tsx
+++ b/src/components/ThemeToggle.tsx
@@ -4,6 +4,20 @@ type ThemeMode = 'light' | 'dark' | 'auto'
type ResolvedTheme = 'light' | 'dark'
+function isThemeMode(value: unknown): value is ThemeMode {
+ return value === 'light' || value === 'dark' || value === 'auto'
+}
+
+/** The stored setting already applied by the inline script in `__root.tsx`. */
+function readStoredMode(): ThemeMode {
+ try {
+ const stored = window.localStorage.getItem('theme')
+ return isThemeMode(stored) ? stored : 'auto'
+ } catch {
+ return 'auto'
+ }
+}
+
function resolveThemeMode(mode: ThemeMode): ResolvedTheme {
if (typeof window === 'undefined') {
return 'light'
@@ -77,14 +91,15 @@ function MoonIcon() {
}
export default function ThemeToggle() {
+ // SSR always renders light; the real mode is picked up after hydration so
+ // that server and client output match.
const [mode, setMode] = useState('auto')
- const [resolvedTheme, setResolvedTheme] = useState(() =>
- resolveThemeMode('auto'),
- )
+ const [resolvedTheme, setResolvedTheme] = useState('light')
useEffect(() => {
- setMode('auto')
- setResolvedTheme(applyThemeMode('auto'))
+ const stored = readStoredMode()
+ setMode(stored)
+ setResolvedTheme(applyThemeMode(stored))
}, [])
useEffect(() => {
@@ -102,29 +117,19 @@ export default function ThemeToggle() {
}, [mode])
function toggleMode() {
- let nextMode: ThemeMode
- if (mode === 'light') {
- nextMode = 'dark'
- } else {
- nextMode = 'light'
- }
+ // From auto mode we switch to the opposite of the currently visible theme,
+ // so that every click produces a noticeable change.
+ const nextMode: ThemeMode = resolvedTheme === 'dark' ? 'light' : 'dark'
setMode(nextMode)
setResolvedTheme(applyThemeMode(nextMode))
window.localStorage.setItem('theme', nextMode)
}
- let modeLabel = 'Light'
- if (mode === 'dark') {
- modeLabel = 'Dark'
- } else if (mode === 'auto') {
- modeLabel = 'Auto'
- }
-
const label =
- mode === 'auto'
- ? 'Theme mode: auto (system). Click to switch to light mode.'
- : `Theme mode: ${mode}. Click to switch mode.`
+ resolvedTheme === 'dark'
+ ? 'Sötét téma aktív. Kattints a világos témára váltáshoz.'
+ : 'Világos téma aktív. Kattints a sötét témára váltáshoz.'
return (
{resolvedTheme === 'dark' ? : }
diff --git a/src/components/Thumbnail.tsx b/src/components/Thumbnail.tsx
new file mode 100644
index 0000000..3973282
--- /dev/null
+++ b/src/components/Thumbnail.tsx
@@ -0,0 +1,67 @@
+'use client'
+
+import { useCallback, useState } from 'react'
+
+/** Every thumbnail is 16:9; a missing image also reserves that space. */
+export const THUMBNAIL_FALLBACK_SRC = '/video-thumbnail.png'
+
+/**
+ * Thumbnail with a fixed 16:9 frame (BSS-019 UI): the frame reserves the
+ * space even before the image arrives, so the grid doesn't jump during
+ * loading. Until the image is ready, the frame flashes as a skeleton; the
+ * image then fades in.
+ */
+export default function Thumbnail({
+ src,
+ alt,
+ className = '',
+ imgClassName = '',
+ loading = 'lazy',
+}: {
+ src: string | null | undefined
+ alt: string
+ /** Extra classes applied to the 16:9 frame. */
+ className?: string
+ /** Extra classes applied to the ` ` itself. */
+ imgClassName?: string
+ loading?: 'lazy' | 'eager'
+}) {
+ const resolvedSrc = src ?? THUMBNAIL_FALLBACK_SRC
+ // We store the loaded URL rather than a plain boolean flag: this way the
+ // placeholder resets automatically when `src` changes (e.g. on re-render
+ // after filtering).
+ const [settledSrc, setSettledSrc] = useState(null)
+ const loaded = settledSrc === resolvedSrc
+
+ // The `load` event of an image arriving from cache can fire before hydration
+ // completes, so we also check the `complete` flag via the ref — otherwise
+ // the image would stay invisible.
+ const measureRef = useCallback(
+ (node: HTMLImageElement | null) => {
+ if (node !== null && node.complete) {
+ setSettledSrc(resolvedSrc)
+ }
+ },
+ [resolvedSrc],
+ )
+
+ return (
+
+
setSettledSrc(resolvedSrc)}
+ // On a broken URL, don't leave the skeleton flashing forever.
+ onError={() => setSettledSrc(resolvedSrc)}
+ className={`absolute inset-0 block h-full w-full object-cover transition-opacity duration-300 ${
+ loaded ? 'opacity-100' : 'opacity-0'
+ } ${imgClassName}`}
+ />
+
+ )
+}
diff --git a/src/components/UserMenu.tsx b/src/components/UserMenu.tsx
new file mode 100644
index 0000000..cea23b7
--- /dev/null
+++ b/src/components/UserMenu.tsx
@@ -0,0 +1,170 @@
+'use client'
+
+import { useEffect, useRef, useState } from 'react'
+import { Link, useLocation } from '@tanstack/react-router'
+import type { ViewerStateDto } from '#/server/pages/viewer-fn.ts'
+
+const LEVEL_LABELS: Record = {
+ anonymous: 'Vendég',
+ schonherz: 'Schönherz',
+ member: 'Tag',
+ leadership: 'Vezetőség',
+}
+
+/** Monogram in place of the profile picture: initials of at most two name parts. */
+function initials(name: string): string {
+ const letters = name
+ .trim()
+ .split(/\s+/)
+ .filter((part) => part !== '')
+ .slice(0, 2)
+ .map((part) => part.slice(0, 1).toLocaleUpperCase('hu-HU'))
+ .join('')
+ return letters === '' ? '?' : letters
+}
+
+function Avatar({
+ name,
+ avatarUrl,
+}: {
+ name: string
+ avatarUrl: string | null
+}) {
+ const [failed, setFailed] = useState(false)
+
+ if (avatarUrl === null || failed) {
+ return (
+
+ {initials(name)}
+
+ )
+ }
+ return (
+ setFailed(true)}
+ className="h-8 w-8 shrink-0 rounded-full object-cover"
+ />
+ )
+}
+
+/**
+ * The signed-in viewer's name and avatar in the navbar, with a popover on
+ * click (BSS-019 UI): the menu contains logout and, for members and above,
+ * the admin area. Authorization is decided by the server; the menu only hides
+ * inaccessible items — direct URLs are guarded by the server too (spec 14).
+ */
+export default function UserMenu({ viewer }: { viewer: ViewerStateDto }) {
+ const [open, setOpen] = useState(false)
+ const containerRef = useRef(null)
+ const location = useLocation()
+ const name = viewer.displayName ?? viewer.username ?? 'Profil'
+
+ // Close the menu on route change.
+ useEffect(() => {
+ setOpen(false)
+ }, [location.href])
+
+ useEffect(() => {
+ if (!open) {
+ return
+ }
+ function onPointerDown(event: MouseEvent) {
+ if (
+ containerRef.current !== null &&
+ event.target instanceof Node &&
+ containerRef.current.contains(event.target)
+ ) {
+ return
+ }
+ setOpen(false)
+ }
+ function onKeyDown(event: KeyboardEvent) {
+ if (event.key === 'Escape') {
+ setOpen(false)
+ }
+ }
+ document.addEventListener('mousedown', onPointerDown)
+ document.addEventListener('keydown', onKeyDown)
+ return () => {
+ document.removeEventListener('mousedown', onPointerDown)
+ document.removeEventListener('keydown', onKeyDown)
+ }
+ }, [open])
+
+ const itemClass =
+ 'block w-full px-3 py-2 text-left text-sm font-bold text-(--bss-text-secondary) hover:bg-(--nav-search-bg) hover:text-(--orange)'
+
+ return (
+
+
setOpen((value) => !value)}
+ aria-expanded={open}
+ aria-haspopup="menu"
+ aria-label={`Profilmenü – ${name}`}
+ className="flex items-center gap-2 rounded-full py-1 pr-2 pl-1 hover:bg-[color-mix(in_srgb,var(--orange)_16%,transparent)]"
+ >
+
+
+ {name}
+
+
+
+
+
+
+ {open && (
+
+
+
+
+
{name}
+
+ {viewer.username !== null && `${viewer.username} · `}
+ {LEVEL_LABELS[viewer.level]}
+
+
+
+
+ {viewer.canAccessAdmin && (
+
setOpen(false)}
+ className={itemClass}
+ >
+ Adminfelület
+
+ )}
+
+
+
+ )}
+
+ )
+}
diff --git a/src/components/VideoDetailPlayer.tsx b/src/components/VideoDetailPlayer.tsx
new file mode 100644
index 0000000..f42f051
--- /dev/null
+++ b/src/components/VideoDetailPlayer.tsx
@@ -0,0 +1,520 @@
+'use client'
+
+import { useEffect, useRef, useState } from 'react'
+
+type WebkitFullscreenVideo = HTMLVideoElement & {
+ webkitDisplayingFullscreen?: boolean
+ webkitEnterFullscreen?: () => void
+ webkitExitFullscreen?: () => void
+}
+
+function formatTime(value: number) {
+ if (!Number.isFinite(value) || value < 0) return '0:00'
+
+ const hours = Math.floor(value / 3600)
+ const minutes = Math.floor((value % 3600) / 60)
+ const seconds = Math.floor(value % 60)
+ return hours > 0
+ ? `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
+ : `${minutes}:${seconds.toString().padStart(2, '0')}`
+}
+
+/** Branded MP4 player. A view is counted on the first successful play event. */
+export default function VideoDetailPlayer({
+ videoId,
+ videoUrl,
+ posterUrl,
+ title,
+}: Readonly<{
+ videoId: string
+ videoUrl: string
+ posterUrl?: string | null
+ title: string
+}>) {
+ const playerRef = useRef(null)
+ const videoRef = useRef(null)
+ const countedRef = useRef(false)
+ const previousVolumeRef = useRef(1)
+ const hudTimerRef = useRef | null>(null)
+ const [errorKey, setErrorKey] = useState(0)
+ const [failed, setFailed] = useState(false)
+ const [isPlaying, setIsPlaying] = useState(false)
+ const [currentTime, setCurrentTime] = useState(0)
+ const [duration, setDuration] = useState(0)
+ const [volume, setVolume] = useState(1)
+ const [isFullscreen, setIsFullscreen] = useState(false)
+ const [isHudVisible, setIsHudVisible] = useState(true)
+
+ useEffect(() => {
+ setFailed(false)
+ setIsPlaying(false)
+ setCurrentTime(0)
+ setDuration(0)
+ countedRef.current = false
+ }, [videoId, videoUrl])
+
+ useEffect(() => {
+ const video = videoRef.current
+ if (!video) return
+
+ const syncDuration = () => {
+ if (Number.isFinite(video.duration) && video.duration > 0) {
+ setDuration(video.duration)
+ }
+ }
+
+ // Metadata can finish loading before hydration attaches React's media
+ // event handlers, especially when the MP4 is already cached.
+ syncDuration()
+ video.addEventListener('loadedmetadata', syncDuration)
+ video.addEventListener('durationchange', syncDuration)
+ video.addEventListener('canplay', syncDuration)
+ return () => {
+ video.removeEventListener('loadedmetadata', syncDuration)
+ video.removeEventListener('durationchange', syncDuration)
+ video.removeEventListener('canplay', syncDuration)
+ }
+ }, [errorKey, videoUrl])
+
+ useEffect(() => {
+ const handleFullscreenChange = () =>
+ setIsFullscreen(document.fullscreenElement === playerRef.current)
+ document.addEventListener('fullscreenchange', handleFullscreenChange)
+ return () =>
+ document.removeEventListener('fullscreenchange', handleFullscreenChange)
+ }, [])
+
+ useEffect(() => {
+ const video = videoRef.current
+ if (!video) return
+
+ const handleMobileFullscreenStart = () => setIsFullscreen(true)
+ const handleMobileFullscreenEnd = () => setIsFullscreen(false)
+ video.addEventListener('webkitbeginfullscreen', handleMobileFullscreenStart)
+ video.addEventListener('webkitendfullscreen', handleMobileFullscreenEnd)
+ return () => {
+ video.removeEventListener(
+ 'webkitbeginfullscreen',
+ handleMobileFullscreenStart,
+ )
+ video.removeEventListener(
+ 'webkitendfullscreen',
+ handleMobileFullscreenEnd,
+ )
+ }
+ }, [errorKey, videoUrl])
+
+ useEffect(() => {
+ revealHud()
+ }, [isFullscreen, isPlaying])
+
+ useEffect(
+ () => () => {
+ if (hudTimerRef.current !== null) clearTimeout(hudTimerRef.current)
+ },
+ [],
+ )
+
+ function handlePlay() {
+ setIsPlaying(true)
+ if (countedRef.current) return
+
+ countedRef.current = true
+ void fetch(`/api/videos/${videoId}/view`, { method: 'POST' }).catch(
+ () => {},
+ )
+ }
+
+ function togglePlayback() {
+ const video = videoRef.current
+ if (!video) return
+ if (video.paused) {
+ void video.play().catch(() => setFailed(true))
+ } else {
+ video.pause()
+ }
+ }
+
+ function skip(seconds: number) {
+ const video = videoRef.current
+ if (!video || !Number.isFinite(video.duration)) return
+ video.currentTime = Math.min(
+ Math.max(video.currentTime + seconds, 0),
+ video.duration,
+ )
+ setCurrentTime(video.currentTime)
+ }
+
+ function changeVolumeBy(amount: number) {
+ const video = videoRef.current
+ if (!video) return
+ const nextVolume = Math.min(Math.max(video.volume + amount, 0), 1)
+ video.volume = nextVolume
+ video.muted = nextVolume === 0
+ setVolume(nextVolume)
+ if (nextVolume > 0) previousVolumeRef.current = nextVolume
+ }
+
+ function handlePlayerKeyDown(event: React.KeyboardEvent) {
+ revealHud()
+
+ const target = event.target as HTMLElement
+ const key = event.key.toLowerCase()
+ if (target !== event.currentTarget) {
+ if (
+ target.closest('input, a, textarea, select, [contenteditable=true]')
+ ) {
+ return
+ }
+ if (target.closest('button') && (key === ' ' || key === 'enter')) {
+ return
+ }
+ }
+
+ const video = videoRef.current
+ if (!video) return
+
+ if (event.repeat && [' ', 'k', 'm', 'f'].includes(key)) {
+ event.preventDefault()
+ return
+ }
+
+ switch (key) {
+ case ' ':
+ case 'k':
+ event.preventDefault()
+ togglePlayback()
+ break
+ case 'j':
+ event.preventDefault()
+ skip(-10)
+ break
+ case 'l':
+ event.preventDefault()
+ skip(10)
+ break
+ case 'arrowleft':
+ event.preventDefault()
+ skip(-5)
+ break
+ case 'arrowright':
+ event.preventDefault()
+ skip(5)
+ break
+ case 'arrowup':
+ event.preventDefault()
+ changeVolumeBy(0.05)
+ break
+ case 'arrowdown':
+ event.preventDefault()
+ changeVolumeBy(-0.05)
+ break
+ case 'm':
+ event.preventDefault()
+ toggleMute()
+ break
+ case 'f':
+ event.preventDefault()
+ void toggleFullscreen()
+ break
+ case 'home':
+ event.preventDefault()
+ video.currentTime = 0
+ setCurrentTime(0)
+ break
+ case 'end':
+ event.preventDefault()
+ if (Number.isFinite(video.duration)) {
+ video.currentTime = video.duration
+ setCurrentTime(video.duration)
+ }
+ break
+ default:
+ if (/^[0-9]$/.test(key) && Number.isFinite(video.duration)) {
+ event.preventDefault()
+ const nextTime = video.duration * (Number(key) / 10)
+ video.currentTime = nextTime
+ setCurrentTime(nextTime)
+ }
+ }
+ }
+
+ function seek(event: React.ChangeEvent) {
+ const video = videoRef.current
+ if (!video) return
+ video.currentTime = Number(event.target.value)
+ setCurrentTime(video.currentTime)
+ }
+
+ function changeVolume(event: React.ChangeEvent) {
+ const video = videoRef.current
+ if (!video) return
+ const nextVolume = Number(event.target.value)
+ video.volume = nextVolume
+ video.muted = nextVolume === 0
+ setVolume(nextVolume)
+ if (nextVolume > 0) previousVolumeRef.current = nextVolume
+ }
+
+ function toggleMute() {
+ const video = videoRef.current
+ if (!video) return
+
+ if (video.muted || volume === 0) {
+ const restoredVolume = previousVolumeRef.current || 1
+ video.muted = false
+ video.volume = restoredVolume
+ setVolume(restoredVolume)
+ } else {
+ previousVolumeRef.current = volume
+ video.muted = true
+ setVolume(0)
+ }
+ }
+
+ async function toggleFullscreen() {
+ const player = playerRef.current
+ const video: WebkitFullscreenVideo | null = videoRef.current
+ if (!player || !video) return
+
+ if (document.fullscreenElement !== null) {
+ try {
+ await document.exitFullscreen()
+ } catch {
+ // The browser owns fullscreen state; leave the player unchanged if
+ // exiting is rejected.
+ }
+ return
+ }
+
+ if (video.webkitDisplayingFullscreen) {
+ video.webkitExitFullscreen?.()
+ return
+ }
+
+ // iOS Safari does not support fullscreen on arbitrary containers. Its
+ // video-specific API must be called directly from the user gesture.
+ if (!document.fullscreenEnabled && video.webkitEnterFullscreen) {
+ video.webkitEnterFullscreen()
+ return
+ }
+
+ try {
+ if (typeof player.requestFullscreen === 'function') {
+ await player.requestFullscreen()
+ return
+ }
+ if (typeof video.requestFullscreen === 'function') {
+ await video.requestFullscreen()
+ return
+ }
+ } catch {
+ // Fall through to the WebKit mobile-video API when the standard API is
+ // present but unavailable for this element.
+ }
+
+ video.webkitEnterFullscreen?.()
+ }
+
+ function revealHud() {
+ setIsHudVisible(true)
+ if (hudTimerRef.current !== null) clearTimeout(hudTimerRef.current)
+
+ if (
+ document.fullscreenElement === playerRef.current &&
+ videoRef.current !== null &&
+ !videoRef.current.paused
+ ) {
+ hudTimerRef.current = setTimeout(() => {
+ setIsHudVisible(false)
+ hudTimerRef.current = null
+ }, 2500)
+ }
+ }
+
+ function retry() {
+ setFailed(false)
+ setErrorKey((value) => value + 1)
+ }
+
+ if (failed) {
+ return (
+
+
+ A videó lejátszása most nem sikerült. Ellenőrizd a kapcsolatot, majd
+ próbáld újra.
+
+
+ Újrapróbálás
+
+
+ )
+ }
+
+ const playedPercent = duration > 0 ? (currentTime / duration) * 100 : 0
+
+ return (
+
+
{
+ playerRef.current?.focus()
+ togglePlayback()
+ }}
+ onPlay={handlePlay}
+ onPause={() => setIsPlaying(false)}
+ onEnded={() => setIsPlaying(false)}
+ onTimeUpdate={(event) =>
+ setCurrentTime(event.currentTarget.currentTime)
+ }
+ onVolumeChange={(event) => {
+ const video = event.currentTarget
+ setVolume(video.muted ? 0 : video.volume)
+ }}
+ onError={() => setFailed(true)}
+ />
+
+ {!isPlaying && (
+
+
+
+
+
+ )}
+
+
+
+
+
+
+
+
+ {isPlaying ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+
+
+ {volume === 0 ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+
+
+
+
+ {formatTime(currentTime)} / {formatTime(duration)}
+
+
+
void toggleFullscreen()}
+ className="grid size-9 shrink-0 place-items-center rounded-full transition hover:bg-white/15 hover:text-(--orange) focus-visible:outline-2 focus-visible:outline-white"
+ aria-label={
+ isFullscreen ? 'Teljes képernyő bezárása' : 'Teljes képernyő'
+ }
+ title={
+ isFullscreen
+ ? 'Teljes képernyő bezárása (F)'
+ : 'Teljes képernyő (F)'
+ }
+ >
+
+ {isFullscreen ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ )
+}
diff --git a/src/components/Videoplayer.tsx b/src/components/Videoplayer.tsx
index bbceabb..aad0b78 100644
--- a/src/components/Videoplayer.tsx
+++ b/src/components/Videoplayer.tsx
@@ -3,10 +3,8 @@
import { useRef, useState, useEffect } from 'react'
export default function Videoplayer({
- videoId = '',
videoUrl = '/test_video.mp4',
}: Readonly<{
- videoId?: string
videoUrl?: string
}>) {
const videoRef = useRef(null)
diff --git a/src/components/admin/._SearchSelect.tsx b/src/components/admin/._SearchSelect.tsx
new file mode 100755
index 0000000..cf353fe
Binary files /dev/null and b/src/components/admin/._SearchSelect.tsx differ
diff --git a/src/components/admin/AdminSidebar.tsx b/src/components/admin/AdminSidebar.tsx
new file mode 100644
index 0000000..4143302
--- /dev/null
+++ b/src/components/admin/AdminSidebar.tsx
@@ -0,0 +1,60 @@
+import { Link, useMatchRoute } from '@tanstack/react-router'
+
+export interface SidebarItem {
+ to: string
+ label: string
+ leadershipOnly?: boolean
+}
+
+/** Sidebar items in order, per chapter 12.1 of the spec. */
+export const ADMIN_SIDEBAR_ITEMS: SidebarItem[] = [
+ { to: '/admin/videos', label: 'Videók' },
+ { to: '/admin/events', label: 'Események' },
+ { to: '/admin/homepage', label: 'Live és kiemelés', leadershipOnly: true },
+ { to: '/admin/catalog/tags', label: 'Címkekatalógus', leadershipOnly: true },
+ {
+ to: '/admin/catalog/staff-roles',
+ label: 'Stábszerepek',
+ leadershipOnly: true,
+ },
+ { to: '/admin/members', label: 'Tagok', leadershipOnly: true },
+ { to: '/admin/trash', label: 'Lomtár' },
+ { to: '/admin/audit', label: 'Auditnapló', leadershipOnly: true },
+]
+
+/**
+ * Admin sidebar (BSS-027): a member does not see leadership menu items — but
+ * the leadership pages are also separately blocked by the server, even via
+ * direct URLs.
+ */
+export function AdminSidebar({ level }: { level: string }) {
+ const matchRoute = useMatchRoute()
+ const items = ADMIN_SIDEBAR_ITEMS.filter(
+ (item) => !item.leadershipOnly || level === 'leadership',
+ )
+
+ return (
+
+ {items.map((item) => {
+ const active = Boolean(matchRoute({ to: item.to, fuzzy: true }))
+ return (
+
+ {item.label}
+
+ )
+ })}
+
+ )
+}
diff --git a/src/components/admin/Alerts.tsx b/src/components/admin/Alerts.tsx
new file mode 100644
index 0000000..9053496
--- /dev/null
+++ b/src/components/admin/Alerts.tsx
@@ -0,0 +1,98 @@
+import type { ReactNode } from 'react'
+
+/**
+ * Admin save error states (BSS-028, spec 12.4):
+ * - expired session: the form data stays on the client; it can be resubmitted
+ * after logging in again;
+ * - stale save (409): conflict message + reload option, no "last save wins"
+ * behavior.
+ */
+
+export function LoginRequiredBanner({ loginUrl }: { loginUrl: string }) {
+ return (
+
+
A bejelentkezésed lejárt.
+
+ A kitöltött adatok nem vesznek el ezen az oldalon.{' '}
+
+ Jelentkezz be újra
+ {' '}
+ egy új fülön, majd próbáld meg itt újra menteni.
+
+
+ )
+}
+
+export function ConflictBanner({
+ message,
+ onReload,
+}: {
+ message: string
+ onReload: () => void
+}) {
+ return (
+
+
Ütközés: más módosította közben a rekordot.
+
{message}
+
+ Legfrissebb állapot betöltése
+
+
+ )
+}
+
+export function ValidationProblems({ problems }: { problems: string[] }) {
+ return (
+
+ {problems.map((problem, index) => (
+ {problem}
+ ))}
+
+ )
+}
+
+export function FormMessage({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ )
+}
+
+/**
+ * Non-blocking warning (e.g. disallowed media host): the draft can still be
+ * saved with it, but it cannot be published.
+ */
+export function WarningList({ warnings }: { warnings: string[] }) {
+ if (warnings.length === 0) {
+ return null
+ }
+ return (
+
+
Figyelem
+
+ {warnings.map((warning, index) => (
+ {warning}
+ ))}
+
+
+ )
+}
diff --git a/src/components/admin/ResponsiveTable.tsx b/src/components/admin/ResponsiveTable.tsx
new file mode 100644
index 0000000..7c01bcd
--- /dev/null
+++ b/src/components/admin/ResponsiveTable.tsx
@@ -0,0 +1,83 @@
+import type { ReactNode } from 'react'
+import { EmptyState } from '#/components/PageStates.tsx'
+
+export interface AdminColumn {
+ key: string
+ header: string
+ render: (row: TRow) => ReactNode
+ /** The primary (bold) row in card view. */
+ primary?: boolean
+}
+
+/**
+ * Shared admin table (BSS-027): a table on desktop, cards on mobile —
+ * instead of a horizontally scrollable table (spec 12.1).
+ */
+export function ResponsiveTable({
+ columns,
+ rows,
+ emptyTitle,
+ emptyDescription,
+}: {
+ columns: Array>
+ rows: readonly TRow[]
+ emptyTitle: string
+ emptyDescription?: string
+}) {
+ if (rows.length === 0) {
+ return
+ }
+
+ return (
+ <>
+ {/* Desktop table */}
+
+
+
+
+ {columns.map((column) => (
+
+ {column.header}
+
+ ))}
+
+
+
+ {rows.map((row, index) => (
+
+ {columns.map((column) => (
+
+ {column.render(row)}
+
+ ))}
+
+ ))}
+
+
+
+ {/* Mobile card view */}
+
+ {rows.map((row, index) => (
+
+ {columns.map((column) => (
+
+
+ {column.header}:
+
+
+ {column.render(row)}
+
+
+ ))}
+
+ ))}
+
+ >
+ )
+}
diff --git a/src/components/admin/SearchSelect.tsx b/src/components/admin/SearchSelect.tsx
new file mode 100644
index 0000000..f6fb1ae
--- /dev/null
+++ b/src/components/admin/SearchSelect.tsx
@@ -0,0 +1,243 @@
+'use client'
+
+import { useEffect, useId, useMemo, useRef, useState } from 'react'
+import type { ReactNode } from 'react'
+import { matchesSearch } from '#/lib/text-search.ts'
+
+export interface SearchSelectOption {
+ value: string
+ label: string
+ /** Secondary info shown at the end of the row (e.g. event date). */
+ meta?: string
+}
+
+/** Subtler label style for filter bars. */
+export const FILTER_LABEL_CLASS = 'text-xs text-(--bss-text-secondary)'
+
+/** The search bar in the dropdown appears above this many items. */
+export const SEARCH_SELECT_THRESHOLD = 8
+
+/**
+ * Searchable single-select list (combobox) for admin lists: event, staff
+ * member, related video, tag, action. Needed instead of a native `select`
+ * because with hundreds of items you can't search in the dropdown (BSS-028 UI
+ * fix). For short, fixed lists (status, visibility) the search bar is omitted
+ * to avoid a needless field, while keeping the appearance consistent.
+ */
+export function AdminSearchSelect({
+ label,
+ value,
+ options,
+ onChange,
+ placeholder = 'Válassz…',
+ searchPlaceholder = 'Keresés…',
+ emptyOptionLabel,
+ hint,
+ disabled = false,
+ triggerClassName = '',
+ labelClassName = 'font-bold text-(--bss-text)',
+ searchThreshold = SEARCH_SELECT_THRESHOLD,
+}: {
+ label?: string
+ /** Selected value; empty text = no selection. */
+ value: string
+ options: ReadonlyArray
+ onChange: (value: string) => void
+ placeholder?: string
+ searchPlaceholder?: string
+ /** When set, a separate row is added at the top of the list to choose the empty value. */
+ emptyOptionLabel?: string
+ hint?: ReactNode
+ disabled?: boolean
+ triggerClassName?: string
+ /** In filter bars we use a subtler label style. */
+ labelClassName?: string
+ /** The search bar appears above this many items. */
+ searchThreshold?: number
+}) {
+ const [open, setOpen] = useState(false)
+ const [query, setQuery] = useState('')
+ const [activeIndex, setActiveIndex] = useState(0)
+ const containerRef = useRef(null)
+ const panelRef = useRef(null)
+ const listboxId = useId()
+ const labelId = useId()
+ const showSearch = options.length > searchThreshold
+
+ const visible = useMemo(() => {
+ const filtered = options.filter((option) =>
+ matchesSearch(`${option.label} ${option.meta ?? ''}`, query),
+ )
+ return emptyOptionLabel !== undefined
+ ? [{ value: '', label: emptyOptionLabel }, ...filtered]
+ : filtered
+ }, [options, query, emptyOptionLabel])
+
+ const selectedLabel =
+ options.find((option) => option.value === value)?.label ?? null
+
+ useEffect(() => {
+ if (!open) {
+ return
+ }
+ function onPointerDown(event: MouseEvent) {
+ if (
+ containerRef.current !== null &&
+ event.target instanceof Node &&
+ containerRef.current.contains(event.target)
+ ) {
+ return
+ }
+ setOpen(false)
+ }
+ document.addEventListener('mousedown', onPointerDown)
+ return () => document.removeEventListener('mousedown', onPointerDown)
+ }, [open])
+
+ // Without a search bar, the panel gets focus so the arrow keys work.
+ useEffect(() => {
+ if (open && !showSearch) {
+ panelRef.current?.focus()
+ }
+ }, [open, showSearch])
+
+ function select(nextValue: string) {
+ onChange(nextValue)
+ setOpen(false)
+ setQuery('')
+ }
+
+ function onKeyDown(event: React.KeyboardEvent) {
+ if (event.key === 'Escape') {
+ event.preventDefault()
+ setOpen(false)
+ return
+ }
+ if (event.key === 'Enter') {
+ // Works inside filter forms too: Enter selects from the list instead of submitting the form.
+ event.preventDefault()
+ const option = visible.at(activeIndex)
+ if (option !== undefined) {
+ select(option.value)
+ }
+ return
+ }
+ if (visible.length === 0) {
+ return
+ }
+ if (event.key === 'ArrowDown') {
+ event.preventDefault()
+ setActiveIndex((index) => (index + 1) % visible.length)
+ } else if (event.key === 'ArrowUp') {
+ event.preventDefault()
+ setActiveIndex((index) => (index <= 0 ? visible.length - 1 : index - 1))
+ }
+ }
+
+ return (
+
+ {label !== undefined && (
+
+ {label}
+
+ )}
+
+
{
+ setQuery('')
+ setActiveIndex(0)
+ setOpen((current) => !current)
+ }}
+ className={`flex h-10 w-full items-center justify-between gap-2 border-b border-(--nav-border-b) bg-(--nav-search-bg) px-2 text-left outline-none hover:border-(--orange) active:scale-100 disabled:opacity-40 ${triggerClassName}`}
+ >
+
+ {selectedLabel ?? placeholder}
+
+
+
+
+
+
+ {open && (
+
+ {showSearch && (
+
{
+ setQuery(event.target.value)
+ setActiveIndex(0)
+ }}
+ placeholder={searchPlaceholder}
+ aria-label={searchPlaceholder}
+ className="h-10 w-full border-b border-(--nav-border-b) bg-(--nav-search-bg) px-2 outline-none focus:border-(--orange)"
+ />
+ )}
+ {visible.length === 0 ? (
+
+ Nincs találat.
+
+ ) : (
+
+ {visible.map((option, index) => (
+
+ setActiveIndex(index)}
+ onClick={() => select(option.value)}
+ className={`flex w-full items-center justify-between gap-2 px-3 py-2 text-left text-sm hover:text-(--orange) ${
+ index === activeIndex
+ ? 'bg-(--nav-search-bg) text-(--orange)'
+ : 'text-(--bss-text-secondary)'
+ } ${option.value === value ? 'font-bold' : ''}`}
+ >
+ {option.label}
+ {option.meta !== undefined && (
+
+ {option.meta}
+
+ )}
+
+
+ ))}
+
+ )}
+
+ )}
+
+ {hint !== undefined && (
+
{hint}
+ )}
+
+ )
+}
diff --git a/src/components/admin/VideoVisibility.tsx b/src/components/admin/VideoVisibility.tsx
new file mode 100644
index 0000000..e08bc94
--- /dev/null
+++ b/src/components/admin/VideoVisibility.tsx
@@ -0,0 +1,15 @@
+import { Eye, EyeOff, Lock } from 'lucide-react'
+import { visibilityLabel } from '#/lib/admin-labels.ts'
+
+export function VideoVisibility({ visibility }: { visibility: string }) {
+ const label = visibilityLabel(visibility)
+ const Icon =
+ visibility === 'public' ? Eye : visibility === 'schonherz' ? EyeOff : Lock
+
+ return (
+
+
+ {label}
+
+ )
+}
diff --git a/src/components/admin/form.tsx b/src/components/admin/form.tsx
new file mode 100644
index 0000000..bc9fc3f
--- /dev/null
+++ b/src/components/admin/form.tsx
@@ -0,0 +1,134 @@
+import type { ReactNode } from 'react'
+
+/** Shared admin form fields (BSS-028). */
+
+export function AdminTextField({
+ label,
+ value,
+ onChange,
+ type = 'text',
+ required = false,
+ maxLength,
+ hint,
+}: {
+ label: string
+ value: string
+ onChange: (value: string) => void
+ type?: string
+ required?: boolean
+ /** When set, the field is limited and gets a remaining-character indicator (spec 18). */
+ maxLength?: number
+ hint?: ReactNode
+}) {
+ return (
+
+
+ {label}
+ {maxLength !== undefined && (
+
+ ({maxLength - value.length} karakter hátra)
+
+ )}
+
+ onChange(event.target.value)}
+ className="h-10 border-b border-(--nav-border-b) bg-(--nav-search-bg) px-2 outline-none focus:border-(--orange)"
+ />
+ {hint !== undefined && (
+ {hint}
+ )}
+
+ )
+}
+
+export function AdminTextArea({
+ label,
+ value,
+ onChange,
+ rows = 4,
+ maxLength,
+ hint,
+}: {
+ label: string
+ value: string
+ onChange: (value: string) => void
+ rows?: number
+ maxLength?: number
+ hint?: ReactNode
+}) {
+ return (
+
+
+ {label}
+ {maxLength !== undefined && (
+
+ ({maxLength - value.length} karakter hátra)
+
+ )}
+
+
+ )
+}
+
+export function AdminPrimaryButton({
+ children,
+ onClick,
+ disabled = false,
+}: {
+ children: ReactNode
+ onClick: () => void
+ disabled?: boolean
+}) {
+ return (
+
+ {children}
+
+ )
+}
+
+export function AdminSecondaryButton({
+ children,
+ onClick,
+ disabled = false,
+ confirm,
+}: {
+ children: ReactNode
+ onClick: () => void
+ disabled?: boolean
+ /** When set, asks for standard confirmation (spec 13.1). */
+ confirm?: string
+}) {
+ return (
+ {
+ if (confirm === undefined || window.confirm(confirm)) {
+ onClick()
+ }
+ }}
+ className="ctrl-btn rounded border border-(--nav-border-b) px-4 py-2 text-sm font-bold text-(--bss-text) hover:border-(--orange) disabled:opacity-40"
+ >
+ {children}
+
+ )
+}
diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx
index b033601..49bd073 100644
--- a/src/components/ui/button.tsx
+++ b/src/components/ui/button.tsx
@@ -1,49 +1,50 @@
-import { Button as ButtonPrimitive } from "@base-ui/react/button"
-import { cva, type VariantProps } from "class-variance-authority"
+import { Button as ButtonPrimitive } from '@base-ui/react/button'
+import { cva } from 'class-variance-authority'
+import type { VariantProps } from 'class-variance-authority'
-import { cn } from "@/lib/utils"
+import { cn } from '@/lib/utils'
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
- default: "bg-primary text-primary-foreground hover:bg-primary/80",
+ default: 'bg-primary text-primary-foreground hover:bg-primary/80',
outline:
- "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
+ 'border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50',
secondary:
- "bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
+ 'bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
ghost:
- "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
+ 'hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50',
destructive:
- "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
- link: "text-primary underline-offset-4 hover:underline",
+ 'bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40',
+ link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default:
- "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
+ 'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
- lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
- icon: "size-8",
- "icon-xs":
+ lg: 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
+ icon: 'size-8',
+ 'icon-xs':
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
- "icon-sm":
- "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
- "icon-lg": "size-9",
+ 'icon-sm':
+ 'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg',
+ 'icon-lg': 'size-9',
},
},
defaultVariants: {
- variant: "default",
- size: "default",
+ variant: 'default',
+ size: 'default',
},
- }
+ },
)
function Button({
className,
- variant = "default",
- size = "default",
+ variant = 'default',
+ size = 'default',
...props
}: ButtonPrimitive.Props & VariantProps) {
return (
diff --git a/src/db/schema.ts b/src/db/schema.ts
index e796ca7..e4241ca 100644
--- a/src/db/schema.ts
+++ b/src/db/schema.ts
@@ -1,67 +1,242 @@
-import { defineRelations } from 'drizzle-orm'
+import { sql } from 'drizzle-orm'
import {
+ bigserial,
+ boolean,
+ check,
+ date,
+ index,
integer,
+ jsonb,
+ pgEnum,
pgTable,
- varchar,
- uuid,
- text,
primaryKey,
- date, pgEnum, bytea,
+ text,
+ timestamp,
+ uniqueIndex,
+ uuid,
+ varchar,
} from 'drizzle-orm/pg-core'
-export const visibilityEnum = pgEnum('visibility', ['public', 'schonherz', 'bss'])
+export const visibilityEnum = pgEnum('visibility', [
+ 'public',
+ 'schonherz',
+ 'bss',
+])
-export const homepageStatusEnum = pgEnum('homepage_status', ['live', 'highlighted_video', 'normal'])
+export const contentStatusEnum = pgEnum('content_status', [
+ 'draft',
+ 'published',
+ 'archived',
+ 'trash',
+])
-export const homepageStatusTable = pgTable('current_homepage_status', {
- id: integer().primaryKey().default(0),
- status: homepageStatusEnum().notNull().default('normal'),
- updated_at: date().defaultNow(),
- created_at: date().defaultNow(),
-})
+export const eventStatusEnum = pgEnum('event_status', [
+ 'draft',
+ 'published',
+ 'archived',
+])
-export const usersTable = pgTable('users', {
- id: uuid().primaryKey().defaultRandom(),
- name: varchar({ length: 255 }).notNull(),
- nickname: varchar({ length: 255 }),
- profile_picture: bytea(),
- joined_at: text(),
- status: text(),
- introduction: text(),
-})
+export const membershipStatusEnum = pgEnum('membership_status', [
+ 'studio_member',
+ 'studio_candidate',
+ 'studio_applicant',
+ 'senior_active',
+ 'senior_archived',
+ 'contributor',
+])
-export const videos = pgTable('videos', {
- id: uuid().primaryKey().defaultRandom(),
- title: text().notNull(),
- description: text(),
- visibility: visibilityEnum().notNull().default('bss'),
- video_url: text().notNull(),
- views: integer().default(0),
- songs: text(),
- changeable_uploaded_at: date().notNull().defaultNow(),
- updated_at: date().defaultNow(),
- created_at: date().defaultNow(),
-})
+export const semesterEnum = pgEnum('semester', ['spring', 'autumn'])
-export const relatedVideos = pgTable(
- 'related_videos',
+export const memberSyncStatusEnum = pgEnum('member_sync_status', [
+ 'ok',
+ 'error',
+])
+
+export const memberSyncTriggerEnum = pgEnum('member_sync_trigger', [
+ 'startup',
+ 'hourly',
+ 'manual',
+ 'test',
+])
+
+export const liveStatusEnum = pgEnum('live_status', [
+ 'scheduled',
+ 'active',
+ 'ended',
+])
+
+export const slugEntityTypeEnum = pgEnum('slug_entity_type', ['video', 'event'])
+
+export const authSessions = pgTable(
+ 'auth_sessions',
{
- videoId: uuid('video_id')
+ id: varchar('id', { length: 64 }).primaryKey(),
+ memberSub: varchar('member_sub', { length: 255 }).notNull(),
+ username: varchar('username', { length: 200 }).notNull(),
+ groups: jsonb('groups').$type().notNull().default([]),
+ accessToken: text('access_token'),
+ createdAt: timestamp('created_at', { withTimezone: true })
.notNull()
- .references(() => videos.id, { onDelete: 'cascade' }),
- relatedVideoId: uuid('related_video_id')
+ .defaultNow(),
+ expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
+ },
+ (table) => [index('auth_sessions_expires_idx').on(table.expiresAt)],
+)
+
+export const memberCache = pgTable(
+ 'member_cache',
+ {
+ sub: varchar('sub', { length: 255 }).primaryKey(),
+ username: varchar('username', { length: 200 }).notNull(),
+ fullName: varchar('full_name', { length: 200 }).notNull(),
+ nickname: varchar('nickname', { length: 200 }),
+ avatarUrl: varchar('avatar_url', { length: 2048 }),
+ membershipStatus: membershipStatusEnum('membership_status').notNull(),
+ isLeadership: boolean('is_leadership').notNull().default(false),
+ joinedYear: integer('joined_year'),
+ joinedSemester: semesterEnum('joined_semester'),
+ joinedSemesterRaw: varchar('joined_semester_raw', { length: 100 }),
+ introduction: varchar('introduction', { length: 10_000 }),
+ syncStatus: memberSyncStatusEnum('sync_status').notNull().default('ok'),
+ lastSyncError: text('last_sync_error'),
+ lastSeenAt: timestamp('last_seen_at', { withTimezone: true })
.notNull()
- .references(() => videos.id, { onDelete: 'cascade' }),
+ .defaultNow(),
+ updatedAt: timestamp('updated_at', { withTimezone: true })
+ .notNull()
+ .defaultNow(),
},
- (table) => ({
- pk: primaryKey({ columns: [table.videoId, table.relatedVideoId] }),
- }),
+ (table) => [
+ uniqueIndex('member_cache_username_key').on(table.username),
+ index('member_cache_status_idx').on(table.membershipStatus),
+ ],
)
-export const tags = pgTable('tags', {
- id: uuid().primaryKey().defaultRandom(),
- name: varchar({ length: 255 }).notNull().unique(),
-})
+export const memberSyncRuns = pgTable(
+ 'member_sync_runs',
+ {
+ id: bigserial('id', { mode: 'number' }).primaryKey(),
+ trigger: memberSyncTriggerEnum('trigger').notNull(),
+ status: memberSyncStatusEnum('status').notNull(),
+ startedAt: timestamp('started_at', { withTimezone: true })
+ .notNull()
+ .defaultNow(),
+ finishedAt: timestamp('finished_at', { withTimezone: true }),
+ totalCount: integer('total_count').notNull().default(0),
+ changedCount: integer('changed_count').notNull().default(0),
+ errorCount: integer('error_count').notNull().default(0),
+ message: text('message'),
+ },
+ (table) => [index('member_sync_runs_started_idx').on(table.startedAt)],
+)
+
+export const events = pgTable(
+ 'events',
+ {
+ id: uuid().primaryKey().defaultRandom(),
+ slug: varchar('slug', { length: 200 }).notNull(),
+ title: varchar({ length: 200 }).notNull(),
+ description: varchar({ length: 10_000 }),
+ thumbnailUrl: varchar('thumbnail_url', { length: 2048 }),
+ // According to the spec, a draft only requires a title; the start date is
+ // a publication requirement (validated on the application side).
+ startDate: date('start_date'),
+ endDate: date('end_date'),
+ status: eventStatusEnum('status').notNull().default('draft'),
+ createdBy: varchar('created_by', { length: 255 }).references(
+ () => memberCache.sub,
+ ),
+ updatedBy: varchar('updated_by', { length: 255 }).references(
+ () => memberCache.sub,
+ ),
+ version: integer('version').notNull().default(1),
+ createdAt: timestamp('created_at', { withTimezone: true })
+ .notNull()
+ .defaultNow(),
+ updatedAt: timestamp('updated_at', { withTimezone: true })
+ .notNull()
+ .defaultNow(),
+ },
+ (table) => [
+ uniqueIndex('events_slug_key').on(table.slug),
+ index('events_status_start_idx').on(table.status, table.startDate),
+ check(
+ 'events_end_after_start_check',
+ sql`${table.endDate} is null or ${table.endDate} >= ${table.startDate}`,
+ ),
+ ],
+)
+
+export const videos = pgTable(
+ 'videos',
+ {
+ id: uuid().primaryKey().defaultRandom(),
+ slug: varchar('slug', { length: 200 }).notNull(),
+ title: varchar({ length: 200 }).notNull(),
+ description: varchar({ length: 10_000 }),
+ guests: varchar({ length: 5000 }),
+ songs: varchar({ length: 5000 }),
+ videoUrl: varchar('video_url', { length: 2048 }),
+ thumbnailUrl: varchar('thumbnail_url', { length: 2048 }),
+ visibility: visibilityEnum('visibility').notNull().default('public'),
+ status: contentStatusEnum('status').notNull().default('draft'),
+ eventId: uuid('event_id').references(() => events.id, {
+ onDelete: 'set null',
+ }),
+ recordedAt: date('recorded_at'),
+ publishedAt: timestamp('published_at', { withTimezone: true }),
+ viewCount: integer('view_count').notNull().default(0),
+ createdBy: varchar('created_by', { length: 255 }).references(
+ () => memberCache.sub,
+ ),
+ updatedBy: varchar('updated_by', { length: 255 }).references(
+ () => memberCache.sub,
+ ),
+ version: integer('version').notNull().default(1),
+ createdAt: timestamp('created_at', { withTimezone: true })
+ .notNull()
+ .defaultNow(),
+ updatedAt: timestamp('updated_at', { withTimezone: true })
+ .notNull()
+ .defaultNow(),
+ trashedAt: timestamp('trashed_at', { withTimezone: true }),
+ trashedBy: varchar('trashed_by', { length: 255 }).references(
+ () => memberCache.sub,
+ ),
+ },
+ (table) => [
+ uniqueIndex('videos_slug_key').on(table.slug),
+ index('videos_visibility_status_idx').on(
+ table.visibility,
+ table.status,
+ table.publishedAt,
+ ),
+ index('videos_event_idx').on(table.eventId),
+ index('videos_recorded_at_idx').on(table.recordedAt),
+ index('videos_trash_purge_idx').on(table.status, table.trashedAt),
+ check(
+ 'videos_published_requires_timestamp_check',
+ sql`${table.status} <> 'published' or ${table.publishedAt} is not null`,
+ ),
+ check(
+ 'videos_trash_needs_timestamp_check',
+ sql`${table.status} <> 'trash' or ${table.trashedAt} is not null`,
+ ),
+ ],
+)
+
+export const tags = pgTable(
+ 'tags',
+ {
+ id: uuid().primaryKey().defaultRandom(),
+ name: varchar('name', { length: 64 }).notNull(),
+ normalizedName: varchar('normalized_name', { length: 64 }).notNull(),
+ createdAt: timestamp('created_at', { withTimezone: true })
+ .notNull()
+ .defaultNow(),
+ },
+ (table) => [uniqueIndex('tags_normalized_name_key').on(table.normalizedName)],
+)
export const videoTags = pgTable(
'video_tags',
@@ -73,266 +248,178 @@ export const videoTags = pgTable(
.notNull()
.references(() => tags.id, { onDelete: 'cascade' }),
},
- (table) => ({
- pk: primaryKey({ columns: [table.videoId, table.tagId] }),
- }),
+ (table) => [
+ primaryKey({ columns: [table.videoId, table.tagId] }),
+ index('video_tags_tag_idx').on(table.tagId),
+ ],
)
-export const events = pgTable('events', {
- id: uuid().primaryKey().defaultRandom(),
- name: text().notNull(),
- description: text(),
- event_start_date: date().notNull(),
- event_end_date: date().notNull(),
- created_at: date().defaultNow(),
- updated_at: date().defaultNow(),
-})
-
-export const roles = pgTable('roles', {
- id: uuid().primaryKey().defaultRandom(),
- name: text().notNull(),
-})
+export const staffRoles = pgTable(
+ 'staff_roles',
+ {
+ id: uuid().primaryKey().defaultRandom(),
+ name: varchar('name', { length: 64 }).notNull(),
+ normalizedName: varchar('normalized_name', { length: 64 }).notNull(),
+ displayOrder: integer('display_order').notNull().default(0),
+ createdAt: timestamp('created_at', { withTimezone: true })
+ .notNull()
+ .defaultNow(),
+ },
+ (table) => [
+ uniqueIndex('staff_roles_normalized_name_key').on(table.normalizedName),
+ index('staff_roles_display_order_idx').on(table.displayOrder),
+ ],
+)
-export const videosRolesUsers = pgTable(
- 'videos_roles_users',
+export const videoStaff = pgTable(
+ 'video_staff',
{
videoId: uuid('video_id')
.notNull()
.references(() => videos.id, { onDelete: 'cascade' }),
roleId: uuid('role_id')
.notNull()
- .references(() => roles.id, { onDelete: 'cascade' }),
- userId: uuid('user_id')
+ .references(() => staffRoles.id, { onDelete: 'restrict' }),
+ memberSub: varchar('member_sub', { length: 255 })
.notNull()
- .references(() => usersTable.id, { onDelete: 'cascade' }),
+ .references(() => memberCache.sub),
},
- (table) => ({
- pk: primaryKey({
- columns: [table.videoId, table.roleId, table.userId],
- }),
- }),
+ (table) => [
+ primaryKey({ columns: [table.videoId, table.roleId, table.memberSub] }),
+ index('video_staff_member_idx').on(table.memberSub),
+ index('video_staff_role_idx').on(table.roleId),
+ ],
)
-export const eventsRolesUsers = pgTable(
- 'events_roles_users',
+export const relatedVideos = pgTable(
+ 'related_videos',
{
- eventId: uuid('event_id')
- .notNull()
- .references(() => events.id, { onDelete: 'cascade' }),
- roleId: uuid('role_id')
+ videoId: uuid('video_id')
.notNull()
- .references(() => roles.id, { onDelete: 'cascade' }),
- userId: uuid('user_id')
+ .references(() => videos.id, { onDelete: 'cascade' }),
+ relatedVideoId: uuid('related_video_id')
.notNull()
- .references(() => usersTable.id, { onDelete: 'cascade' }),
+ .references(() => videos.id, { onDelete: 'cascade' }),
+ position: integer('position').notNull(),
},
- (table) => ({
- pk: primaryKey({
- columns: [table.eventId, table.roleId, table.userId],
- }),
- }),
+ (table) => [
+ primaryKey({ columns: [table.videoId, table.relatedVideoId] }),
+ check(
+ 'related_videos_no_self_reference_check',
+ sql`${table.videoId} <> ${table.relatedVideoId}`,
+ ),
+ ],
)
-export const videosEvents = pgTable('videos_events', {
- videoId: uuid('video_id')
- .notNull()
- .references(() => videos.id, { onDelete: 'cascade' }),
- eventId: uuid('event_id')
- .notNull()
- .references(() => events.id, { onDelete: 'cascade' }),
-}, (table) => ({
- pk: primaryKey({ columns: [table.videoId, table.eventId] }),
-}))
-
-export const usersRoles = pgTable('users_roles', {
- userId: uuid('user_id')
- .notNull()
- .references(() => usersTable.id, { onDelete: 'cascade' }),
- roleId: uuid('role_id')
- .notNull()
- .references(() => roles.id, { onDelete: 'cascade' }),
-}, (table) => ({
- pk: primaryKey({ columns: [table.userId, table.roleId] }),
-}))
-
-export const usersRolesRelations = defineRelations(
- { usersTable, roles, usersRoles },
- (r) => ({
- usersTable: {
- usersRoles: r.many.usersRoles({
- from: r.usersTable.id,
- to: r.usersRoles.userId,
- }),
- },
- roles: {
- usersRoles: r.many.usersRoles({
- from: r.roles.id,
- to: r.usersRoles.roleId,
- }),
- },
- usersRoles: {
- user: r.one.usersTable({
- from: r.usersRoles.userId,
- to: r.usersTable.id,
- }),
- role: r.one.roles({
- from: r.usersRoles.roleId,
- to: r.roles.id,
- }),
- },
- }),
+export const slugHistory = pgTable(
+ 'slug_history',
+ {
+ entityType: slugEntityTypeEnum('entity_type').notNull(),
+ slug: varchar('slug', { length: 200 }).notNull(),
+ entityId: uuid('entity_id').notNull(),
+ createdAt: timestamp('created_at', { withTimezone: true })
+ .notNull()
+ .defaultNow(),
+ },
+ (table) => [
+ primaryKey({ columns: [table.entityType, table.slug] }),
+ index('slug_history_entity_idx').on(table.entityType, table.entityId),
+ ],
)
-export const eventsRolesUsersRelations = defineRelations(
- { events, roles, usersTable, eventsRolesUsers },
- (r) => ({
- events: {
- eventsRolesUsers: r.many.eventsRolesUsers({
- from: r.events.id,
- to: r.eventsRolesUsers.eventId,
- }),
- },
- roles: {
- eventsRolesUsers: r.many.eventsRolesUsers({
- from: r.roles.id,
- to: r.eventsRolesUsers.roleId,
- }),
- },
- usersTable: {
- eventsRolesUsers: r.many.eventsRolesUsers({
- from: r.usersTable.id,
- to: r.eventsRolesUsers.userId,
- }),
- },
- eventsRolesUsers: {
- event: r.one.events({
- from: r.eventsRolesUsers.eventId,
- to: r.events.id,
- }),
- role: r.one.roles({
- from: r.eventsRolesUsers.roleId,
- to: r.roles.id,
- }),
- user: r.one.usersTable({
- from: r.eventsRolesUsers.userId,
- to: r.usersTable.id,
- }),
- },
- }),
+export const liveStreams = pgTable(
+ 'live_streams',
+ {
+ id: uuid().primaryKey().defaultRandom(),
+ youtubeVideoId: varchar('youtube_video_id', { length: 64 }).notNull(),
+ startsAt: timestamp('starts_at', { withTimezone: true }).notNull(),
+ endsAt: timestamp('ends_at', { withTimezone: true }).notNull(),
+ status: liveStatusEnum('status').notNull().default('scheduled'),
+ activationError: text('activation_error'),
+ activatedAt: timestamp('activated_at', { withTimezone: true }),
+ endedAt: timestamp('ended_at', { withTimezone: true }),
+ createdBy: varchar('created_by', { length: 255 }).references(
+ () => memberCache.sub,
+ ),
+ createdAt: timestamp('created_at', { withTimezone: true })
+ .notNull()
+ .defaultNow(),
+ updatedAt: timestamp('updated_at', { withTimezone: true })
+ .notNull()
+ .defaultNow(),
+ },
+ (table) => [
+ index('live_streams_status_starts_idx').on(table.status, table.startsAt),
+ check(
+ 'live_streams_end_after_start_check',
+ sql`${table.endsAt} > ${table.startsAt}`,
+ ),
+ ],
)
-export const videosEventsRelations = defineRelations(
- { videos, events, videosEvents },
- (r) => ({
- videos: {
- videosEvents: r.many.videosEvents({
- from: r.videos.id,
- to: r.videosEvents.videoId,
- }),
- },
- events: {
- videosEvents: r.many.videosEvents({
- from: r.events.id,
- to: r.videosEvents.eventId,
- }),
- },
- videosEvents: {
- video: r.one.videos({
- from: r.videosEvents.videoId,
- to: r.videos.id,
- }),
- event: r.one.events({
- from: r.videosEvents.eventId,
- to: r.events.id,
- }),
- },
+export const siteSettings = pgTable('site_settings', {
+ id: integer().primaryKey().default(0),
+ highlightedVideoId: uuid('highlighted_video_id').references(() => videos.id, {
+ onDelete: 'set null',
}),
-)
+ updatedAt: timestamp('updated_at', { withTimezone: true })
+ .notNull()
+ .defaultNow(),
+})
-export const videosRolesUsersRelations = defineRelations(
- { videos, roles, usersTable, videosRolesUsers },
- (r) => ({
- videos: {
- videosRolesUsers: r.many.videosRolesUsers({
- from: r.videos.id,
- to: r.videosRolesUsers.videoId,
- }),
- },
- roles: {
- videosRolesUsers: r.many.videosRolesUsers({
- from: r.roles.id,
- to: r.videosRolesUsers.roleId,
- }),
- },
- usersTable: {
- videosRolesUsers: r.many.videosRolesUsers({
- from: r.usersTable.id,
- to: r.videosRolesUsers.userId,
- }),
- },
- videosRolesUsers: {
- video: r.one.videos({
- from: r.videosRolesUsers.videoId,
- to: r.videos.id,
- }),
- role: r.one.roles({
- from: r.videosRolesUsers.roleId,
- to: r.roles.id,
- }),
- user: r.one.usersTable({
- from: r.videosRolesUsers.userId,
- to: r.usersTable.id,
- }),
- },
- }),
+export const aboutPageVideos = pgTable(
+ 'about_page_videos',
+ {
+ position: integer('position').notNull(),
+ videoId: uuid('video_id')
+ .notNull()
+ .references(() => videos.id, { onDelete: 'cascade' }),
+ },
+ (table) => [
+ primaryKey({ columns: [table.position, table.videoId] }),
+ uniqueIndex('about_page_videos_video_key').on(table.videoId),
+ check(
+ 'about_page_videos_position_range_check',
+ sql`${table.position} >= 1 and ${table.position} <= 6`,
+ ),
+ ],
)
-export const videoTagRelations = defineRelations(
- { videos, tags, videoTags },
- (r) => ({
- videos: {
- videoTags: r.many.videoTags({
- from: r.videos.id,
- to: r.videoTags.videoId,
- }),
- },
- tags: {
- videoTags: r.many.videoTags({
- from: r.tags.id,
- to: r.videoTags.tagId,
- }),
- },
- videoTags: {
- video: r.one.videos({
- from: r.videoTags.videoId,
- to: r.videos.id,
- }),
- tag: r.one.tags({
- from: r.videoTags.tagId,
- to: r.tags.id,
- }),
- },
- }),
+export const auditLog = pgTable(
+ 'audit_log',
+ {
+ id: bigserial('id', { mode: 'number' }).primaryKey(),
+ actor: varchar('actor', { length: 255 }).notNull(),
+ entityType: varchar('entity_type', { length: 50 }).notNull(),
+ entityId: varchar('entity_id', { length: 255 }).notNull(),
+ action: varchar('action', { length: 50 }).notNull(),
+ beforeValue: jsonb('before_value'),
+ afterValue: jsonb('after_value'),
+ occurredAt: timestamp('occurred_at', { withTimezone: true })
+ .notNull()
+ .defaultNow(),
+ },
+ (table) => [
+ index('audit_log_occurred_idx').on(table.occurredAt),
+ index('audit_log_entity_idx').on(table.entityType, table.entityId),
+ index('audit_log_actor_idx').on(table.actor),
+ index('audit_log_action_idx').on(table.action),
+ ],
)
-export const videosRelatedVideosRelations = defineRelations(
- { videos, relatedVideos },
- (r) => ({
- videos: {
- relatedVideos: r.many.relatedVideos({
- from: r.videos.id,
- to: r.relatedVideos.videoId,
- }),
- },
- relatedVideos: {
- video: r.one.videos({
- from: r.relatedVideos.videoId,
- to: r.videos.id,
- }),
- relatedVideo: r.one.videos({
- from: r.relatedVideos.relatedVideoId,
- to: r.videos.id,
- }),
- },
- }),
+export const viewSessions = pgTable(
+ 'view_sessions',
+ {
+ videoId: uuid('video_id')
+ .notNull()
+ .references(() => videos.id, { onDelete: 'cascade' }),
+ sessionId: varchar('session_id', { length: 128 }).notNull(),
+ viewedAt: timestamp('viewed_at', { withTimezone: true })
+ .notNull()
+ .defaultNow(),
+ },
+ (table) => [
+ primaryKey({ columns: [table.videoId, table.sessionId] }),
+ index('view_sessions_viewed_idx').on(table.viewedAt),
+ ],
)
diff --git a/src/lib/activity.ts b/src/lib/activity.ts
new file mode 100644
index 0000000..bda25a9
--- /dev/null
+++ b/src/lib/activity.ts
@@ -0,0 +1,73 @@
+export interface ActivityRow {
+ videoId: string
+ slug: string
+ title: string
+ /** Calendar date (`YYYY-MM-DD` or null); sorting: descending, missing values last. */
+ recordedAt: string | null
+ year: number | null
+ roles: string[]
+}
+
+export interface YearGroup {
+ year: number
+ groups: Array<{ roleName: string; videos: Array }>
+}
+
+export interface RoleGroup {
+ roleName: string
+ videos: Array
+}
+
+/**
+ * Activity grouping (spec 8.4):
+ * - year view: years in descending order, with staff-role groups below;
+ * - role view: videos listed chronologically under each role.
+ * With multiple roles, the same video appears in every affected group.
+ */
+export function groupActivity(
+ rows: Array,
+ view: 'year' | 'role',
+): { yearGroups: Array; roleGroups: Array } {
+ const rolesOf = (row: ActivityRow): string[] =>
+ row.roles.length > 0 ? row.roles : ['Stábtag']
+
+ if (view === 'role') {
+ const byRole = new Map()
+ for (const row of rows) {
+ for (const roleName of rolesOf(row)) {
+ let group = byRole.get(roleName)
+ if (group === undefined) {
+ group = { roleName, videos: [] }
+ byRole.set(roleName, group)
+ }
+ group.videos.push(row)
+ }
+ }
+ return { yearGroups: [], roleGroups: [...byRole.values()] }
+ }
+
+ const byYear = new Map<
+ number,
+ Map }>
+ >()
+ for (const row of rows) {
+ const year = row.year ?? 0
+ let rolesMap = byYear.get(year)
+ if (rolesMap === undefined) {
+ rolesMap = new Map()
+ byYear.set(year, rolesMap)
+ }
+ for (const roleName of rolesOf(row)) {
+ let group = rolesMap.get(roleName)
+ if (group === undefined) {
+ group = { roleName, videos: [] }
+ rolesMap.set(roleName, group)
+ }
+ group.videos.push(row)
+ }
+ }
+ const yearGroups: Array = [...byYear.entries()]
+ .sort((a, b) => b[0] - a[0])
+ .map(([year, rolesMap]) => ({ year, groups: [...rolesMap.values()] }))
+ return { yearGroups, roleGroups: [] }
+}
diff --git a/src/lib/admin-api.ts b/src/lib/admin-api.ts
new file mode 100644
index 0000000..49ff7eb
--- /dev/null
+++ b/src/lib/admin-api.ts
@@ -0,0 +1,80 @@
+/**
+ * Admin client-side request helper (BSS-028). It turns the server's JSON
+ * responses into a discriminated result: on a 401 (expired session) it
+ * returns the loginUrl so the client can preserve the form data and
+ * resubmit it after a new login (spec 12.4).
+ */
+
+export interface ApiError {
+ status: number
+ code:
+ | 'auth_required'
+ | 'forbidden'
+ | 'conflict'
+ | 'validation'
+ | 'confirmation'
+ | 'name_conflict'
+ | 'overlap'
+ | 'role_in_use'
+ | 'not_found'
+ | 'bad_request'
+ | 'internal'
+ message: string
+ problems?: string[]
+ loginUrl?: string
+}
+
+export type ApiResult =
+ { ok: true; data: T } | { ok: false; error: ApiError }
+
+export async function postJson(
+ url: string,
+ body: unknown,
+): Promise> {
+ let response: Response
+ try {
+ response = await fetch(url, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify(body),
+ })
+ } catch {
+ return {
+ ok: false,
+ error: {
+ status: 0,
+ code: 'internal',
+ message: 'A kérés nem érte el a szervert. Ellenőrizd a kapcsolatot.',
+ },
+ }
+ }
+
+ let payload: Record = {}
+ try {
+ payload = (await response.json()) as Record
+ } catch {
+ // Not a JSON response (e.g. a proxy error page)
+ }
+
+ if (!response.ok) {
+ return {
+ ok: false,
+ error: {
+ status: response.status,
+ code: payload['error'] as ApiError['code'],
+ message:
+ typeof payload['message'] === 'string'
+ ? payload['message']
+ : 'Váratlan hiba történt.',
+ problems: Array.isArray(payload['problems'])
+ ? (payload['problems'] as string[])
+ : undefined,
+ loginUrl:
+ typeof payload['loginUrl'] === 'string'
+ ? payload['loginUrl']
+ : undefined,
+ },
+ }
+ }
+ return { ok: true, data: payload as T }
+}
diff --git a/src/lib/admin-labels.ts b/src/lib/admin-labels.ts
new file mode 100644
index 0000000..efb4ada
--- /dev/null
+++ b/src/lib/admin-labels.ts
@@ -0,0 +1,56 @@
+/** Hungarian labels for admin statuses and visibilities (spec 4.1). */
+
+export const VIDEO_STATUS_LABELS: Record = {
+ draft: 'Piszkozat',
+ published: 'Publikált',
+ archived: 'Archivált',
+ trash: 'Lomtár',
+}
+
+export const EVENT_STATUS_LABELS: Record = {
+ draft: 'Piszkozat',
+ published: 'Publikált',
+ archived: 'Archivált',
+}
+
+export const VISIBILITY_LABELS: Record = {
+ public: 'Nyilvános',
+ schonherz: 'Schönherz',
+ bss: 'BSS-tag',
+}
+
+export const MEMBERSHIP_STATUS_LABELS: Record = {
+ studio_member: 'Stúdiós',
+ studio_candidate: 'Stúdiósjelölt',
+ studio_applicant: 'Stúdiósjelölt-jelölt',
+ senior_active: 'Aktív öregtag',
+ senior_archived: 'Archivált öregtag',
+ contributor: 'Dolgozott még velünk',
+}
+
+/**
+ * Dropdown items built from the labels, in display order. This way the filters
+ * and the editor get the Hungarian labels from the same single source.
+ */
+function toOptions(
+ labels: Record,
+): Array<{ value: string; label: string }> {
+ return Object.entries(labels).map(([value, label]) => ({ value, label }))
+}
+
+export const VIDEO_STATUS_OPTIONS = toOptions(VIDEO_STATUS_LABELS)
+export const EVENT_STATUS_OPTIONS = toOptions(EVENT_STATUS_LABELS)
+export const VISIBILITY_OPTIONS = toOptions(VISIBILITY_LABELS)
+export const MEMBERSHIP_STATUS_OPTIONS = toOptions(MEMBERSHIP_STATUS_LABELS)
+
+export function videoStatusLabel(status: string): string {
+ return VIDEO_STATUS_LABELS[status] ?? status
+}
+
+export function eventStatusLabel(status: string): string {
+ return EVENT_STATUS_LABELS[status] ?? status
+}
+
+export function visibilityLabel(visibility: string): string {
+ return VISIBILITY_LABELS[visibility] ?? visibility
+}
diff --git a/src/lib/clock.ts b/src/lib/clock.ts
new file mode 100644
index 0000000..acb6a8d
--- /dev/null
+++ b/src/lib/clock.ts
@@ -0,0 +1,43 @@
+export interface Clock {
+ now: () => Date
+}
+
+export class SystemClock implements Clock {
+ now(): Date {
+ return new Date()
+ }
+}
+
+export class FakeClock implements Clock {
+ private current: Date
+
+ constructor(initial?: Date | string | number) {
+ this.current = new Date(initial ?? Date.now())
+ }
+
+ now(): Date {
+ return new Date(this.current)
+ }
+
+ set(instant: Date | string | number): void {
+ this.current = new Date(instant)
+ }
+
+ advanceMilliseconds(milliseconds: number): void {
+ this.current = new Date(this.current.getTime() + milliseconds)
+ }
+
+ advanceMinutes(minutes: number): void {
+ this.advanceMilliseconds(minutes * 60_000)
+ }
+
+ advanceHours(hours: number): void {
+ this.advanceMilliseconds(hours * 3_600_000)
+ }
+
+ advanceDays(days: number): void {
+ this.advanceMilliseconds(days * 86_400_000)
+ }
+}
+
+export const systemClock: Clock = new SystemClock()
diff --git a/src/lib/format-date.ts b/src/lib/format-date.ts
new file mode 100644
index 0000000..0de0450
--- /dev/null
+++ b/src/lib/format-date.ts
@@ -0,0 +1,81 @@
+/** Public date format (spec 4.4): `2026. június 6.` — according to Europe/Budapest. */
+export function formatDateHu(date: Date): string {
+ const parts = new Intl.DateTimeFormat('hu-HU', {
+ timeZone: 'Europe/Budapest',
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ }).formatToParts(date)
+ const value = (type: string) =>
+ parts.find((part) => part.type === type)?.value ?? ''
+ return `${value('year')}. ${value('month')} ${Number(value('day'))}.`
+}
+
+/** Admin and audit format (spec 4.4): `2026. június 6. 14:32` — Europe/Budapest. */
+export function formatAdminDateTimeHu(date: Date): string {
+ const parts = new Intl.DateTimeFormat('hu-HU', {
+ timeZone: 'Europe/Budapest',
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ hour12: false,
+ }).formatToParts(date)
+ const value = (type: string) =>
+ parts.find((part) => part.type === type)?.value ?? ''
+ return `${value('year')}. ${value('month')} ${Number(value('day'))}. ${value('hour')}:${value('minute')}`
+}
+
+const MONTHS = [
+ 'január',
+ 'február',
+ 'március',
+ 'április',
+ 'május',
+ 'június',
+ 'július',
+ 'augusztus',
+ 'szeptember',
+ 'október',
+ 'november',
+ 'december',
+]
+
+/**
+ * Displaying a calendar date (timezone-less `YYYY-MM-DD`).
+ * The UTC fields are handled separately so that no offset is introduced.
+ */
+export function formatCalendarDateHu(isoDate: string): string {
+ const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(isoDate)
+ if (match === null) {
+ return isoDate
+ }
+ const year = Number(match[1])
+ const month = Number(match[2])
+ const day = Number(match[3])
+ const monthName = MONTHS[month - 1] ?? ''
+ return `${year}. ${monthName} ${day}.`
+}
+
+/** Event interval (spec 4.4): `2026. június 6-8.` or a plain date for a single-day event. */
+export function formatEventIntervalHu(
+ startDate: string,
+ endDate: string | null,
+): string {
+ if (endDate === null || endDate === '' || endDate === startDate) {
+ return formatCalendarDateHu(startDate)
+ }
+ const startMatch = /^(\d{4})-(\d{2})-(\d{2})$/.exec(startDate)
+ const endMatch = /^(\d{4})-(\d{2})-(\d{2})$/.exec(endDate)
+ if (
+ startMatch !== null &&
+ endMatch !== null &&
+ startMatch[1] === endMatch[1] &&
+ startMatch[2] === endMatch[2]
+ ) {
+ const monthName = MONTHS[Number(startMatch[2]) - 1] ?? ''
+ return `${startMatch[1]}. ${monthName} ${Number(startMatch[3])}-${Number(endMatch[3])}.`
+ }
+ return `${formatCalendarDateHu(startDate)} – ${formatCalendarDateHu(endDate)}`
+}
diff --git a/src/lib/media-url.ts b/src/lib/media-url.ts
new file mode 100644
index 0000000..f5b8a2e
--- /dev/null
+++ b/src/lib/media-url.ts
@@ -0,0 +1,56 @@
+/**
+ * Client-side media URL validation for pre-save warnings
+ * (spec 5.4). Actual enforcement stays server-side
+ * (`src/server/media/validator.ts`); this is only early feedback in
+ * the editor so that a bad host doesn't surface only after saving.
+ */
+
+/** If the OOB config is not available to the client, this host is the fallback. */
+export const DEFAULT_MEDIA_HOSTS = ['v.bsstudio.hu'] as const
+
+function parseUrl(rawUrl: string): URL | null {
+ try {
+ return new URL(rawUrl)
+ } catch {
+ return null
+ }
+}
+
+/**
+ * Validation warning for the field, or `null` if the URL is fine.
+ * An empty value is not an error: the video can be saved as a draft
+ * even without a URL.
+ */
+export function mediaUrlWarning(
+ label: string,
+ rawUrl: string,
+ allowedHosts: readonly string[],
+): string | null {
+ const trimmed = rawUrl.trim()
+ if (trimmed === '') {
+ return null
+ }
+ const url = parseUrl(trimmed)
+ if (url === null) {
+ return `${label}: a megadott URL érvénytelen.`
+ }
+ if (url.protocol !== 'https:') {
+ return `${label}: csak https:// URL adható meg.`
+ }
+ const hosts = allowedHosts.length > 0 ? allowedHosts : DEFAULT_MEDIA_HOSTS
+ if (!hosts.includes(url.hostname)) {
+ return `${label}: a(z) „${url.hostname}" host nem engedélyezett, csak ${hosts.join(', ')}.`
+ }
+ return null
+}
+
+/** Validation of both media fields in the editor, returned as a list. */
+export function mediaUrlWarnings(
+ fields: { videoUrl: string; thumbnailUrl: string },
+ allowedHosts: readonly string[],
+): string[] {
+ return [
+ mediaUrlWarning('MP4 URL', fields.videoUrl, allowedHosts),
+ mediaUrlWarning('Thumbnail URL', fields.thumbnailUrl, allowedHosts),
+ ].filter((warning): warning is string => warning !== null)
+}
diff --git a/src/lib/song-list.ts b/src/lib/song-list.ts
new file mode 100644
index 0000000..e42b527
--- /dev/null
+++ b/src/lib/song-list.ts
@@ -0,0 +1,85 @@
+/**
+ * Structured handling of the "Used songs" field. In storage it remains a
+ * single text, one `Artist - Song title` per line (spec 5.2), while the
+ * editor splits it into two input fields when the existing content is
+ * interpretable.
+ */
+
+export interface SongEntry {
+ artist: string
+ title: string
+}
+
+/** Dash variants: these are line separators, so they cannot appear within a field. */
+const DASHES = /[-–—]/
+const DASHES_GLOBAL = /[-–—]/g
+/** Whitespace-surrounded dash: this is the normal separator. */
+const SPACED_DASH = /\s+[-–—]\s+/g
+
+function splitLine(line: string): SongEntry | null {
+ const spaced = [...line.matchAll(SPACED_DASH)]
+ if (spaced.length === 1) {
+ const match = spaced[0]
+ const artist = line.slice(0, match.index).trim()
+ const title = line.slice(match.index + match[0].length).trim()
+ return artist === '' || title === '' ? null : { artist, title }
+ }
+ if (spaced.length > 1) {
+ // Multiple separators: it can't be determined which one is the boundary.
+ return null
+ }
+ // A single dash without surrounding whitespace (e.g. "Artist-Title").
+ const dashCount = (line.match(DASHES_GLOBAL) ?? []).length
+ if (dashCount !== 1) {
+ return null
+ }
+ const index = line.search(DASHES)
+ const artist = line.slice(0, index).trim()
+ const title = line.slice(index + 1).trim()
+ return artist === '' || title === '' ? null : { artist, title }
+}
+
+/**
+ * Interprets lines into artist/title pairs. Returns `null` if any line is
+ * not interpretable — in that case the editor falls back to the free-text
+ * field so that the existing content isn't damaged.
+ */
+export function parseSongList(raw: string): Array | null {
+ const lines = raw
+ .split('\n')
+ .map((line) => line.trim())
+ .filter((line) => line !== '')
+ const entries: Array = []
+ for (const line of lines) {
+ const entry = splitLine(line)
+ if (entry === null) {
+ return null
+ }
+ entries.push(entry)
+ }
+ return entries
+}
+
+/**
+ * Serializes back into the stored text form. Completely empty rows are
+ * omitted, and a half-filled row never leaks a separating dash.
+ */
+export function serializeSongList(entries: ReadonlyArray): string {
+ return entries
+ .map((entry) =>
+ [entry.artist.trim(), entry.title.trim()].filter((part) => part !== ''),
+ )
+ .filter((parts) => parts.length > 0)
+ .map((parts) => parts.join(' - '))
+ .join('\n')
+}
+
+/** Removes dashes: in structured mode these are the separators. */
+export function stripSongDashes(value: string): string {
+ return value.replace(DASHES_GLOBAL, '')
+}
+
+/** True if a character forbidden in structured mode got into the field. */
+export function hasSongDash(value: string): boolean {
+ return DASHES.test(value)
+}
diff --git a/src/lib/text-search.ts b/src/lib/text-search.ts
new file mode 100644
index 0000000..a628f20
--- /dev/null
+++ b/src/lib/text-search.ts
@@ -0,0 +1,25 @@
+/**
+ * Client-side list filtering based on text. Accent-insensitive, so that
+ * Hungarian names ("Schönherz") are also found when typed without accents.
+ */
+
+export function normalizeForSearch(value: string): string {
+ return value
+ .normalize('NFD')
+ .replace(/\p{Diacritic}/gu, '')
+ .toLocaleLowerCase('hu-HU')
+ .trim()
+}
+
+/**
+ * True if every word of the query appears in the label (also as a partial
+ * word). An empty search matches everything.
+ */
+export function matchesSearch(label: string, query: string): boolean {
+ const words = normalizeForSearch(query).split(/\s+/).filter(Boolean)
+ if (words.length === 0) {
+ return true
+ }
+ const haystack = normalizeForSearch(label)
+ return words.every((word) => haystack.includes(word))
+}
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
index bd0c391..31e8033 100644
--- a/src/lib/utils.ts
+++ b/src/lib/utils.ts
@@ -1,5 +1,6 @@
-import { clsx, type ClassValue } from "clsx"
-import { twMerge } from "tailwind-merge"
+import { clsx } from 'clsx'
+import type { ClassValue } from 'clsx'
+import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
diff --git a/src/lib/youtube-url.ts b/src/lib/youtube-url.ts
new file mode 100644
index 0000000..ba5ab54
--- /dev/null
+++ b/src/lib/youtube-url.ts
@@ -0,0 +1,62 @@
+/**
+ * YouTube URL parsing. Needed on both the client and the server (at live
+ * scheduling the editor already flags a bad URL while typing), which is why
+ * it lives here; the network oEmbed check stays in the server-side module.
+ */
+
+/**
+ * YouTube URL normalization (spec 9.3). Accepted forms:
+ * youtube.com/watch?v=ID, youtube.com/live/ID, youtu.be/ID, embed/ID,
+ * and youtube-nocookie.com variants. The result is always a video ID.
+ */
+export function normalizeYoutubeVideoId(rawUrl: string): string | null {
+ try {
+ const url = new URL(rawUrl)
+ const hostname = url.hostname.replace(/^www\./, '')
+ const isYoutube =
+ hostname === 'youtube.com' ||
+ hostname === 'm.youtube.com' ||
+ hostname === 'youtube-nocookie.com'
+ const isShort = hostname === 'youtu.be'
+
+ if (isShort) {
+ const parts = url.pathname.split('/').filter(Boolean)
+ if (parts.length === 0) {
+ return null
+ }
+ return parts[0]
+ }
+ if (!isYoutube) {
+ return null
+ }
+ if (url.pathname === '/watch') {
+ const v = url.searchParams.get('v')
+ if (v !== null && /^[A-Za-z0-9_-]{6,20}$/.test(v)) {
+ return v
+ }
+ return null
+ }
+ const parts = url.pathname.split('/').filter(Boolean)
+ for (const segment of ['live', 'embed', 'shorts']) {
+ if (parts[0] === segment) {
+ const id = parts.at(1)
+ return id !== undefined && /^[A-Za-z0-9_-]{6,20}$/.test(id) ? id : null
+ }
+ }
+ return null
+ } catch {
+ return null
+ }
+}
+
+/** Validation warning for the live form, or `null` if the URL is fine. */
+export function youtubeUrlWarning(rawUrl: string): string | null {
+ const trimmed = rawUrl.trim()
+ if (trimmed === '') {
+ return null
+ }
+ if (normalizeYoutubeVideoId(trimmed) === null) {
+ return 'A YouTube URL nem értelmezhető: watch?v=, live/, embed/, shorts/ vagy youtu.be/ alak szükséges.'
+ }
+ return null
+}
diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts
index 9ea2c62..edbf213 100644
--- a/src/routeTree.gen.ts
+++ b/src/routeTree.gen.ts
@@ -9,19 +9,36 @@
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
-import { Route as CoursesRouteImport } from './routes/courses'
-import { Route as AboutRouteImport } from './routes/about'
import { Route as IndexRouteImport } from './routes/index'
-import { Route as VideosIndexRouteImport } from './routes/videos/index'
-import { Route as MembersIndexRouteImport } from './routes/members/index'
+import { Route as AboutRouteImport } from './routes/about'
+import { Route as AdminRouteImport } from './routes/admin'
+import { Route as CoursesRouteImport } from './routes/courses'
+import { Route as SearchRouteImport } from './routes/search'
+import { Route as AdminIndexRouteImport } from './routes/admin/index'
+import { Route as AdminAuditRouteImport } from './routes/admin/audit'
+import { Route as AdminHomepageRouteImport } from './routes/admin/homepage'
+import { Route as AdminMembersRouteImport } from './routes/admin/members'
+import { Route as AdminTrashRouteImport } from './routes/admin/trash'
import { Route as EventsIndexRouteImport } from './routes/events/index'
-import { Route as VideosVideoIdRouteImport } from './routes/videos/$videoId'
-import { Route as MembersMemberIdRouteImport } from './routes/members/$memberId'
-import { Route as DemoTanstackQueryRouteImport } from './routes/demo/tanstack-query'
+import { Route as EventsSlugRouteImport } from './routes/events/$slug'
+import { Route as MembersIndexRouteImport } from './routes/members/index'
+import { Route as MembersSlugRouteImport } from './routes/members/$slug'
+import { Route as MembersArchivedRouteImport } from './routes/members/archived'
+import { Route as MembersContributorsRouteImport } from './routes/members/contributors'
+import { Route as VideosIndexRouteImport } from './routes/videos/index'
+import { Route as VideosSlugRouteImport } from './routes/videos/$slug'
+import { Route as AdminCatalogStaffRolesRouteImport } from './routes/admin/catalog/staff-roles'
+import { Route as AdminCatalogTagsRouteImport } from './routes/admin/catalog/tags'
+import { Route as AdminEventsIndexRouteImport } from './routes/admin/events/index'
+import { Route as AdminEventsIdRouteImport } from './routes/admin/events/$id'
+import { Route as AdminEventsNewRouteImport } from './routes/admin/events/new'
+import { Route as AdminVideosIndexRouteImport } from './routes/admin/videos/index'
+import { Route as AdminVideosIdRouteImport } from './routes/admin/videos/$id'
+import { Route as AdminVideosNewRouteImport } from './routes/admin/videos/new'
-const CoursesRoute = CoursesRouteImport.update({
- id: '/courses',
- path: '/courses',
+const IndexRoute = IndexRouteImport.update({
+ id: '/',
+ path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const AboutRoute = AboutRouteImport.update({
@@ -29,14 +46,54 @@ const AboutRoute = AboutRouteImport.update({
path: '/about',
getParentRoute: () => rootRouteImport,
} as any)
-const IndexRoute = IndexRouteImport.update({
+const AdminRoute = AdminRouteImport.update({
+ id: '/admin',
+ path: '/admin',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const CoursesRoute = CoursesRouteImport.update({
+ id: '/courses',
+ path: '/courses',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const SearchRoute = SearchRouteImport.update({
+ id: '/search',
+ path: '/search',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const AdminIndexRoute = AdminIndexRouteImport.update({
id: '/',
path: '/',
+ getParentRoute: () => AdminRoute,
+} as any)
+const AdminAuditRoute = AdminAuditRouteImport.update({
+ id: '/audit',
+ path: '/audit',
+ getParentRoute: () => AdminRoute,
+} as any)
+const AdminHomepageRoute = AdminHomepageRouteImport.update({
+ id: '/homepage',
+ path: '/homepage',
+ getParentRoute: () => AdminRoute,
+} as any)
+const AdminMembersRoute = AdminMembersRouteImport.update({
+ id: '/members',
+ path: '/members',
+ getParentRoute: () => AdminRoute,
+} as any)
+const AdminTrashRoute = AdminTrashRouteImport.update({
+ id: '/trash',
+ path: '/trash',
+ getParentRoute: () => AdminRoute,
+} as any)
+const EventsIndexRoute = EventsIndexRouteImport.update({
+ id: '/events/',
+ path: '/events/',
getParentRoute: () => rootRouteImport,
} as any)
-const VideosIndexRoute = VideosIndexRouteImport.update({
- id: '/videos/',
- path: '/videos/',
+const EventsSlugRoute = EventsSlugRouteImport.update({
+ id: '/events/$slug',
+ path: '/events/$slug',
getParentRoute: () => rootRouteImport,
} as any)
const MembersIndexRoute = MembersIndexRouteImport.update({
@@ -44,104 +101,253 @@ const MembersIndexRoute = MembersIndexRouteImport.update({
path: '/members/',
getParentRoute: () => rootRouteImport,
} as any)
-const EventsIndexRoute = EventsIndexRouteImport.update({
- id: '/events/',
- path: '/events/',
+const MembersSlugRoute = MembersSlugRouteImport.update({
+ id: '/members/$slug',
+ path: '/members/$slug',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const MembersArchivedRoute = MembersArchivedRouteImport.update({
+ id: '/members/archived',
+ path: '/members/archived',
getParentRoute: () => rootRouteImport,
} as any)
-const VideosVideoIdRoute = VideosVideoIdRouteImport.update({
- id: '/videos/$videoId',
- path: '/videos/$videoId',
+const MembersContributorsRoute = MembersContributorsRouteImport.update({
+ id: '/members/contributors',
+ path: '/members/contributors',
getParentRoute: () => rootRouteImport,
} as any)
-const MembersMemberIdRoute = MembersMemberIdRouteImport.update({
- id: '/members/$memberId',
- path: '/members/$memberId',
+const VideosIndexRoute = VideosIndexRouteImport.update({
+ id: '/videos/',
+ path: '/videos/',
getParentRoute: () => rootRouteImport,
} as any)
-const DemoTanstackQueryRoute = DemoTanstackQueryRouteImport.update({
- id: '/demo/tanstack-query',
- path: '/demo/tanstack-query',
+const VideosSlugRoute = VideosSlugRouteImport.update({
+ id: '/videos/$slug',
+ path: '/videos/$slug',
getParentRoute: () => rootRouteImport,
} as any)
+const AdminCatalogStaffRolesRoute = AdminCatalogStaffRolesRouteImport.update({
+ id: '/catalog/staff-roles',
+ path: '/catalog/staff-roles',
+ getParentRoute: () => AdminRoute,
+} as any)
+const AdminCatalogTagsRoute = AdminCatalogTagsRouteImport.update({
+ id: '/catalog/tags',
+ path: '/catalog/tags',
+ getParentRoute: () => AdminRoute,
+} as any)
+const AdminEventsIndexRoute = AdminEventsIndexRouteImport.update({
+ id: '/events/',
+ path: '/events/',
+ getParentRoute: () => AdminRoute,
+} as any)
+const AdminEventsIdRoute = AdminEventsIdRouteImport.update({
+ id: '/events/$id',
+ path: '/events/$id',
+ getParentRoute: () => AdminRoute,
+} as any)
+const AdminEventsNewRoute = AdminEventsNewRouteImport.update({
+ id: '/events/new',
+ path: '/events/new',
+ getParentRoute: () => AdminRoute,
+} as any)
+const AdminVideosIndexRoute = AdminVideosIndexRouteImport.update({
+ id: '/videos/',
+ path: '/videos/',
+ getParentRoute: () => AdminRoute,
+} as any)
+const AdminVideosIdRoute = AdminVideosIdRouteImport.update({
+ id: '/videos/$id',
+ path: '/videos/$id',
+ getParentRoute: () => AdminRoute,
+} as any)
+const AdminVideosNewRoute = AdminVideosNewRouteImport.update({
+ id: '/videos/new',
+ path: '/videos/new',
+ getParentRoute: () => AdminRoute,
+} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/about': typeof AboutRoute
+ '/admin': typeof AdminRouteWithChildren
'/courses': typeof CoursesRoute
- '/demo/tanstack-query': typeof DemoTanstackQueryRoute
- '/members/$memberId': typeof MembersMemberIdRoute
- '/videos/$videoId': typeof VideosVideoIdRoute
+ '/search': typeof SearchRoute
+ '/admin/audit': typeof AdminAuditRoute
+ '/admin/homepage': typeof AdminHomepageRoute
+ '/admin/members': typeof AdminMembersRoute
+ '/admin/trash': typeof AdminTrashRoute
+ '/events/$slug': typeof EventsSlugRoute
+ '/members/$slug': typeof MembersSlugRoute
+ '/members/archived': typeof MembersArchivedRoute
+ '/members/contributors': typeof MembersContributorsRoute
+ '/videos/$slug': typeof VideosSlugRoute
+ '/admin/': typeof AdminIndexRoute
'/events/': typeof EventsIndexRoute
'/members/': typeof MembersIndexRoute
'/videos/': typeof VideosIndexRoute
+ '/admin/catalog/staff-roles': typeof AdminCatalogStaffRolesRoute
+ '/admin/catalog/tags': typeof AdminCatalogTagsRoute
+ '/admin/events/$id': typeof AdminEventsIdRoute
+ '/admin/events/new': typeof AdminEventsNewRoute
+ '/admin/videos/$id': typeof AdminVideosIdRoute
+ '/admin/videos/new': typeof AdminVideosNewRoute
+ '/admin/events/': typeof AdminEventsIndexRoute
+ '/admin/videos/': typeof AdminVideosIndexRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/about': typeof AboutRoute
'/courses': typeof CoursesRoute
- '/demo/tanstack-query': typeof DemoTanstackQueryRoute
- '/members/$memberId': typeof MembersMemberIdRoute
- '/videos/$videoId': typeof VideosVideoIdRoute
+ '/search': typeof SearchRoute
+ '/admin/audit': typeof AdminAuditRoute
+ '/admin/homepage': typeof AdminHomepageRoute
+ '/admin/members': typeof AdminMembersRoute
+ '/admin/trash': typeof AdminTrashRoute
+ '/events/$slug': typeof EventsSlugRoute
+ '/members/$slug': typeof MembersSlugRoute
+ '/members/archived': typeof MembersArchivedRoute
+ '/members/contributors': typeof MembersContributorsRoute
+ '/videos/$slug': typeof VideosSlugRoute
+ '/admin': typeof AdminIndexRoute
'/events': typeof EventsIndexRoute
'/members': typeof MembersIndexRoute
'/videos': typeof VideosIndexRoute
+ '/admin/catalog/staff-roles': typeof AdminCatalogStaffRolesRoute
+ '/admin/catalog/tags': typeof AdminCatalogTagsRoute
+ '/admin/events/$id': typeof AdminEventsIdRoute
+ '/admin/events/new': typeof AdminEventsNewRoute
+ '/admin/videos/$id': typeof AdminVideosIdRoute
+ '/admin/videos/new': typeof AdminVideosNewRoute
+ '/admin/events': typeof AdminEventsIndexRoute
+ '/admin/videos': typeof AdminVideosIndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/about': typeof AboutRoute
+ '/admin': typeof AdminRouteWithChildren
'/courses': typeof CoursesRoute
- '/demo/tanstack-query': typeof DemoTanstackQueryRoute
- '/members/$memberId': typeof MembersMemberIdRoute
- '/videos/$videoId': typeof VideosVideoIdRoute
+ '/search': typeof SearchRoute
+ '/admin/audit': typeof AdminAuditRoute
+ '/admin/homepage': typeof AdminHomepageRoute
+ '/admin/members': typeof AdminMembersRoute
+ '/admin/trash': typeof AdminTrashRoute
+ '/events/$slug': typeof EventsSlugRoute
+ '/members/$slug': typeof MembersSlugRoute
+ '/members/archived': typeof MembersArchivedRoute
+ '/members/contributors': typeof MembersContributorsRoute
+ '/videos/$slug': typeof VideosSlugRoute
+ '/admin/': typeof AdminIndexRoute
'/events/': typeof EventsIndexRoute
'/members/': typeof MembersIndexRoute
'/videos/': typeof VideosIndexRoute
+ '/admin/catalog/staff-roles': typeof AdminCatalogStaffRolesRoute
+ '/admin/catalog/tags': typeof AdminCatalogTagsRoute
+ '/admin/events/$id': typeof AdminEventsIdRoute
+ '/admin/events/new': typeof AdminEventsNewRoute
+ '/admin/videos/$id': typeof AdminVideosIdRoute
+ '/admin/videos/new': typeof AdminVideosNewRoute
+ '/admin/events/': typeof AdminEventsIndexRoute
+ '/admin/videos/': typeof AdminVideosIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/about'
+ | '/admin'
| '/courses'
- | '/demo/tanstack-query'
- | '/members/$memberId'
- | '/videos/$videoId'
+ | '/search'
+ | '/admin/audit'
+ | '/admin/homepage'
+ | '/admin/members'
+ | '/admin/trash'
+ | '/events/$slug'
+ | '/members/$slug'
+ | '/members/archived'
+ | '/members/contributors'
+ | '/videos/$slug'
+ | '/admin/'
| '/events/'
| '/members/'
| '/videos/'
+ | '/admin/catalog/staff-roles'
+ | '/admin/catalog/tags'
+ | '/admin/events/$id'
+ | '/admin/events/new'
+ | '/admin/videos/$id'
+ | '/admin/videos/new'
+ | '/admin/events/'
+ | '/admin/videos/'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/about'
| '/courses'
- | '/demo/tanstack-query'
- | '/members/$memberId'
- | '/videos/$videoId'
+ | '/search'
+ | '/admin/audit'
+ | '/admin/homepage'
+ | '/admin/members'
+ | '/admin/trash'
+ | '/events/$slug'
+ | '/members/$slug'
+ | '/members/archived'
+ | '/members/contributors'
+ | '/videos/$slug'
+ | '/admin'
| '/events'
| '/members'
| '/videos'
+ | '/admin/catalog/staff-roles'
+ | '/admin/catalog/tags'
+ | '/admin/events/$id'
+ | '/admin/events/new'
+ | '/admin/videos/$id'
+ | '/admin/videos/new'
+ | '/admin/events'
+ | '/admin/videos'
id:
| '__root__'
| '/'
| '/about'
+ | '/admin'
| '/courses'
- | '/demo/tanstack-query'
- | '/members/$memberId'
- | '/videos/$videoId'
+ | '/search'
+ | '/admin/audit'
+ | '/admin/homepage'
+ | '/admin/members'
+ | '/admin/trash'
+ | '/events/$slug'
+ | '/members/$slug'
+ | '/members/archived'
+ | '/members/contributors'
+ | '/videos/$slug'
+ | '/admin/'
| '/events/'
| '/members/'
| '/videos/'
+ | '/admin/catalog/staff-roles'
+ | '/admin/catalog/tags'
+ | '/admin/events/$id'
+ | '/admin/events/new'
+ | '/admin/videos/$id'
+ | '/admin/videos/new'
+ | '/admin/events/'
+ | '/admin/videos/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
AboutRoute: typeof AboutRoute
+ AdminRoute: typeof AdminRouteWithChildren
CoursesRoute: typeof CoursesRoute
- DemoTanstackQueryRoute: typeof DemoTanstackQueryRoute
- MembersMemberIdRoute: typeof MembersMemberIdRoute
- VideosVideoIdRoute: typeof VideosVideoIdRoute
+ SearchRoute: typeof SearchRoute
+ EventsSlugRoute: typeof EventsSlugRoute
+ MembersSlugRoute: typeof MembersSlugRoute
+ MembersArchivedRoute: typeof MembersArchivedRoute
+ MembersContributorsRoute: typeof MembersContributorsRoute
+ VideosSlugRoute: typeof VideosSlugRoute
EventsIndexRoute: typeof EventsIndexRoute
MembersIndexRoute: typeof MembersIndexRoute
VideosIndexRoute: typeof VideosIndexRoute
@@ -149,11 +355,11 @@ export interface RootRouteChildren {
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
- '/courses': {
- id: '/courses'
- path: '/courses'
- fullPath: '/courses'
- preLoaderRoute: typeof CoursesRouteImport
+ '/': {
+ id: '/'
+ path: '/'
+ fullPath: '/'
+ preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/about': {
@@ -163,18 +369,74 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AboutRouteImport
parentRoute: typeof rootRouteImport
}
- '/': {
- id: '/'
+ '/admin': {
+ id: '/admin'
+ path: '/admin'
+ fullPath: '/admin'
+ preLoaderRoute: typeof AdminRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/courses': {
+ id: '/courses'
+ path: '/courses'
+ fullPath: '/courses'
+ preLoaderRoute: typeof CoursesRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/search': {
+ id: '/search'
+ path: '/search'
+ fullPath: '/search'
+ preLoaderRoute: typeof SearchRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/admin/': {
+ id: '/admin/'
path: '/'
- fullPath: '/'
- preLoaderRoute: typeof IndexRouteImport
+ fullPath: '/admin/'
+ preLoaderRoute: typeof AdminIndexRouteImport
+ parentRoute: typeof AdminRoute
+ }
+ '/admin/audit': {
+ id: '/admin/audit'
+ path: '/audit'
+ fullPath: '/admin/audit'
+ preLoaderRoute: typeof AdminAuditRouteImport
+ parentRoute: typeof AdminRoute
+ }
+ '/admin/homepage': {
+ id: '/admin/homepage'
+ path: '/homepage'
+ fullPath: '/admin/homepage'
+ preLoaderRoute: typeof AdminHomepageRouteImport
+ parentRoute: typeof AdminRoute
+ }
+ '/admin/members': {
+ id: '/admin/members'
+ path: '/members'
+ fullPath: '/admin/members'
+ preLoaderRoute: typeof AdminMembersRouteImport
+ parentRoute: typeof AdminRoute
+ }
+ '/admin/trash': {
+ id: '/admin/trash'
+ path: '/trash'
+ fullPath: '/admin/trash'
+ preLoaderRoute: typeof AdminTrashRouteImport
+ parentRoute: typeof AdminRoute
+ }
+ '/events/': {
+ id: '/events/'
+ path: '/events'
+ fullPath: '/events/'
+ preLoaderRoute: typeof EventsIndexRouteImport
parentRoute: typeof rootRouteImport
}
- '/videos/': {
- id: '/videos/'
- path: '/videos'
- fullPath: '/videos/'
- preLoaderRoute: typeof VideosIndexRouteImport
+ '/events/$slug': {
+ id: '/events/$slug'
+ path: '/events/$slug'
+ fullPath: '/events/$slug'
+ preLoaderRoute: typeof EventsSlugRouteImport
parentRoute: typeof rootRouteImport
}
'/members/': {
@@ -184,44 +446,145 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof MembersIndexRouteImport
parentRoute: typeof rootRouteImport
}
- '/events/': {
- id: '/events/'
- path: '/events'
- fullPath: '/events/'
- preLoaderRoute: typeof EventsIndexRouteImport
+ '/members/$slug': {
+ id: '/members/$slug'
+ path: '/members/$slug'
+ fullPath: '/members/$slug'
+ preLoaderRoute: typeof MembersSlugRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/members/archived': {
+ id: '/members/archived'
+ path: '/members/archived'
+ fullPath: '/members/archived'
+ preLoaderRoute: typeof MembersArchivedRouteImport
parentRoute: typeof rootRouteImport
}
- '/videos/$videoId': {
- id: '/videos/$videoId'
- path: '/videos/$videoId'
- fullPath: '/videos/$videoId'
- preLoaderRoute: typeof VideosVideoIdRouteImport
+ '/members/contributors': {
+ id: '/members/contributors'
+ path: '/members/contributors'
+ fullPath: '/members/contributors'
+ preLoaderRoute: typeof MembersContributorsRouteImport
parentRoute: typeof rootRouteImport
}
- '/members/$memberId': {
- id: '/members/$memberId'
- path: '/members/$memberId'
- fullPath: '/members/$memberId'
- preLoaderRoute: typeof MembersMemberIdRouteImport
+ '/videos/': {
+ id: '/videos/'
+ path: '/videos'
+ fullPath: '/videos/'
+ preLoaderRoute: typeof VideosIndexRouteImport
parentRoute: typeof rootRouteImport
}
- '/demo/tanstack-query': {
- id: '/demo/tanstack-query'
- path: '/demo/tanstack-query'
- fullPath: '/demo/tanstack-query'
- preLoaderRoute: typeof DemoTanstackQueryRouteImport
+ '/videos/$slug': {
+ id: '/videos/$slug'
+ path: '/videos/$slug'
+ fullPath: '/videos/$slug'
+ preLoaderRoute: typeof VideosSlugRouteImport
parentRoute: typeof rootRouteImport
}
+ '/admin/catalog/staff-roles': {
+ id: '/admin/catalog/staff-roles'
+ path: '/catalog/staff-roles'
+ fullPath: '/admin/catalog/staff-roles'
+ preLoaderRoute: typeof AdminCatalogStaffRolesRouteImport
+ parentRoute: typeof AdminRoute
+ }
+ '/admin/catalog/tags': {
+ id: '/admin/catalog/tags'
+ path: '/catalog/tags'
+ fullPath: '/admin/catalog/tags'
+ preLoaderRoute: typeof AdminCatalogTagsRouteImport
+ parentRoute: typeof AdminRoute
+ }
+ '/admin/events/': {
+ id: '/admin/events/'
+ path: '/events'
+ fullPath: '/admin/events/'
+ preLoaderRoute: typeof AdminEventsIndexRouteImport
+ parentRoute: typeof AdminRoute
+ }
+ '/admin/events/$id': {
+ id: '/admin/events/$id'
+ path: '/events/$id'
+ fullPath: '/admin/events/$id'
+ preLoaderRoute: typeof AdminEventsIdRouteImport
+ parentRoute: typeof AdminRoute
+ }
+ '/admin/events/new': {
+ id: '/admin/events/new'
+ path: '/events/new'
+ fullPath: '/admin/events/new'
+ preLoaderRoute: typeof AdminEventsNewRouteImport
+ parentRoute: typeof AdminRoute
+ }
+ '/admin/videos/': {
+ id: '/admin/videos/'
+ path: '/videos'
+ fullPath: '/admin/videos/'
+ preLoaderRoute: typeof AdminVideosIndexRouteImport
+ parentRoute: typeof AdminRoute
+ }
+ '/admin/videos/$id': {
+ id: '/admin/videos/$id'
+ path: '/videos/$id'
+ fullPath: '/admin/videos/$id'
+ preLoaderRoute: typeof AdminVideosIdRouteImport
+ parentRoute: typeof AdminRoute
+ }
+ '/admin/videos/new': {
+ id: '/admin/videos/new'
+ path: '/videos/new'
+ fullPath: '/admin/videos/new'
+ preLoaderRoute: typeof AdminVideosNewRouteImport
+ parentRoute: typeof AdminRoute
+ }
}
}
+interface AdminRouteChildren {
+ AdminAuditRoute: typeof AdminAuditRoute
+ AdminHomepageRoute: typeof AdminHomepageRoute
+ AdminMembersRoute: typeof AdminMembersRoute
+ AdminTrashRoute: typeof AdminTrashRoute
+ AdminIndexRoute: typeof AdminIndexRoute
+ AdminCatalogStaffRolesRoute: typeof AdminCatalogStaffRolesRoute
+ AdminCatalogTagsRoute: typeof AdminCatalogTagsRoute
+ AdminEventsIdRoute: typeof AdminEventsIdRoute
+ AdminEventsNewRoute: typeof AdminEventsNewRoute
+ AdminVideosIdRoute: typeof AdminVideosIdRoute
+ AdminVideosNewRoute: typeof AdminVideosNewRoute
+ AdminEventsIndexRoute: typeof AdminEventsIndexRoute
+ AdminVideosIndexRoute: typeof AdminVideosIndexRoute
+}
+
+const AdminRouteChildren: AdminRouteChildren = {
+ AdminAuditRoute: AdminAuditRoute,
+ AdminHomepageRoute: AdminHomepageRoute,
+ AdminMembersRoute: AdminMembersRoute,
+ AdminTrashRoute: AdminTrashRoute,
+ AdminIndexRoute: AdminIndexRoute,
+ AdminCatalogStaffRolesRoute: AdminCatalogStaffRolesRoute,
+ AdminCatalogTagsRoute: AdminCatalogTagsRoute,
+ AdminEventsIdRoute: AdminEventsIdRoute,
+ AdminEventsNewRoute: AdminEventsNewRoute,
+ AdminVideosIdRoute: AdminVideosIdRoute,
+ AdminVideosNewRoute: AdminVideosNewRoute,
+ AdminEventsIndexRoute: AdminEventsIndexRoute,
+ AdminVideosIndexRoute: AdminVideosIndexRoute,
+}
+
+const AdminRouteWithChildren = AdminRoute._addFileChildren(AdminRouteChildren)
+
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
AboutRoute: AboutRoute,
+ AdminRoute: AdminRouteWithChildren,
CoursesRoute: CoursesRoute,
- DemoTanstackQueryRoute: DemoTanstackQueryRoute,
- MembersMemberIdRoute: MembersMemberIdRoute,
- VideosVideoIdRoute: VideosVideoIdRoute,
+ SearchRoute: SearchRoute,
+ EventsSlugRoute: EventsSlugRoute,
+ MembersSlugRoute: MembersSlugRoute,
+ MembersArchivedRoute: MembersArchivedRoute,
+ MembersContributorsRoute: MembersContributorsRoute,
+ VideosSlugRoute: VideosSlugRoute,
EventsIndexRoute: EventsIndexRoute,
MembersIndexRoute: MembersIndexRoute,
VideosIndexRoute: VideosIndexRoute,
@@ -229,12 +592,3 @@ const rootRouteChildren: RootRouteChildren = {
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes()
-
-import type { getRouter } from './router.tsx'
-import type { createStart } from '@tanstack/react-start'
-declare module '@tanstack/react-start' {
- interface Register {
- ssr: true
- router: Awaited>
- }
-}
diff --git a/src/router.tsx b/src/router.tsx
index 2161efb..bfc250c 100644
--- a/src/router.tsx
+++ b/src/router.tsx
@@ -1,12 +1,9 @@
import { createRouter as createTanStackRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
-import type { ReactNode } from 'react'
-import { QueryClient } from '@tanstack/react-query'
import { setupRouterSsrQueryIntegration } from '@tanstack/react-router-ssr-query'
-import TanstackQueryProvider, {
- getContext,
-} from './integrations/tanstack-query/root-provider'
+import { getContext } from './integrations/tanstack-query/root-provider'
+import { LoadingState } from '#/components/PageStates.tsx'
export function getRouter() {
const context = getContext()
@@ -17,13 +14,76 @@ export function getRouter() {
scrollRestoration: true,
defaultPreload: 'intent',
defaultPreloadStaleTime: 0,
+ defaultPendingComponent: () => ,
})
setupRouterSsrQueryIntegration({ router, queryClient: context.queryClient })
+ silenceQueryStreamEndError(router)
return router
}
+/** `hydrate()` runs as a no-op on empty state. */
+const EMPTY_DEHYDRATED_QUERY_STATE = { queries: [], mutations: [] }
+
+interface QueryStreamCarrier {
+ queryStream?: {
+ getReader: () => {
+ read: () => Promise<{ done: boolean; value?: unknown }>
+ cancel: (reason?: unknown) => Promise
+ releaseLock: () => void
+ }
+ }
+}
+
+/**
+ * Workaround for a bug in `@tanstack/router-ssr-query-core@1.169.1`
+ * (`dist/esm/index.js:93`): the hydration loop calls `hydrate()` even on the
+ * stream's closing read, where `value` is already `undefined`. As a result,
+ * at the end of every page load it throws away an
+ * "Error reading query stream: TypeError: … dehydratedState is undefined"
+ * error. No data is lost — all the real chunks have arrived by then — but it
+ * fills up the console.
+ *
+ * For the closing read we return an empty dehydrated state instead of
+ * `undefined`. This does not swallow real stream errors; it only supplies
+ * the closing value. If it gets fixed upstream, this function can be deleted.
+ */
+function silenceQueryStreamEndError(router: {
+ options: { hydrate?: (dehydrated: never) => unknown }
+}): void {
+ // The package only sets up `hydrate` on the client; nothing to do on the server.
+ const ssrQueryHydrate = router.options.hydrate
+ if (ssrQueryHydrate === undefined) {
+ return
+ }
+
+ router.options.hydrate = (dehydrated: never) => {
+ const carrier = dehydrated as QueryStreamCarrier | null
+ const stream = carrier?.queryStream
+
+ if (stream !== undefined) {
+ carrier!.queryStream = {
+ getReader: () => {
+ const reader = stream.getReader()
+ return {
+ cancel: (reason?: unknown) => reader.cancel(reason),
+ releaseLock: () => reader.releaseLock(),
+ read: async () => {
+ const result = await reader.read()
+ return result.done
+ ? { done: true, value: EMPTY_DEHYDRATED_QUERY_STATE }
+ : result
+ },
+ }
+ },
+ }
+ }
+
+ return ssrQueryHydrate(dehydrated)
+ }
+}
+
declare module '@tanstack/react-router' {
interface Register {
router: ReturnType
diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx
index a91dacf..c97d8d6 100644
--- a/src/routes/__root.tsx
+++ b/src/routes/__root.tsx
@@ -13,6 +13,8 @@ import appCss from '../styles.css?url'
import type { QueryClient } from '@tanstack/react-query'
import Navbar from '#/components/Navbar.tsx'
+import { ErrorState, NotFoundContent } from '#/components/PageStates.tsx'
+import { fetchViewerState } from '#/server/pages/viewer-fn.ts'
interface MyRouterContext {
queryClient: QueryClient
@@ -31,7 +33,7 @@ export const Route = createRootRouteWithContext()({
content: 'width=device-width, initial-scale=1',
},
{
- title: 'TanStack Start Starter',
+ title: 'Budavári Schönherz Studió',
},
],
links: [
@@ -41,24 +43,30 @@ export const Route = createRootRouteWithContext()({
},
],
}),
+ // The navbar's login state is needed on every page: load it once.
+ loader: ({ context }) =>
+ context.queryClient.ensureQueryData({
+ queryKey: ['viewer'],
+ queryFn: fetchViewerState,
+ staleTime: 60_000,
+ }),
+ notFoundComponent: NotFoundContent,
+ errorComponent: () => ,
shellComponent: RootDocument,
})
-
function RootDocument({ children }: { children: React.ReactNode }) {
-
-
return (
-
+
-
+ {/* The page background (color + dot pattern) sits on the root element
+ in styles.css, so `body` stays transparent. */}
+
-
- {children}
-
+ {children}
-
-
-
-
- Mit csinál egy BSS-es?
-
-
- A Budavári Schönherz Stúdió Budavári Schönherz Stúdió, röviden
- BSS, 1982 óta készít riportokat, interjúkat, műsorokat és stúdiós
- felvételeket a műegyetemi közösség számára. A munkafolyamat a
- tervezéstől a felvételen át egészen az utómunkáig tart.
-
-
- A csapatban mindenki megtalálhatja a saját területét: van, aki a
- kamera mögött érzi otthon magát, más a vágásban vagy a
- szervezésben segít, de az is könnyen megtalálja a helyét, aki még
- csak most ismerkedik a videós világgal.
-
-
-
-
-
-
-
+/**
+ * About page text (spec 10.1): versioned plain text content;
+ * changing it requires a code change.
+ */
+const ABOUT_TEXT_VERSION = 1
-
-
-
- Mérnök is kell, bölcsész is
-
-
- A média világa komoly technikai tudást és jó kommunikációt is
- igényel. Egy-egy felvételnél fontos a világítás, a hang, a kamera
- beállítása és az is, hogy a stáb tagjai egymással jól tudjanak
- együtt dolgozni.
-
-
- Nálunk a műszaki érdeklődés és a kreatív szemlélet egyszerre
- számít. Ha szereted a technikát, de érdekel az, hogyan áll össze
- egy műsor a háttérben, akkor jó helyen jársz.
-
-
+const ABOUT_TEXT = `A Budavári Schönherz Studió a BME Schönherz Kollázium öntevékeny köre.
+Videókat készítünk az egyetemi életről, rendezvényeinkről és a kollégium közösségéről.
+Tagjaink operatőri, vágói és riporteri gyakorlatot szereznek, miközben együtt dolgozunk a
+legjobb egyetemi videótartalmakon.`
-
-
-
-
-
-
-
-
- Mivel foglalkozunk?
-
-
- Heti rendszerességgel szerkesztett anyagokat, riportokat, élő
- közvetítéseket és különböző stúdiós felvételeket készítünk. A
- feladatok között megtalálható a forgatás, a kamerakezelés, a vágás
- és a produkciók előkészítése is.
-
-
- Az új tagok fokozatosan kapcsolódnak be a munkába, így mindenki a
- saját tempójában ismerheti meg az eszközöket és a stúdió
- működését.
-
-
-
-
-
-
-
+const loadAboutPage = createServerFn({ method: 'GET' }).handler(async () => {
+ const db = await getDefaultDb()
+ const aboutVideos = await getAboutPageVideos(db)
+ return aboutVideos.map((video) => ({
+ id: video.id,
+ slug: video.slug,
+ title: video.title,
+ thumbnailUrl: video.thumbnailUrl,
+ }))
+})
-
-
-
- Amikor nem forog a kamera
-
-
- A stúdió életében nem csak a felvétel számít. A háttérben sok
- szervezés, egyeztetés és közös munka zajlik: a nyersanyagok
- rendezése, a vágás, az archiválás és a következő műsorok tervezése
- mind része a mindennapoknak.
-
-
- Ez teszi igazán csapattá a BSS-t: a felvételek mellett a közös
- munka, a tapasztalatcsere és az egymást segítő szemlélet is fontos
- szerepet kap.
-
-
+export const Route = createFileRoute('/about')({
+ loader: ({ context }) =>
+ context.queryClient.ensureQueryData({
+ queryKey: ['about-page'],
+ queryFn: loadAboutPage,
+ staleTime: 60_000,
+ }),
+ component: AboutPage,
+})
-
-
-
-
+function AboutPage() {
+ const videos = Route.useLoaderData()
-
-
-
- Hogyan csatlakozhatok?
-
-
- Ha érdekel a videózás, a televíziós műsorkészítés vagy a stúdiós
- munka, a tanfolyamainkon betekintést kapsz abba, hogyan dolgozunk.
- A jelentkezés után felvesszük veled a kapcsolatot, és megmutatjuk,
- hogyan lehet része a munkádnak a BSS.
-
-
- A célunk, hogy olyan közösséget építsünk, ahol a technikai tudás,
- a kreativitás és az együttműködés egyszerre fejlődik.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ return (
+
+ Rólunk | BSS
+
+ Mivel foglalkozunk?
+
+
+ {ABOUT_TEXT}
+
+ {videos.length > 0 && (
+
+
+ Válogatott videóink
+
+
+ {videos.map((video) => (
+
+
+
+ {video.title}
+
+
+ ))}
+
+
+ )}
)
}
+
+export { ABOUT_TEXT_VERSION }
diff --git a/src/routes/admin.tsx b/src/routes/admin.tsx
new file mode 100644
index 0000000..49c13ae
--- /dev/null
+++ b/src/routes/admin.tsx
@@ -0,0 +1,39 @@
+import { Outlet, createFileRoute, redirect } from '@tanstack/react-router'
+import { ForbiddenContent } from '#/components/PageStates.tsx'
+import { AdminSidebar } from '#/components/admin/AdminSidebar.tsx'
+import { fetchAdminAreaAccess } from '#/server/pages/admin/access-fn.ts'
+
+/**
+ * Admin layout (BSS-027): every page under /admin is protected by a
+ * server-side guard. Anonymous users are sent to login with returnTo
+ * preserved; logged-in users without permission receive a Hungarian 403 page.
+ */
+export const Route = createFileRoute('/admin')({
+ loader: async () => {
+ const access = await fetchAdminAreaAccess()
+ if (access.kind === 'login') {
+ throw redirect({ href: access.loginUrl })
+ }
+ return { access }
+ },
+ component: AdminLayout,
+})
+
+function AdminLayout() {
+ const { access } = Route.useLoaderData()
+
+ if (access.kind === 'forbidden' || access.viewer === undefined) {
+ return
+ }
+
+ return (
+
+ {/* The admin area must not be indexed (spec 16). */}
+
+
+
+
+
+
+ )
+}
diff --git a/src/routes/admin/audit.tsx b/src/routes/admin/audit.tsx
new file mode 100644
index 0000000..726ef18
--- /dev/null
+++ b/src/routes/admin/audit.tsx
@@ -0,0 +1,306 @@
+import { createFileRoute, redirect } from '@tanstack/react-router'
+import { createServerFn } from '@tanstack/react-start'
+import { useQuery, useQueryClient } from '@tanstack/react-query'
+import { getDefaultDb } from '#/server/auth/session-store.ts'
+import {
+ parsePaginationNumber,
+ parseSearchPage,
+} from '#/server/shared/pagination.ts'
+import {
+ getAuditFilterValues,
+ getAuditPage,
+ parseAuditFilters,
+} from '#/server/admin/audit-admin.ts'
+import type { AuditListItem } from '#/server/admin/audit-admin.ts'
+import { fetchLeadershipAreaAccess } from '#/server/pages/admin/access-fn.ts'
+import { ErrorState, LoadingState } from '#/components/PageStates.tsx'
+import { ResponsiveTable } from '#/components/admin/ResponsiveTable.tsx'
+import {
+ AdminSearchSelect,
+ FILTER_LABEL_CLASS,
+} from '#/components/admin/SearchSelect.tsx'
+import type { AdminColumn } from '#/components/admin/ResponsiveTable.tsx'
+import type { SearchSelectOption } from '#/components/admin/SearchSelect.tsx'
+import { formatAdminDateTimeHu } from '#/lib/format-date.ts'
+
+const loadAuditPage = createServerFn({ method: 'GET' })
+ .validator(
+ (input: Record
| undefined) =>
+ input ?? {},
+ )
+ .handler(async ({ data }) => {
+ const db = await getDefaultDb()
+ return getAuditPage(db, {
+ page: parsePaginationNumber(data['page'], 1),
+ perPage: parsePaginationNumber(data['perPage'], 25),
+ filters: parseAuditFilters(data),
+ })
+ })
+
+const loadAuditFilterValues = createServerFn({ method: 'GET' }).handler(
+ async () => {
+ const db = await getDefaultDb()
+ return getAuditFilterValues(db)
+ },
+)
+
+interface AuditSearch extends Record {
+ page?: number
+}
+
+export const Route = createFileRoute('/admin/audit')({
+ validateSearch: (search: Record): AuditSearch => ({
+ actor: typeof search['actor'] === 'string' ? search['actor'] : undefined,
+ action: typeof search['action'] === 'string' ? search['action'] : undefined,
+ entityType:
+ typeof search['entityType'] === 'string'
+ ? search['entityType']
+ : undefined,
+ entityId:
+ typeof search['entityId'] === 'string' ? search['entityId'] : undefined,
+ from: typeof search['from'] === 'string' ? search['from'] : undefined,
+ to: typeof search['to'] === 'string' ? search['to'] : undefined,
+ page: parseSearchPage(search['page']),
+ }),
+ beforeLoad: async () => {
+ const access = await fetchLeadershipAreaAccess()
+ if (access.kind === 'login') {
+ throw redirect({ href: access.loginUrl })
+ }
+ if (access.kind === 'forbidden') {
+ throw redirect({ to: '/admin/videos' })
+ }
+ },
+ loaderDeps: ({ search }) => ({ search }),
+ loader: ({ deps, context }) =>
+ context.queryClient.ensureQueryData({
+ queryKey: ['admin-audit', deps.search],
+ queryFn: () => loadAuditPage({ data: deps.search }),
+ }),
+ component: AuditAdminPage,
+})
+
+/** The search parameter with an index signature, passed as text to the select. */
+function asText(value: string | number | undefined): string {
+ return value === undefined ? '' : String(value)
+}
+
+/** Values occurring in the audit log as searchable list options. */
+function nameOptions(names?: string[]): Array {
+ return (names ?? []).map((name) => ({ value: name, label: name }))
+}
+
+function AuditAdminPage() {
+ const navigate = Route.useNavigate()
+ const search = Route.useSearch()
+ const queryClient = useQueryClient()
+ const auditQuery = useQuery({
+ queryKey: ['admin-audit', search],
+ queryFn: () => loadAuditPage({ data: search }),
+ })
+ const valuesQuery = useQuery({
+ queryKey: ['admin-audit-values'],
+ queryFn: loadAuditFilterValues,
+ staleTime: 60_000,
+ })
+
+ function refresh() {
+ void queryClient.invalidateQueries({
+ queryKey: ['admin-audit-values'],
+ })
+ }
+ void refresh
+
+ return (
+
+ Auditnapló
+
+ Minden adminmódosítás előtte-utána értékkel. A napló nem módosítható,
+ nem törölhető és nem exportálható; megőrzése korlátlan.
+
+
+
+
+ {auditQuery.isPending && }
+ {auditQuery.isError && (
+
+ )}
+ {auditQuery.isSuccess &&
+ (auditQuery.data.items.length === 0 ? (
+
+ Nincs a szűrőknek megfelelő bejegyzés.
+
+ ) : (
+ <>
+
+
+
+ navigate({
+ search: (prev) => ({
+ ...prev,
+ page:
+ auditQuery.data.page - 1 <= 1
+ ? undefined
+ : auditQuery.data.page - 1,
+ }),
+ })
+ }
+ className="ctrl-btn rounded border border-(--nav-border-b) px-3 py-1"
+ >
+ ‹ Előző
+
+
+ {auditQuery.data.page}. /{' '}
+ {Math.max(auditQuery.data.totalPages, 1)}. oldal ·{' '}
+ {auditQuery.data.total} bejegyzés
+
+ = auditQuery.data.totalPages}
+ onClick={() =>
+ navigate({
+ search: (prev) => ({
+ ...prev,
+ page: auditQuery.data.page + 1,
+ }),
+ })
+ }
+ className="ctrl-btn rounded border border-(--nav-border-b) px-3 py-1"
+ >
+ Következő ›
+
+
+ >
+ ))}
+
+ )
+
+ function applyPatch(patch: Partial>) {
+ void navigate({
+ search: (prev) => ({ ...prev, ...patch, page: undefined }),
+ })
+ }
+}
+
+function JsonCell({ json }: { json: string | null }) {
+ if (json === null) {
+ return <>—>
+ }
+ return (
+
+ részletek
+
+ {json}
+
+
+ )
+}
+
+const auditColumns: Array> = [
+ {
+ key: 'time',
+ header: 'Időpont',
+ primary: true,
+ render: (row) => formatAdminDateTimeHu(row.occurredAt),
+ },
+ { key: 'actor', header: 'Szereplő', render: (row) => row.actor },
+ { key: 'action', header: 'Művelet', render: (row) => row.action },
+ {
+ key: 'entity',
+ header: 'Entitás',
+ render: (row) => `${row.entityType} (${row.entityId.slice(0, 8)}…)`,
+ },
+ {
+ key: 'before',
+ header: 'Előtte',
+ render: (row) => ,
+ },
+ {
+ key: 'after',
+ header: 'Utána',
+ render: (row) => ,
+ },
+]
diff --git a/src/routes/admin/catalog/staff-roles.tsx b/src/routes/admin/catalog/staff-roles.tsx
new file mode 100644
index 0000000..1d2c99b
--- /dev/null
+++ b/src/routes/admin/catalog/staff-roles.tsx
@@ -0,0 +1,369 @@
+import { createFileRoute, redirect } from '@tanstack/react-router'
+import { createServerFn } from '@tanstack/react-start'
+import { useQuery, useQueryClient } from '@tanstack/react-query'
+import { useState } from 'react'
+import { getDefaultDb } from '#/server/auth/session-store.ts'
+import { listStaffRolesWithUsage } from '#/server/catalog/staff-roles.ts'
+import { fetchLeadershipAreaAccess } from '#/server/pages/admin/access-fn.ts'
+import { ErrorState, LoadingState } from '#/components/PageStates.tsx'
+import {
+ AdminSearchSelect,
+ FILTER_LABEL_CLASS,
+} from '#/components/admin/SearchSelect.tsx'
+import {
+ AdminPrimaryButton,
+ AdminSecondaryButton,
+ AdminTextField,
+} from '#/components/admin/form.tsx'
+import { FormMessage, LoginRequiredBanner } from '#/components/admin/Alerts.tsx'
+import { postJson } from '#/lib/admin-api.ts'
+
+interface RoleRow {
+ id: string
+ name: string
+ displayOrder: number
+ videoCount: number
+}
+
+const loadRoleCatalog = createServerFn({ method: 'GET' }).handler(async () => {
+ const db = await getDefaultDb()
+ return listStaffRolesWithUsage(db)
+})
+
+export const Route = createFileRoute('/admin/catalog/staff-roles')({
+ beforeLoad: async () => {
+ const access = await fetchLeadershipAreaAccess()
+ if (access.kind === 'login') {
+ throw redirect({ href: access.loginUrl })
+ }
+ if (access.kind === 'forbidden') {
+ throw redirect({ to: '/admin/videos' })
+ }
+ },
+ loader: ({ context }) =>
+ context.queryClient.ensureQueryData({
+ queryKey: ['admin-role-catalog'],
+ queryFn: loadRoleCatalog,
+ }),
+ component: StaffRoleCatalogPage,
+})
+
+function StaffRoleCatalogPage() {
+ const queryClient = useQueryClient()
+ const catalogQuery = useQuery({
+ queryKey: ['admin-role-catalog'],
+ queryFn: loadRoleCatalog,
+ })
+
+ function refresh() {
+ void queryClient.invalidateQueries({ queryKey: ['admin-role-catalog'] })
+ }
+
+ return (
+
+
+ Stábszerepek
+
+
+ {catalogQuery.isPending && }
+ {catalogQuery.isError && (
+
+ )}
+ {catalogQuery.isSuccess && (
+
+ )}
+
+ )
+}
+
+function RoleList({
+ roles,
+ onChanged,
+}: {
+ roles: RoleRow[]
+ onChanged: () => void
+}) {
+ const [order, setOrder] = useState(() =>
+ [...roles]
+ .sort((a, b) => a.displayOrder - b.displayOrder)
+ .map((role) => role.id),
+ )
+
+ if (roles.length === 0) {
+ return (
+
+ Még nincs stábszerep a katalógusban.
+
+ )
+ }
+
+ const byId = new Map(roles.map((role) => [role.id, role]))
+ const ordered = order.filter((id) => byId.has(id))
+ // New roles that are not yet in the order:
+ for (const role of [...roles].sort(
+ (a, b) => a.displayOrder - b.displayOrder,
+ )) {
+ if (!ordered.includes(role.id)) ordered.push(role.id)
+ }
+ const orderChanged = JSON.stringify(order) !== JSON.stringify(ordered)
+
+ function move(index: number, delta: number) {
+ const next = [...ordered]
+ const target = index + delta
+ if (target < 0 || target >= next.length) return
+ ;[next[index], next[target]] = [next[target], next[index]]
+ setOrder(next)
+ }
+
+ async function saveOrder() {
+ await postJson('/api/admin/staff-roles/reorder', {
+ orderedRoleIds: ordered,
+ })
+ onChanged()
+ }
+
+ return (
+ <>
+
+ {ordered.map((roleId, index) => (
+ move(index, delta)}
+ onChanged={() => {
+ setOrder(ordered)
+ onChanged()
+ }}
+ />
+ ))}
+
+
+
void saveOrder()}
+ disabled={!orderChanged}
+ >
+ Sorrend mentése
+
+ {orderChanged && (
+
+ A megjelenítési sorrend módosult.
+
+ )}
+
+ >
+ )
+}
+
+function RoleRowEditor({
+ role,
+ allRoles,
+ index,
+ total,
+ onMove,
+ onChanged,
+}: {
+ role: RoleRow
+ allRoles: RoleRow[]
+ index: number
+ total: number
+ onMove: (delta: number) => void
+ onChanged: () => void
+}) {
+ const [renaming, setRenaming] = useState(false)
+ const [newName, setNewName] = useState(role.name)
+ const [merging, setMerging] = useState(false)
+ const [targetId, setTargetId] = useState('')
+ const [busy, setBusy] = useState(false)
+ const [problems, setProblems] = useState([])
+ const [message, setMessage] = useState(null)
+
+ async function act(url: string, body: Record) {
+ setBusy(true)
+ setProblems([])
+ setMessage(null)
+ const result = await postJson(url, body)
+ setBusy(false)
+ if (result.ok) {
+ setRenaming(false)
+ setMerging(false)
+ onChanged()
+ return true
+ }
+ setProblems(result.error.problems ?? [result.error.message])
+ return false
+ }
+
+ return (
+
+
+
+ onMove(-1)}
+ className="px-1 disabled:opacity-30"
+ >
+ ↑
+
+ onMove(1)}
+ className="px-1 disabled:opacity-30"
+ >
+ ↓
+
+ {index + 1}. {role.name}
+
+
+ {role.videoCount} videón használva
+
+
+
setRenaming((value) => !value)}>
+ Átnevezés
+
+ {allRoles.length > 1 && (
+
setMerging((value) => !value)}>
+ Összevonás
+
+ )}
+ {role.videoCount === 0 ? (
+
+ void act(`/api/admin/staff-roles/${role.id}/delete`, {})
+ }
+ >
+ Törlés
+
+ ) : (
+
+ Használatban van — nem törölhető, csak összevonható.
+
+ )}
+
+
+ {renaming && (
+
+
+
+ void act(`/api/admin/staff-roles/${role.id}/rename`, {
+ name: newName,
+ })
+ }
+ >
+ Mentés
+
+
+ )}
+
+ {merging && (
+
+
+
other.id !== role.id)
+ .map((other) => ({ value: other.id, label: other.name }))}
+ searchPlaceholder="Szerep keresése…"
+ labelClassName={FILTER_LABEL_CLASS}
+ />
+
+
+ void act(`/api/admin/staff-roles/${role.id}/merge`, {
+ targetRoleId: targetId,
+ })
+ }
+ >
+ Összevonás
+
+
+ )}
+
+ {problems.length > 0 && (
+
+ {problems.map((problem, i) => (
+ {problem}
+ ))}
+
+ )}
+ {message !== null && {message} }
+
+ )
+}
+
+function NewRoleForm({ onCreated }: { onCreated: () => void }) {
+ const [name, setName] = useState('')
+ const [busy, setBusy] = useState(false)
+ const [problems, setProblems] = useState([])
+ const [loginUrl, setLoginUrl] = useState(null)
+
+ async function submit() {
+ setBusy(true)
+ setProblems([])
+ setLoginUrl(null)
+ const result = await postJson('/api/admin/staff-roles', { name })
+ setBusy(false)
+ if (result.ok) {
+ setName('')
+ onCreated()
+ return
+ }
+ if (result.error.code === 'auth_required' && result.error.loginUrl) {
+ setLoginUrl(result.error.loginUrl)
+ return
+ }
+ setProblems(result.error.problems ?? [result.error.message])
+ }
+
+ return (
+
+ )
+}
diff --git a/src/routes/admin/catalog/tags.tsx b/src/routes/admin/catalog/tags.tsx
new file mode 100644
index 0000000..0c9ba67
--- /dev/null
+++ b/src/routes/admin/catalog/tags.tsx
@@ -0,0 +1,345 @@
+import { createFileRoute, redirect } from '@tanstack/react-router'
+import { createServerFn } from '@tanstack/react-start'
+import { useQuery, useQueryClient } from '@tanstack/react-query'
+import { useState } from 'react'
+import { getDefaultDb } from '#/server/auth/session-store.ts'
+import { listTagsWithUsage } from '#/server/catalog/tags.ts'
+import { fetchLeadershipAreaAccess } from '#/server/pages/admin/access-fn.ts'
+import { ErrorState, LoadingState } from '#/components/PageStates.tsx'
+import {
+ AdminSearchSelect,
+ FILTER_LABEL_CLASS,
+} from '#/components/admin/SearchSelect.tsx'
+import {
+ AdminPrimaryButton,
+ AdminSecondaryButton,
+ AdminTextField,
+} from '#/components/admin/form.tsx'
+import {
+ ConflictBanner,
+ FormMessage,
+ LoginRequiredBanner,
+} from '#/components/admin/Alerts.tsx'
+import { postJson } from '#/lib/admin-api.ts'
+
+interface TagRow {
+ id: string
+ name: string
+ videoCount: number
+}
+
+const loadTagCatalog = createServerFn({ method: 'GET' }).handler(async () => {
+ const db = await getDefaultDb()
+ return listTagsWithUsage(db)
+})
+
+export const Route = createFileRoute('/admin/catalog/tags')({
+ beforeLoad: async () => {
+ const access = await fetchLeadershipAreaAccess()
+ if (access.kind === 'login') {
+ throw redirect({ href: access.loginUrl })
+ }
+ if (access.kind === 'forbidden') {
+ throw redirect({ to: '/admin/videos' })
+ }
+ },
+ loader: ({ context }) =>
+ context.queryClient.ensureQueryData({
+ queryKey: ['admin-tag-catalog'],
+ queryFn: loadTagCatalog,
+ }),
+ component: TagCatalogPage,
+})
+
+function TagCatalogPage() {
+ const queryClient = useQueryClient()
+ const catalogQuery = useQuery({
+ queryKey: ['admin-tag-catalog'],
+ queryFn: loadTagCatalog,
+ })
+
+ function refresh() {
+ void queryClient.invalidateQueries({ queryKey: ['admin-tag-catalog'] })
+ }
+
+ return (
+
+
+ Címkekatalógus
+
+
+ {catalogQuery.isPending && }
+ {catalogQuery.isError && (
+
+ )}
+ {catalogQuery.isSuccess && (
+ <>
+ {catalogQuery.data.length === 0 ? (
+
+ Még nincs címke a katalógusban.
+
+ ) : (
+ catalogQuery.data.map((tag) => (
+
+ ))
+ )}
+ >
+ )}
+
+ )
+}
+
+function NewTagForm({ onCreated }: { onCreated: () => void }) {
+ const [name, setName] = useState('')
+ const [busy, setBusy] = useState(false)
+ const [message, setMessage] = useState(null)
+ const [problems, setProblems] = useState([])
+ const [similar, setSimilar] = useState([])
+ const [loginUrl, setLoginUrl] = useState(null)
+
+ async function checkSimilar(value: string) {
+ if (value.trim() === '') {
+ setSimilar([])
+ return
+ }
+ const response = await fetch(
+ `/api/admin/tags/similar?name=${encodeURIComponent(value)}`,
+ )
+ if (response.ok) {
+ const payload = (await response.json()) as { similar?: string[] }
+ setSimilar(payload.similar ?? [])
+ }
+ }
+
+ async function submit() {
+ setBusy(true)
+ setProblems([])
+ setMessage(null)
+ setLoginUrl(null)
+ const result = await postJson('/api/admin/tags', { name })
+ setBusy(false)
+ if (result.ok) {
+ setMessage(`„${name.trim()}" címke létrehozva.`)
+ setName('')
+ setSimilar([])
+ onCreated()
+ return
+ }
+ if (result.error.code === 'auth_required' && result.error.loginUrl) {
+ setLoginUrl(result.error.loginUrl)
+ return
+ }
+ setProblems(result.error.problems ?? [result.error.message])
+ }
+
+ return (
+
+ )
+}
+
+function TagRowEditor({
+ tag,
+ allTags,
+ onChanged,
+}: {
+ tag: TagRow
+ allTags: TagRow[]
+ onChanged: () => void
+}) {
+ const [renaming, setRenaming] = useState(false)
+ const [newName, setNewName] = useState(tag.name)
+ const [merging, setMerging] = useState(false)
+ const [targetId, setTargetId] = useState('')
+ const [deleting, setDeleting] = useState(false)
+ const [confirmation, setConfirmation] = useState('')
+ const [busy, setBusy] = useState(false)
+ const [problems, setProblems] = useState([])
+ const [conflict, setConflict] = useState(null)
+
+ async function act(url: string, body: Record) {
+ setBusy(true)
+ setProblems([])
+ setConflict(null)
+ const result = await postJson(url, body)
+ setBusy(false)
+ if (result.ok) {
+ onChanged()
+ setRenaming(false)
+ setMerging(false)
+ setDeleting(false)
+ return true
+ }
+ if (result.error.code === 'conflict') {
+ setConflict(result.error.message)
+ return false
+ }
+ setProblems(result.error.problems ?? [result.error.message])
+ return false
+ }
+
+ return (
+
+
+
{tag.name}
+
+ {tag.videoCount} videón használva
+
+
+
setRenaming((value) => !value)}>
+ Átnevezés
+
+ {allTags.length > 1 && (
+
setMerging((value) => !value)}>
+ Összevonás
+
+ )}
+
{
+ setDeleting((value) => !value)
+ setConfirmation('')
+ }}
+ >
+ Törlés
+
+
+
+ {renaming && (
+
+
+
+ void act(`/api/admin/tags/${tag.id}/rename`, { name: newName })
+ }
+ >
+ Mentés
+
+
+ )}
+
+ {merging && (
+
+
+
other.id !== tag.id)
+ .map((other) => ({ value: other.id, label: other.name }))}
+ searchPlaceholder="Címke keresése…"
+ labelClassName={FILTER_LABEL_CLASS}
+ />
+
+
+ void act(`/api/admin/tags/${tag.id}/merge`, {
+ targetTagId: targetId,
+ })
+ }
+ >
+ Összevonás
+
+
+ )}
+
+ {deleting && (
+
+ {tag.videoCount > 0 && (
+
+ Ez a címke {tag.videoCount} videón szerepel; törléskor minden
+ kapcsolat megszűnik. Írd be a nevét a megerősítéshez:{' '}
+ {tag.name}
+
+ )}
+ {tag.videoCount > 0 && (
+
setConfirmation(event.target.value)}
+ placeholder={tag.name}
+ className="h-10 w-full max-w-md border-b border-(--nav-border-b) bg-(--nav-search-bg) px-2"
+ />
+ )}
+
+
0 && confirmation !== tag.name)
+ }
+ confirm={`Biztosan törlöd „${tag.name}" címkét?`}
+ onClick={() =>
+ void act(`/api/admin/tags/${tag.id}/delete`, {
+ confirmation,
+ })
+ }
+ >
+ Végleges törlés
+
+
+
+ )}
+
+ {conflict !== null && (
+
+ )}
+ {problems.length > 0 && (
+
+ {problems.map((problem, index) => (
+ {problem}
+ ))}
+
+ )}
+
+ )
+}
diff --git a/src/routes/admin/events/$id.tsx b/src/routes/admin/events/$id.tsx
new file mode 100644
index 0000000..4db109e
--- /dev/null
+++ b/src/routes/admin/events/$id.tsx
@@ -0,0 +1,357 @@
+import {
+ createFileRoute,
+ Link,
+ notFound,
+ useNavigate,
+} from '@tanstack/react-router'
+import { createServerFn } from '@tanstack/react-start'
+import { useQuery, useQueryClient } from '@tanstack/react-query'
+import { useEffect, useState } from 'react'
+import { getAdminEventDetail } from '#/server/admin/event-list.ts'
+import { getDefaultDb } from '#/server/auth/session-store.ts'
+import { allowedMediaHosts } from '#/server/media/allowed-hosts.ts'
+import { fetchViewerState } from '#/server/pages/viewer-fn.ts'
+import { ErrorState, LoadingState } from '#/components/PageStates.tsx'
+import {
+ AdminPrimaryButton,
+ AdminSecondaryButton,
+ AdminTextArea,
+ AdminTextField,
+} from '#/components/admin/form.tsx'
+import {
+ ConflictBanner,
+ FormMessage,
+ LoginRequiredBanner,
+ ValidationProblems,
+ WarningList,
+} from '#/components/admin/Alerts.tsx'
+import { postJson } from '#/lib/admin-api.ts'
+import { eventStatusLabel } from '#/lib/admin-labels.ts'
+import { mediaUrlWarning } from '#/lib/media-url.ts'
+import type { AdminEventDetail } from '#/server/admin/event-list.ts'
+
+const loadAdminEventEditor = createServerFn({ method: 'GET' })
+ .validator((input: unknown) => input as { id: string })
+ .handler(async ({ data }) => {
+ const db = await getDefaultDb()
+ const detail = await getAdminEventDetail(db, data.id)
+ return { detail, mediaAllowedHosts: allowedMediaHosts() }
+ })
+
+export const Route = createFileRoute('/admin/events/$id')({
+ loader: ({ params, context }) =>
+ context.queryClient.ensureQueryData({
+ queryKey: ['admin-event-editor', params.id],
+ queryFn: () => loadAdminEventEditor({ data: { id: params.id } }),
+ }),
+ component: AdminEventEditorPage,
+})
+
+function AdminEventEditorPage() {
+ const { id } = Route.useParams()
+ const queryClient = useQueryClient()
+ const editorQuery = useQuery({
+ queryKey: ['admin-event-editor', id],
+ queryFn: () => loadAdminEventEditor({ data: { id } }),
+ })
+ const viewerQuery = useQuery({
+ queryKey: ['viewer'],
+ queryFn: fetchViewerState,
+ staleTime: 60_000,
+ })
+
+ if (editorQuery.isPending) {
+ return
+ }
+ if (editorQuery.isError) {
+ return (
+
+ )
+ }
+ const detail = editorQuery.data.detail
+ if (detail === null) {
+ throw notFound()
+ }
+
+ return (
+
+ queryClient.invalidateQueries({ queryKey: ['admin-event-editor', id] })
+ }
+ />
+ )
+}
+
+function EventEditor({
+ detail,
+ mediaAllowedHosts,
+ isLeadership,
+ onReload,
+}: {
+ detail: AdminEventDetail
+ mediaAllowedHosts: string[]
+ isLeadership: boolean
+ onReload: () => Promise
+}) {
+ const navigate = useNavigate()
+ const [form, setForm] = useState({
+ title: detail.title,
+ slug: detail.slug,
+ description: detail.description ?? '',
+ thumbnailUrl: detail.thumbnailUrl ?? '',
+ startDate: detail.startDate ?? '',
+ endDate: detail.endDate ?? '',
+ })
+ const [version, setVersion] = useState(detail.version)
+ const [busy, setBusy] = useState(false)
+ const [problems, setProblems] = useState([])
+ const [message, setMessage] = useState(null)
+ const [loginUrl, setLoginUrl] = useState(null)
+ const [conflictMessage, setConflictMessage] = useState(null)
+ // Permanent deletion confirmation: the event title must be typed in (spec 6.4).
+ const [deleteConfirmation, setDeleteConfirmation] = useState('')
+ const [deleteSummary, setDeleteSummary] = useState(null)
+
+ // Capture the initial state only at load time (the key re-mounts it).
+ const [initialSnapshot] = useState(() => JSON.stringify(form))
+ const isDirty = JSON.stringify(form) !== initialSnapshot
+
+ useEffect(() => {
+ if (!isDirty) return
+ const handler = (event: BeforeUnloadEvent) => {
+ event.preventDefault()
+ }
+ window.addEventListener('beforeunload', handler)
+ return () => window.removeEventListener('beforeunload', handler)
+ }, [isDirty])
+
+ // The server rejects an invalid thumbnail host even in drafts (spec 6.1),
+ // so we surface it here while editing.
+ const thumbnailWarning = mediaUrlWarning(
+ 'Thumbnail URL',
+ form.thumbnailUrl,
+ mediaAllowedHosts,
+ )
+
+ function patch(partial: Partial) {
+ setForm((prev) => ({ ...prev, ...partial }))
+ }
+
+ async function call(
+ action: string,
+ body: Record,
+ successMessage?: string,
+ ): Promise {
+ setBusy(true)
+ setProblems([])
+ setMessage(null)
+ setLoginUrl(null)
+ setConflictMessage(null)
+ setDeleteSummary(null)
+ const result = await postJson<{
+ version?: number
+ slug?: string
+ detachedVideoCount?: number
+ }>(`/api/admin/events/${detail.id}/${action}`, { version, ...body })
+ setBusy(false)
+ if (result.ok) {
+ if (typeof result.data.version === 'number') {
+ setVersion(result.data.version)
+ }
+ if (result.data.slug !== undefined && result.data.slug !== form.slug) {
+ patch({ slug: result.data.slug })
+ }
+ if (
+ typeof result.data.detachedVideoCount === 'number' &&
+ action === 'delete_permanent'
+ ) {
+ setDeleteSummary(
+ `Az esemény véglegesen törölve; ${result.data.detachedVideoCount} videó leválasztva.`,
+ )
+ await navigate({ to: '/admin/events' })
+ return true
+ }
+ setMessage(successMessage ?? null)
+ return true
+ }
+ const error = result.error
+ if (error.code === 'auth_required' && error.loginUrl !== undefined) {
+ setLoginUrl(error.loginUrl)
+ return false
+ }
+ if (error.code === 'conflict') {
+ setConflictMessage(error.message)
+ return false
+ }
+ if (error.problems !== undefined) {
+ setProblems(error.problems)
+ return false
+ }
+ setProblems([error.message])
+ return false
+ }
+
+ async function saveDraft(): Promise {
+ return call('update', form, 'Piszkozat elmentve.')
+ }
+
+ async function publish() {
+ if (isDirty && !(await saveDraft())) return
+ await call('publish', {}, 'Esemény publikálva.')
+ }
+
+ async function deletePermanently() {
+ await call('delete_permanent', { confirmationTitle: deleteConfirmation })
+ }
+
+ return (
+
+
+
{detail.title}
+
+ {eventStatusLabel(detail.status)} · v{version}
+
+ {/* Public page of a published event in a new tab, so editing isn't lost. */}
+ {detail.status === 'published' ? (
+
+ Megnyitás az oldalon ↗
+
+ ) : (
+
+ A nyilvános oldal csak publikált állapotban érhető el.
+
+ )}
+
+
+ {loginUrl !== null && }
+ {conflictMessage !== null && (
+ void onReload()}
+ />
+ )}
+ {problems.length > 0 && }
+ {message !== null && {message} }
+
+
+
+
patch({ title: value })}
+ required
+ maxLength={200}
+ />
+ patch({ slug: value })}
+ maxLength={200}
+ hint="Módosításkor a régi slug átirányításként megmarad."
+ />
+ patch({ startDate: value })}
+ hint="Publikáláshoz kötelező."
+ />
+ patch({ endDate: value })}
+ hint="Nem lehet korábbi a kezdésnél."
+ />
+
+ patch({ thumbnailUrl: value })}
+ hint="Opcionális; hiányában a legfrissebb látható videó thumbnailje jelenik meg. Csak https://v.bsstudio.hu fogadható el."
+ />
+
+ patch({ description: value })}
+ rows={5}
+ maxLength={10_000}
+ />
+
+
+
+ {detail.status !== 'published' && (
+ void publish()} disabled={busy}>
+ Publikálás
+
+ )}
+ {(detail.status === 'published' || detail.status === 'draft') && (
+ void call('archive', {}, 'Esemény archiválva.')}
+ disabled={busy}
+ >
+ Archiválás
+
+ )}
+
+
+ {isLeadership && (
+
+ Végleges törlés
+
+ A művelet azonnali és visszavonhatatlan. A hozzárendelt{' '}
+ {detail.attachedVideoIds.length} videó
+ leválasztásra kerül (a készülési dátumuk megmarad), a slug nem
+ használható fel újra. A megerősítéshez írd be az esemény címét:{' '}
+ {detail.title}
+
+ setDeleteConfirmation(event.target.value)}
+ placeholder={detail.title}
+ className="h-10 w-full max-w-md border-b border-(--nav-border-b) bg-(--nav-search-bg) px-2 outline-none"
+ />
+
+
void deletePermanently()}
+ disabled={busy || deleteConfirmation.trim() !== detail.title}
+ confirm={`Biztosan VÉGLEGesen törlöd „${detail.title}" eseményt? ${detail.attachedVideoIds.length} videó válik le róla.`}
+ >
+ Végleges törlés
+
+
+ {deleteSummary !== null && {deleteSummary} }
+
+ )}
+
+
+
void saveDraft()}
+ disabled={busy || !isDirty}
+ >
+ Piszkozat mentése
+
+ {!isDirty ? (
+
+ Nincs mentetlen változás.
+
+ ) : (
+
Mentetlen változások!
+ )}
+
+
+ )
+}
diff --git a/src/routes/admin/events/index.tsx b/src/routes/admin/events/index.tsx
new file mode 100644
index 0000000..a645249
--- /dev/null
+++ b/src/routes/admin/events/index.tsx
@@ -0,0 +1,307 @@
+import { createFileRoute, Link } from '@tanstack/react-router'
+import { createServerFn } from '@tanstack/react-start'
+import { useQuery } from '@tanstack/react-query'
+import { useState } from 'react'
+import {
+ getAdminEventList,
+ parseAdminEventFilters,
+} from '#/server/admin/event-list.ts'
+import { getDefaultDb } from '#/server/auth/session-store.ts'
+import {
+ parsePaginationNumber,
+ parseSearchPage,
+} from '#/server/shared/pagination.ts'
+import { ErrorState, LoadingState } from '#/components/PageStates.tsx'
+import { ResponsiveTable } from '#/components/admin/ResponsiveTable.tsx'
+import {
+ AdminSearchSelect,
+ FILTER_LABEL_CLASS,
+} from '#/components/admin/SearchSelect.tsx'
+import type { AdminColumn } from '#/components/admin/ResponsiveTable.tsx'
+import type { AdminEventListItem } from '#/server/admin/event-list.ts'
+import { EVENT_STATUS_OPTIONS, eventStatusLabel } from '#/lib/admin-labels.ts'
+import { formatEventIntervalHu } from '#/lib/format-date.ts'
+
+const loadAdminEventList = createServerFn({ method: 'GET' })
+ .validator(
+ (input: Record | undefined) =>
+ input ?? {},
+ )
+ .handler(async ({ data }) => {
+ const db = await getDefaultDb()
+ return getAdminEventList(db, {
+ page: parsePaginationNumber(data['page'], 1),
+ perPage: parsePaginationNumber(data['perPage'], 25),
+ filters: parseAdminEventFilters(data),
+ })
+ })
+
+// Type alias (not an interface) so it can be passed to the server function's
+// `Record` parameter with an implicit index signature.
+type AdminEventSearch = {
+ q?: string
+ status?: string
+ from?: string
+ to?: string
+ page?: number
+}
+
+export const Route = createFileRoute('/admin/events/')({
+ validateSearch: (search: Record): AdminEventSearch => ({
+ q: pickString(search, 'q'),
+ status: pickString(search, 'status'),
+ from: pickString(search, 'from'),
+ to: pickString(search, 'to'),
+ page: parseSearchPage(search['page']),
+ }),
+ loaderDeps: ({ search }) => ({ search }),
+ loader: ({ deps, context }) =>
+ context.queryClient.ensureQueryData({
+ queryKey: ['admin-event-list', deps.search],
+ queryFn: () => loadAdminEventList({ data: deps.search }),
+ }),
+ component: AdminEventListPage,
+})
+
+function pickString(
+ search: Record,
+ key: string,
+): string | undefined {
+ const value = search[key]
+ return typeof value === 'string' && value !== '' ? value : undefined
+}
+
+function AdminEventListPage() {
+ const navigate = Route.useNavigate()
+ const search = Route.useSearch()
+ const listQuery = useQuery({
+ queryKey: ['admin-event-list', search],
+ queryFn: () => loadAdminEventList({ data: search }),
+ })
+
+ return (
+
+
+
Események
+
+ Új esemény
+
+
+
+
+ navigate({
+ search: (prev) => ({ ...prev, ...patch, page: undefined }),
+ })
+ }
+ />
+
+ {listQuery.isPending && }
+ {listQuery.isError && (
+
+ )}
+ {listQuery.isSuccess &&
+ (listQuery.data.items.length === 0 ? (
+
+ {search.q !== undefined ||
+ search.status !== undefined ||
+ search.from !== undefined ||
+ search.to !== undefined
+ ? 'Nincs találat a megadott szűrőkkel.'
+ : 'Még nincs esemény.'}
+
+ ) : (
+ <>
+
+
+
+ navigate({
+ search: (prev) => ({
+ ...prev,
+ page:
+ listQuery.data.page - 1 <= 1
+ ? undefined
+ : listQuery.data.page - 1,
+ }),
+ })
+ }
+ className="rounded border border-(--nav-border-b) px-3 py-1 disabled:opacity-30"
+ >
+ ‹ Előző
+
+
+ {listQuery.data.page}. /{' '}
+ {Math.max(listQuery.data.totalPages, 1)}. oldal
+
+ = listQuery.data.totalPages}
+ onClick={() =>
+ navigate({
+ search: (prev) => ({
+ ...prev,
+ page: listQuery.data.page + 1,
+ }),
+ })
+ }
+ className="rounded border border-(--nav-border-b) px-3 py-1 disabled:opacity-30"
+ >
+ Következő ›
+
+
+ >
+ ))}
+
+ )
+}
+
+const eventColumns: Array> = [
+ {
+ key: 'title',
+ header: 'Cím',
+ primary: true,
+ render: (row) => (
+
+
+ {row.title}
+
+ {/* Public page of a published event; nothing to open for a draft. */}
+ {row.status === 'published' && (
+
+ ↗
+
+ )}
+
+ ),
+ },
+ {
+ key: 'date',
+ header: 'Dátum',
+ render: (row) =>
+ row.startDate !== null
+ ? formatEventIntervalHu(row.startDate, row.endDate)
+ : '—',
+ },
+ {
+ key: 'status',
+ header: 'Állapot',
+ render: (row) => eventStatusLabel(row.status),
+ },
+ {
+ key: 'videos',
+ header: 'Videók száma',
+ render: (row) => String(row.videoCount),
+ },
+ {
+ key: 'updated',
+ header: 'Utoljára módosította',
+ render: (row) =>
+ row.updatedByName !== null
+ ? `${row.updatedByName} (${formatDateShort(row.updatedAt)})`
+ : formatDateShort(row.updatedAt),
+ },
+]
+
+function formatDateShort(date: Date): string {
+ return new Intl.DateTimeFormat('hu-HU', {
+ timeZone: 'Europe/Budapest',
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ hour12: false,
+ }).format(date)
+}
+
+function EventFilters({
+ search,
+ onApply,
+}: {
+ search: AdminEventSearch
+ onApply: (patch: Partial>) => void
+}) {
+ const [q, setQ] = useState(search.q ?? '')
+ return (
+
+ )
+}
diff --git a/src/routes/admin/events/new.tsx b/src/routes/admin/events/new.tsx
new file mode 100644
index 0000000..e47d146
--- /dev/null
+++ b/src/routes/admin/events/new.tsx
@@ -0,0 +1,81 @@
+import { createFileRoute, useNavigate } from '@tanstack/react-router'
+import { useState } from 'react'
+import { postJson } from '#/lib/admin-api.ts'
+import { AdminPrimaryButton, AdminTextField } from '#/components/admin/form.tsx'
+import {
+ LoginRequiredBanner,
+ ValidationProblems,
+} from '#/components/admin/Alerts.tsx'
+
+export const Route = createFileRoute('/admin/events/new')({
+ component: NewEventPage,
+})
+
+function NewEventPage() {
+ const navigate = useNavigate()
+ const [title, setTitle] = useState('')
+ const [busy, setBusy] = useState(false)
+ const [problems, setProblems] = useState([])
+ const [loginUrl, setLoginUrl] = useState(null)
+ const [errorMessage, setErrorMessage] = useState(null)
+
+ async function submit() {
+ setBusy(true)
+ setProblems([])
+ setErrorMessage(null)
+ setLoginUrl(null)
+ // A draft only needs a title (spec 6.1).
+ const result = await postJson<{ id: string }>('/api/admin/events', {
+ title,
+ })
+ setBusy(false)
+ if (result.ok) {
+ await navigate({
+ to: '/admin/events/$id',
+ params: { id: result.data.id },
+ })
+ return
+ }
+ if (result.error.code === 'auth_required' && result.error.loginUrl) {
+ setLoginUrl(result.error.loginUrl)
+ return
+ }
+ if (result.error.problems !== undefined) {
+ setProblems(result.error.problems)
+ return
+ }
+ setErrorMessage(result.error.message)
+ }
+
+ return (
+
+ Új esemény
+ {loginUrl !== null && }
+
+
+ )
+}
diff --git a/src/routes/admin/homepage.tsx b/src/routes/admin/homepage.tsx
new file mode 100644
index 0000000..92db938
--- /dev/null
+++ b/src/routes/admin/homepage.tsx
@@ -0,0 +1,543 @@
+import { createFileRoute, redirect } from '@tanstack/react-router'
+import { createServerFn } from '@tanstack/react-start'
+import { useQuery, useQueryClient } from '@tanstack/react-query'
+import { useState } from 'react'
+import { getDefaultDb } from '#/server/auth/session-store.ts'
+import { getHomepageAdminData } from '#/server/admin/homepage-admin.ts'
+import { fetchLeadershipAreaAccess } from '#/server/pages/admin/access-fn.ts'
+import { ErrorState, LoadingState } from '#/components/PageStates.tsx'
+import {
+ AdminPrimaryButton,
+ AdminSecondaryButton,
+ AdminTextField,
+} from '#/components/admin/form.tsx'
+import {
+ FormMessage,
+ LoginRequiredBanner,
+ ValidationProblems,
+} from '#/components/admin/Alerts.tsx'
+import {
+ AdminSearchSelect,
+ FILTER_LABEL_CLASS,
+} from '#/components/admin/SearchSelect.tsx'
+import { postJson } from '#/lib/admin-api.ts'
+import { formatAdminDateTimeHu } from '#/lib/format-date.ts'
+import { youtubeUrlWarning } from '#/lib/youtube-url.ts'
+import type { SearchSelectOption } from '#/components/admin/SearchSelect.tsx'
+
+const loadHomepageAdmin = createServerFn({ method: 'GET' }).handler(
+ async () => {
+ const db = await getDefaultDb()
+ return getHomepageAdminData(db)
+ },
+)
+
+export const Route = createFileRoute('/admin/homepage')({
+ beforeLoad: async () => {
+ const access = await fetchLeadershipAreaAccess()
+ if (access.kind === 'login') {
+ throw redirect({ href: access.loginUrl })
+ }
+ if (access.kind === 'forbidden') {
+ throw redirect({ to: '/admin/videos' })
+ }
+ },
+ loader: ({ context }) =>
+ context.queryClient.ensureQueryData({
+ queryKey: ['admin-homepage'],
+ queryFn: loadHomepageAdmin,
+ }),
+ component: HomepageAdminPage,
+})
+
+function HomepageAdminPage() {
+ const queryClient = useQueryClient()
+ const dataQuery = useQuery({
+ queryKey: ['admin-homepage'],
+ queryFn: loadHomepageAdmin,
+ })
+
+ function refresh() {
+ void queryClient.invalidateQueries({ queryKey: ['admin-homepage'] })
+ }
+
+ return (
+
+
+ Live és kiemelés
+
+ {dataQuery.isPending && }
+ {dataQuery.isError && (
+
+ )}
+ {dataQuery.isSuccess && (
+
+ )}
+
+ )
+}
+
+type HomepageAdminPayload = Awaited>
+
+/** Video list for the searchable select. */
+function videoOptions(
+ videos: HomepageAdminPayload['selectableVideos'],
+): Array {
+ return videos.map((video) => ({ value: video.id, label: video.title }))
+}
+
+function HighlightSection({
+ data,
+ onChanged,
+}: {
+ data: HomepageAdminPayload
+ onChanged: () => void
+}) {
+ const [busy, setBusy] = useState(false)
+ const [problems, setProblems] = useState([])
+ const [message, setMessage] = useState(null)
+ const [loginUrl, setLoginUrl] = useState(null)
+ const [pendingId, setPendingId] = useState('')
+
+ async function call(body: Record, okMessage: string) {
+ setBusy(true)
+ setProblems([])
+ setMessage(null)
+ setLoginUrl(null)
+ const result = await postJson('/api/admin/highlight', body)
+ setBusy(false)
+ if (result.ok) {
+ setMessage(okMessage)
+ onChanged()
+ return
+ }
+ if (result.error.code === 'auth_required' && result.error.loginUrl) {
+ setLoginUrl(result.error.loginUrl)
+ return
+ }
+ setProblems(result.error.problems ?? [result.error.message])
+ }
+
+ return (
+
+ Kiemelt videó
+ {data.highlight.videoId !== null ? (
+
+ Jelenleg kiemelve:{' '}
+ {data.highlight.title ?? data.highlight.videoId}
+
+ ) : (
+
+ Nincs kiemelt videó; a homepage normál állapotot mutat.
+
+ )}
+
+
+
{
+ if (pendingId === '') {
+ return
+ }
+ void call({ videoId: pendingId }, 'Kiemelés beállítva.')
+ setPendingId('')
+ }}
+ >
+ Kiemelés
+
+ {data.highlight.videoId !== null && (
+
+ void call({ videoId: null }, 'Kiemelés eltávolítva.')
+ }
+ >
+ Kiemelés eltávolítása
+
+ )}
+
+
+ Csak publikált, publikus videó emelhető ki; archiválás, lomtár vagy
+ láthatóság-szűkítés esetén a kiemelés automatikusan megszűnik.
+
+ {loginUrl !== null && }
+ {problems.length > 0 && }
+ {message !== null && {message} }
+
+ )
+}
+
+function LiveSection({
+ data,
+ onChanged,
+}: {
+ data: HomepageAdminPayload
+ onChanged: () => void
+}) {
+ const [youtubeUrl, setYoutubeUrl] = useState('')
+ const [startsAt, setStartsAt] = useState('')
+ const [endsAt, setEndsAt] = useState('')
+ const [busy, setBusy] = useState(false)
+ const [problems, setProblems] = useState([])
+ const [message, setMessage] = useState(null)
+ const [loginUrl, setLoginUrl] = useState(null)
+
+ async function call(
+ url: string,
+ body: Record,
+ okMessage?: string,
+ ) {
+ setBusy(true)
+ setProblems([])
+ setMessage(null)
+ setLoginUrl(null)
+ const result = await postJson<{ activated?: boolean }>(url, body)
+ setBusy(false)
+ if (result.ok) {
+ if (result.data.activated === false) {
+ setMessage(
+ 'Az aktiválás nem sikerült (YouTube-hiba); a live ütemezetten marad.',
+ )
+ } else if (okMessage !== undefined) {
+ setMessage(okMessage)
+ }
+ onChanged()
+ return true
+ }
+ if (result.error.code === 'auth_required' && result.error.loginUrl) {
+ setLoginUrl(result.error.loginUrl)
+ return false
+ }
+ setProblems(result.error.problems ?? [result.error.message])
+ return false
+ }
+
+ function create() {
+ void call(
+ '/api/admin/live',
+ {
+ youtubeUrl,
+ startsAt:
+ startsAt === '' ? '' : new Date(`${startsAt}:00+02:00`).toISOString(),
+ endsAt:
+ endsAt === '' ? '' : new Date(`${endsAt}:00+02:00`).toISOString(),
+ },
+ 'Live ütemezve.',
+ ).then((ok) => {
+ if (ok) {
+ setYoutubeUrl('')
+ setStartsAt('')
+ setEndsAt('')
+ }
+ })
+ }
+
+ // The server uses the same parser before the oEmbed check,
+ // so it's worth flagging an unparsable URL already here (spec 9.3).
+ const urlWarning = youtubeUrlWarning(youtubeUrl)
+ const activeOrScheduled = data.live.filter((live) => live.status !== 'ended')
+ const ended = data.live.filter((live) => live.status === 'ended')
+
+ return (
+
+ Live
+
+
+
+ {activeOrScheduled.length === 0 ? (
+
+ Nincs ütemezett vagy futó live.
+
+ ) : (
+ activeOrScheduled.map((live) => (
+
+
+ {live.youtubeVideoId}
+
+ {live.status === 'active' ? 'Fut' : 'Ütemezett'}
+
+
+ {formatAdminDateTimeHu(live.startsAt)} –{' '}
+ {formatAdminDateTimeHu(live.endsAt)}
+
+ {live.activationError !== null && (
+
+ Aktiválási hiba: {live.activationError}
+
+ )}
+
+
+ {live.status === 'scheduled' && (
+ <>
+
+ void call(`/api/admin/live/${live.id}/start_now`, {})
+ }
+ >
+ Indítás most
+
+
+ void call(`/api/admin/live/${live.id}/delete`, {})
+ }
+ >
+ Törlés
+
+ >
+ )}
+ {live.status === 'active' && (
+
+ void call(`/api/admin/live/${live.id}/end_now`, {})
+ }
+ >
+ Lezárás most
+
+ )}
+
+
+ ))
+ )}
+
+ {ended.length > 0 && (
+
+
+ Befejezett live előzmény ({ended.length})
+
+
+ {ended.map((live) => (
+
+ {live.youtubeVideoId} · {formatAdminDateTimeHu(live.startsAt)} –{' '}
+ {formatAdminDateTimeHu(live.endsAt)} · korábbi live csak
+ másolatként ütemezhető újra
+
+ ))}
+
+
+ )}
+
+ {loginUrl !== null && }
+ {problems.length > 0 && }
+ {message !== null && {message} }
+
+ )
+}
+
+function AboutSection({
+ data,
+ onChanged,
+}: {
+ data: HomepageAdminPayload
+ onChanged: () => void
+}) {
+ const queryClient = useQueryClient()
+ const [selected, setSelected] = useState(
+ data.about.map((entry) => entry.videoId),
+ )
+ const [busy, setBusy] = useState(false)
+ const [problems, setProblems] = useState([])
+ const [message, setMessage] = useState(null)
+ const [pendingId, setPendingId] = useState('')
+
+ const titlesById = new Map()
+ for (const video of data.selectableVideos) {
+ titlesById.set(video.id, video.title)
+ }
+ for (const entry of data.about) {
+ if (entry.title !== null) {
+ titlesById.set(entry.videoId, entry.title)
+ }
+ }
+ const invalidIds = new Set(
+ data.about.filter((entry) => !entry.valid).map((entry) => entry.videoId),
+ )
+
+ async function save(nextSelected: string[]) {
+ const previousSelected = selected
+ setSelected(nextSelected)
+ setBusy(true)
+ setProblems([])
+ setMessage(null)
+ const result = await postJson('/api/admin/about', {
+ orderedVideoIds: nextSelected,
+ })
+ setBusy(false)
+ if (result.ok) {
+ setMessage('Rólunk-videók elmentve.')
+ void queryClient.invalidateQueries({ queryKey: ['about-page'] })
+ onChanged()
+ return
+ }
+ setSelected(previousSelected)
+ setProblems(result.error.problems ?? [result.error.message])
+ }
+
+ return (
+
+ Rólunk-videók
+
+ Legfeljebb hat, sorrendezett publikus videó jelenik meg a Rólunk
+ oldalon; az érvénytelenné vált elemek automatikusan kiesnek a
+ megjelenítésből. A változtatások automatikusan mentődnek.
+
+ {selected.length === 0 ? (
+
+ Nincs kiválasztott videó.
+
+ ) : (
+
+ {selected.map((videoId, index) => (
+
+
+ {titlesById.get(videoId) ?? videoId}
+ {invalidIds.has(videoId) && (
+
+ (érvénytelen — kiesik a megjelenítésből)
+
+ )}
+
+ {
+ const next = [...selected]
+ ;[next[index - 1], next[index]] = [
+ next[index],
+ next[index - 1],
+ ]
+ void save(next)
+ }}
+ className="ctrl-btn rounded px-1"
+ >
+ ↑
+
+ {
+ const next = [...selected]
+ ;[next[index + 1], next[index]] = [
+ next[index],
+ next[index + 1],
+ ]
+ void save(next)
+ }}
+ className="ctrl-btn rounded px-1"
+ >
+ ↓
+
+
+ void save(selected.filter((id) => id !== videoId))
+ }
+ className="rounded px-1 text-red-500 hover:bg-red-500/15 disabled:opacity-50"
+ >
+ ✕
+
+
+ ))}
+
+ )}
+
+
+
!selected.includes(video.id),
+ ),
+ )}
+ placeholder="Válassz publikus videót…"
+ searchPlaceholder="Videó keresése cím szerint…"
+ />
+
+
= 6 || busy || pendingId === ''}
+ onClick={() => {
+ if (pendingId === '') {
+ return
+ }
+ void save([...selected, pendingId])
+ setPendingId('')
+ }}
+ >
+ Hozzáadás
+
+
+ {problems.length > 0 && }
+ {message !== null && {message} }
+
+ )
+}
diff --git a/src/routes/admin/index.tsx b/src/routes/admin/index.tsx
new file mode 100644
index 0000000..674cbdd
--- /dev/null
+++ b/src/routes/admin/index.tsx
@@ -0,0 +1,8 @@
+import { createFileRoute, redirect } from '@tanstack/react-router'
+
+/** After login the Videos list opens (spec 12.1); there is no separate dashboard. */
+export const Route = createFileRoute('/admin/')({
+ beforeLoad: () => {
+ throw redirect({ to: '/admin/videos' })
+ },
+})
diff --git a/src/routes/admin/members.tsx b/src/routes/admin/members.tsx
new file mode 100644
index 0000000..a9c28c1
--- /dev/null
+++ b/src/routes/admin/members.tsx
@@ -0,0 +1,278 @@
+import { createFileRoute, redirect } from '@tanstack/react-router'
+import { createServerFn } from '@tanstack/react-start'
+import { useQuery, useQueryClient } from '@tanstack/react-query'
+import { useState } from 'react'
+import { getDefaultDb } from '#/server/auth/session-store.ts'
+import { getMemberDiagnostics } from '#/server/admin/member-diagnostics.ts'
+import { fetchLeadershipAreaAccess } from '#/server/pages/admin/access-fn.ts'
+import { getCachedOobConfig } from '#/server/config/load.ts'
+import { ErrorState, LoadingState } from '#/components/PageStates.tsx'
+import { AdminPrimaryButton } from '#/components/admin/form.tsx'
+import {
+ FormMessage,
+ LoginRequiredBanner,
+ ValidationProblems,
+} from '#/components/admin/Alerts.tsx'
+import { ResponsiveTable } from '#/components/admin/ResponsiveTable.tsx'
+import type { AdminColumn } from '#/components/admin/ResponsiveTable.tsx'
+import type { DiagnosticsProfile } from '#/server/admin/member-diagnostics.ts'
+import { postJson } from '#/lib/admin-api.ts'
+import { MEMBERSHIP_STATUS_LABELS } from '#/lib/admin-labels.ts'
+import { formatAdminDateTimeHu } from '#/lib/format-date.ts'
+
+const loadMemberDiagnostics = createServerFn({ method: 'GET' }).handler(
+ async () => {
+ const db = await getDefaultDb()
+ return getMemberDiagnostics(db)
+ },
+)
+
+/** The root of the Authentik admin UI, derived from the OOB issuer URL. */
+const loadAuthentikBaseUrl = createServerFn({ method: 'GET' }).handler(
+ async (): Promise => {
+ try {
+ const issuerUrl = getCachedOobConfig().authentik.issuerUrl
+ const url = new URL(issuerUrl)
+ return `${url.protocol}//${url.host}`
+ } catch {
+ return null
+ }
+ },
+)
+
+export const Route = createFileRoute('/admin/members')({
+ beforeLoad: async () => {
+ const access = await fetchLeadershipAreaAccess()
+ if (access.kind === 'login') {
+ throw redirect({ href: access.loginUrl })
+ }
+ if (access.kind === 'forbidden') {
+ throw redirect({ to: '/admin/videos' })
+ }
+ },
+ loader: ({ context }) =>
+ Promise.all([
+ context.queryClient.ensureQueryData({
+ queryKey: ['admin-member-diagnostics'],
+ queryFn: loadMemberDiagnostics,
+ }),
+ context.queryClient.ensureQueryData({
+ queryKey: ['admin-authentik-base'],
+ queryFn: loadAuthentikBaseUrl,
+ staleTime: Number.POSITIVE_INFINITY,
+ }),
+ ]),
+ component: MemberDiagnosticsPage,
+})
+
+function MemberDiagnosticsPage() {
+ const queryClient = useQueryClient()
+ const dataQuery = useQuery({
+ queryKey: ['admin-member-diagnostics'],
+ queryFn: loadMemberDiagnostics,
+ })
+ const authentikBase = useQuery({
+ queryKey: ['admin-authentik-base'],
+ queryFn: loadAuthentikBaseUrl,
+ staleTime: Number.POSITIVE_INFINITY,
+ })
+
+ function refresh() {
+ void queryClient.invalidateQueries({
+ queryKey: ['admin-member-diagnostics'],
+ })
+ }
+
+ return (
+
+ Tagok
+
+ {dataQuery.isPending && }
+ {dataQuery.isError && (
+
+ )}
+
+ {dataQuery.isSuccess &&
+ (() => {
+ const data = dataQuery.data
+ const hasSyncProblem =
+ data.summary.lastRunStatus === 'error' ||
+ data.summary.errorProfiles > 0 ||
+ data.runs.some((run) => run.status === 'error')
+ return (
+
+ {hasSyncProblem && (
+
+
Tartós szinkronhiba
+
+ {data.summary.lastRunMessage ??
+ `${data.summary.errorProfiles} profil szinkronhibás állapotban van.`}
+
+
+ )}
+
+
+
+
+
+ Utolsó szinkronfutások
+
+ {data.runs.length === 0 ? (
+
+ Még nem futott szinkron.
+
+ ) : (
+
+ {data.runs.slice(0, 10).map((run) => (
+
+
+ {run.status === 'ok' ? 'Sikeres' : 'Hibás'}
+ {' '}
+ ({run.trigger}) · {formatAdminDateTimeHu(run.startedAt)}{' '}
+ · {run.totalCount} profil, {run.changedCount} változás
+ {run.errorCount > 0 && `, ${run.errorCount} hiba`}
+ {run.message !== null && (
+ — {run.message}
+ )}
+
+ ))}
+
+ )}
+
+
+
+
+ Profilok ({data.summary.total}) — csak olvashatóan
+
+
+
+
+ {authentikBase.data !== null &&
+ authentikBase.data !== undefined && (
+
+ Authentik admin megnyitása (új fülön)
+
+ )}
+
+ )
+ })()}
+
+ )
+}
+
+function SyncSection({ onDone }: { onDone: () => void }) {
+ const [busy, setBusy] = useState(false)
+ const [problems, setProblems] = useState([])
+ const [message, setMessage] = useState(null)
+ const [loginUrl, setLoginUrl] = useState(null)
+
+ async function sync() {
+ setBusy(true)
+ setProblems([])
+ setMessage(null)
+ setLoginUrl(null)
+ const result = await postJson<{
+ result: {
+ status: string
+ totalCount: number
+ changedCount: number
+ errorCount: number
+ }
+ }>('/api/admin/members/sync', {})
+ setBusy(false)
+ if (result.ok) {
+ const runResult = result.data.result
+ setMessage(
+ runResult.status === 'ok'
+ ? `Szinkron kész: ${runResult.totalCount} profil, ${runResult.changedCount} változás.`
+ : 'A szinkron hibával zárult; lásd a futások listáját.',
+ )
+ onDone()
+ return
+ }
+ if (result.error.code === 'auth_required' && result.error.loginUrl) {
+ setLoginUrl(result.error.loginUrl)
+ return
+ }
+ setProblems([result.error.message])
+ }
+
+ return (
+
+
void sync()} disabled={busy}>
+ {busy ? 'Szinkron fut…' : 'Kézi szinkron indítása'}
+
+ {loginUrl !== null &&
}
+ {problems.length > 0 &&
}
+ {message !== null &&
{message} }
+
+ )
+}
+
+const profileColumns: Array> = [
+ {
+ key: 'name',
+ header: 'Név',
+ primary: true,
+ render: (row) => (
+ <>
+ {row.fullName}
+ {row.nickname !== null && (
+ ({row.nickname})
+ )}
+ >
+ ),
+ },
+ { key: 'username', header: 'Felhasználónév', render: (row) => row.username },
+ {
+ key: 'status',
+ header: 'Tagsági státusz',
+ render: (row) =>
+ MEMBERSHIP_STATUS_LABELS[row.membershipStatus] ?? row.membershipStatus,
+ },
+ {
+ key: 'leadership',
+ header: 'Vezetőség',
+ render: (row) => (row.isLeadership ? 'Igen' : '—'),
+ },
+ {
+ key: 'sync',
+ header: 'Szinkronállapot',
+ render: (row) =>
+ row.syncStatus === 'error' ? (
+
+ Hiba{row.lastSyncError !== null ? `: ${row.lastSyncError}` : ''}
+
+ ) : (
+ 'Rendben'
+ ),
+ },
+ {
+ key: 'joined',
+ header: 'Csatlakozási félév',
+ render: (row) => row.joinedSemesterRaw ?? '—',
+ },
+ {
+ key: 'lastSeen',
+ header: 'Utoljára látva',
+ render: (row) => formatAdminDateTimeHu(row.lastSeenAt),
+ },
+ {
+ key: 'vanished',
+ header: 'Eltűnt?',
+ render: (row) => (row.likelyVanished ? 'Valószínűleg eltűnt' : '—'),
+ },
+]
diff --git a/src/routes/admin/trash.tsx b/src/routes/admin/trash.tsx
new file mode 100644
index 0000000..babcecb
--- /dev/null
+++ b/src/routes/admin/trash.tsx
@@ -0,0 +1,237 @@
+import { createFileRoute, Link } from '@tanstack/react-router'
+import { createServerFn } from '@tanstack/react-start'
+import { useQuery, useQueryClient } from '@tanstack/react-query'
+import { useState } from 'react'
+import { getDefaultDb } from '#/server/auth/session-store.ts'
+import {
+ parsePaginationNumber,
+ parseSearchPage,
+} from '#/server/shared/pagination.ts'
+import { getTrashPage } from '#/server/admin/trash-admin.ts'
+import type { TrashPage } from '#/server/admin/trash-admin.ts'
+import { fetchViewerState } from '#/server/pages/viewer-fn.ts'
+import { ErrorState, LoadingState } from '#/components/PageStates.tsx'
+import { AdminPrimaryButton } from '#/components/admin/form.tsx'
+import { postJson } from '#/lib/admin-api.ts'
+import { formatAdminDateTimeHu } from '#/lib/format-date.ts'
+
+const loadTrashPage = createServerFn({ method: 'GET' })
+ .validator(
+ (input: Record | undefined) =>
+ input ?? {},
+ )
+ .handler(async ({ data }) => {
+ const db = await getDefaultDb()
+ return getTrashPage(db, {
+ page: parsePaginationNumber(data['page'], 1),
+ perPage: parsePaginationNumber(data['perPage'], 25),
+ })
+ })
+
+export const Route = createFileRoute('/admin/trash')({
+ validateSearch: (search: Record) => ({
+ page: parseSearchPage(search['page']),
+ }),
+ loaderDeps: ({ search }) => ({ page: search.page ?? 1 }),
+ loader: ({ deps, context }) =>
+ context.queryClient.ensureQueryData({
+ queryKey: ['admin-trash', deps.page],
+ queryFn: () => loadTrashPage({ data: { page: deps.page } }),
+ }),
+ component: TrashPageComponent,
+})
+
+function TrashPageComponent() {
+ const navigate = Route.useNavigate()
+ const queryClient = useQueryClient()
+ const page = Route.useSearch().page ?? 1
+ const trashQuery = useQuery({
+ queryKey: ['admin-trash', page],
+ queryFn: () => loadTrashPage({ data: { page } }),
+ })
+ const viewerQuery = useQuery({
+ queryKey: ['viewer'],
+ queryFn: fetchViewerState,
+ staleTime: 60_000,
+ })
+ const isLeadership = viewerQuery.data?.level === 'leadership'
+
+ function refresh() {
+ void queryClient.invalidateQueries({ queryKey: ['admin-trash', page] })
+ void queryClient.invalidateQueries({ queryKey: ['viewer'] })
+ }
+
+ return (
+
+ Lomtár
+
+ A lomtárban lévő videók kapcsolatai megmaradnak; a napi feladat a
+ legalább 30 napja lomtárban lévő rekordokat véglegesen törli (a külső
+ médiafájlokat nem). A visszaállítás vezetőségi jog; a visszaállított
+ videó archivált állapotba kerül.
+
+
+ {trashQuery.isPending && }
+ {trashQuery.isError && (
+
+ )}
+
+ {trashQuery.isSuccess &&
+ (trashQuery.data.items.length === 0 ? (
+
+ A lomtár üres.
+
+ ) : (
+
+ {trashQuery.data.expiredCount > 0 && (
+
+ {trashQuery.data.expiredCount} videót a napi feladat már
+ véglegesen töröl az elkövetkező futásakor.
+
+ )}
+ {trashQuery.data.items.map((item) => (
+
+ ))}
+
+
+ navigate({
+ search: (prev) => ({
+ ...prev,
+ page:
+ trashQuery.data.page - 1 <= 1
+ ? undefined
+ : trashQuery.data.page - 1,
+ }),
+ })
+ }
+ className="ctrl-btn rounded border border-(--nav-border-b) px-3 py-1"
+ >
+ ‹ Előző
+
+
+ {trashQuery.data.page}. /{' '}
+ {Math.max(trashQuery.data.totalPages, 1)}. oldal ·{' '}
+ {trashQuery.data.total} videó
+
+ = trashQuery.data.totalPages}
+ onClick={() =>
+ navigate({
+ search: (prev) => ({
+ ...prev,
+ page: trashQuery.data.page + 1,
+ }),
+ })
+ }
+ className="ctrl-btn rounded border border-(--nav-border-b) px-3 py-1"
+ >
+ Következő ›
+
+
+
+ ))}
+
+ )
+}
+
+function TrashRow({
+ item,
+ isLeadership,
+ onChanged,
+}: {
+ item: TrashPageItem
+ isLeadership: boolean
+ onChanged: () => void
+}) {
+ return (
+
+
+
+
+ {item.title}
+
+
+ Lomtárba helyezte: {item.trashedByName ?? 'ismeretlen'} ·{' '}
+ {formatAdminDateTimeHu(item.trashedAt)} · hátralévő idő kb.{' '}
+ {item.remainingDays} nap
+
+ {isLeadership && (
+ <>
+
+
+ >
+ )}
+
+
+ )
+}
+
+type TrashPageItem = TrashPage['items'][number]
+
+function RestoreButton({
+ videoId,
+ version,
+ onDone,
+}: {
+ videoId: string
+ version: number
+ onDone: () => void
+}) {
+ const [busy, setBusy] = useState(false)
+ const [error, setError] = useState(null)
+
+ async function restore() {
+ setBusy(true)
+ setError(null)
+ const result = await postJson(`/api/admin/videos/${videoId}/restore`, {
+ version,
+ })
+ setBusy(false)
+ if (result.ok) {
+ onDone()
+ return
+ }
+ if (result.error.code === 'auth_required' && result.error.loginUrl) {
+ setError('A bejelentkezés lejárt; jelentkezz be újra, majd próbáld újra.')
+ return
+ }
+ setError(result.error.message)
+ }
+
+ return (
+
+ void restore()} disabled={busy}>
+ Visszaállítás
+
+ {error !== null && (
+
+ {error}
+
+ )}
+
+ )
+}
diff --git a/src/routes/admin/videos/$id.tsx b/src/routes/admin/videos/$id.tsx
new file mode 100644
index 0000000..ab00c76
--- /dev/null
+++ b/src/routes/admin/videos/$id.tsx
@@ -0,0 +1,966 @@
+import { createFileRoute, Link, notFound } from '@tanstack/react-router'
+import { createServerFn } from '@tanstack/react-start'
+import { useQuery, useQueryClient } from '@tanstack/react-query'
+import { useEffect, useState } from 'react'
+import {
+ getAdminVideoDetail,
+ getAdminVideoEditorOptions,
+} from '#/server/admin/video-detail.ts'
+import { getDefaultDb } from '#/server/auth/session-store.ts'
+import { allowedMediaHosts } from '#/server/media/allowed-hosts.ts'
+import { fetchViewerState } from '#/server/pages/viewer-fn.ts'
+import { ErrorState, LoadingState } from '#/components/PageStates.tsx'
+import {
+ AdminPrimaryButton,
+ AdminSecondaryButton,
+ AdminTextArea,
+ AdminTextField,
+} from '#/components/admin/form.tsx'
+import {
+ ConflictBanner,
+ FormMessage,
+ LoginRequiredBanner,
+ ValidationProblems,
+ WarningList,
+} from '#/components/admin/Alerts.tsx'
+import { AdminSearchSelect } from '#/components/admin/SearchSelect.tsx'
+import { VideoVisibility } from '#/components/admin/VideoVisibility.tsx'
+import { postJson } from '#/lib/admin-api.ts'
+import { VISIBILITY_OPTIONS, videoStatusLabel } from '#/lib/admin-labels.ts'
+import { formatAdminDateTimeHu } from '#/lib/format-date.ts'
+import { mediaUrlWarnings } from '#/lib/media-url.ts'
+import {
+ parseSongList,
+ serializeSongList,
+ stripSongDashes,
+} from '#/lib/song-list.ts'
+import type { SearchSelectOption } from '#/components/admin/SearchSelect.tsx'
+import type { SongEntry } from '#/lib/song-list.ts'
+import type { AdminVideoDetail } from '#/server/admin/video-detail.ts'
+
+const loadAdminVideoEditor = createServerFn({ method: 'GET' })
+ .validator((input: unknown) => input as { id: string })
+ .handler(async ({ data }) => {
+ const db = await getDefaultDb()
+ const detail = await getAdminVideoDetail(db, data.id)
+ if (detail === null) {
+ return null
+ }
+ const options = await getAdminVideoEditorOptions(db, data.id)
+ return { detail, options, mediaAllowedHosts: allowedMediaHosts() }
+ })
+
+export const Route = createFileRoute('/admin/videos/$id')({
+ loader: ({ params, context }) =>
+ context.queryClient.ensureQueryData({
+ queryKey: ['admin-video-editor', params.id],
+ queryFn: () => loadAdminVideoEditor({ data: { id: params.id } }),
+ }),
+ component: AdminVideoEditorPage,
+})
+
+interface EditorForm {
+ title: string
+ slug: string
+ description: string
+ guests: string
+ songs: string
+ videoUrl: string
+ thumbnailUrl: string
+ visibility: string
+ eventId: string
+ recordedAt: string
+ publishedAtLocal: string
+}
+
+function formFromDetail(detail: AdminVideoDetail): EditorForm {
+ return {
+ title: detail.title,
+ slug: detail.slug,
+ description: detail.description ?? '',
+ guests: detail.guests ?? '',
+ songs: detail.songs ?? '',
+ videoUrl: detail.videoUrl ?? '',
+ thumbnailUrl: detail.thumbnailUrl ?? '',
+ visibility: detail.visibility,
+ eventId: detail.eventId ?? '',
+ recordedAt: detail.recordedAt ?? '',
+ publishedAtLocal:
+ detail.publishedAt !== null ? toDatetimeLocal(detail.publishedAt) : '',
+ }
+}
+
+function toDatetimeLocal(date: Date): string {
+ // Local time in Europe/Budapest for the datetime-local field.
+ const formatter = new Intl.DateTimeFormat('sv-SE', {
+ timeZone: 'Europe/Budapest',
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ })
+ return formatter.format(date).replace('T', 'T')
+}
+
+function AdminVideoEditorPage() {
+ const { id } = Route.useParams()
+ const queryClient = useQueryClient()
+ const editorQuery = useQuery({
+ queryKey: ['admin-video-editor', id],
+ queryFn: () => loadAdminVideoEditor({ data: { id } }),
+ })
+ const viewerQuery = useQuery({
+ queryKey: ['viewer'],
+ queryFn: fetchViewerState,
+ staleTime: 60_000,
+ })
+
+ if (editorQuery.isPending) {
+ return
+ }
+ if (editorQuery.isError) {
+ return (
+
+ )
+ }
+ const payload = editorQuery.data
+ if (payload === null) {
+ throw notFound()
+ }
+
+ return (
+
+ queryClient.invalidateQueries({ queryKey: ['admin-video-editor', id] })
+ }
+ />
+ )
+}
+
+function VideoEditor({
+ detail,
+ options,
+ mediaAllowedHosts,
+ isLeadership,
+ onReload,
+}: {
+ detail: AdminVideoDetail
+ options: Awaited>
+ mediaAllowedHosts: string[]
+ isLeadership: boolean
+ onReload: () => Promise
+}) {
+ const [form, setForm] = useState(() => formFromDetail(detail))
+ const [version, setVersion] = useState(detail.version)
+ const [tagIds, setTagIds] = useState(detail.tagIds)
+ const [staff, setStaff] = useState<
+ Array<{ roleId: string; memberSub: string }>
+ >(detail.staffAssignments)
+ const [relatedIds, setRelatedIds] = useState(detail.relatedVideoIds)
+
+ const [savedFormSnapshot, setSavedFormSnapshot] = useState(() =>
+ JSON.stringify(formFromDetail(detail)),
+ )
+ const [savedTagSnapshot, setSavedTagSnapshot] = useState(() =>
+ JSON.stringify(detail.tagIds),
+ )
+ const [savedStaffSnapshot, setSavedStaffSnapshot] = useState(() =>
+ JSON.stringify(detail.staffAssignments),
+ )
+ const [savedRelatedSnapshot, setSavedRelatedSnapshot] = useState(() =>
+ JSON.stringify(detail.relatedVideoIds),
+ )
+
+ const [busy, setBusy] = useState(false)
+ const [problems, setProblems] = useState([])
+ const [message, setMessage] = useState(null)
+ const [loginUrl, setLoginUrl] = useState(null)
+ const [conflictMessage, setConflictMessage] = useState(null)
+
+ const isDirty =
+ JSON.stringify(form) !== savedFormSnapshot ||
+ JSON.stringify(tagIds) !== savedTagSnapshot ||
+ JSON.stringify(staff) !== savedStaffSnapshot ||
+ JSON.stringify(relatedIds) !== savedRelatedSnapshot
+
+ // Confirm before navigating away with unsaved changes (spec 5.3).
+ useEffect(() => {
+ if (!isDirty) return
+ const handler = (event: BeforeUnloadEvent) => {
+ event.preventDefault()
+ }
+ window.addEventListener('beforeunload', handler)
+ return () => window.removeEventListener('beforeunload', handler)
+ }, [isDirty])
+
+ const eventOptions: Array = options.events.map(
+ (item) => ({ value: item.id, label: item.title }),
+ )
+
+ // Live validation: a disallowed host is already visible while editing.
+ const mediaWarnings = mediaUrlWarnings(
+ { videoUrl: form.videoUrl, thumbnailUrl: form.thumbnailUrl },
+ mediaAllowedHosts,
+ )
+
+ function patch(partial: Partial) {
+ setForm((prev) => ({ ...prev, ...partial }))
+ }
+
+ /**
+ * Media URL validation before saving (spec 5.4). A broken URL can be saved
+ * in a draft, but only deliberately: we ask for confirmation.
+ */
+ function confirmMediaWarnings(): boolean {
+ if (mediaWarnings.length === 0) {
+ return true
+ }
+ return window.confirm(
+ `${mediaWarnings.join('\n')}\n\nMented így, a hibás URL-lel?`,
+ )
+ }
+
+ async function call(
+ action: string,
+ body: Record,
+ successMessage?: string,
+ ): Promise {
+ setBusy(true)
+ setProblems([])
+ setMessage(null)
+ setLoginUrl(null)
+ setConflictMessage(null)
+ const result = await postJson<{
+ version?: number
+ warnings?: string[]
+ slug?: string
+ }>(`/api/admin/videos/${detail.id}/${action}`, { version, ...body })
+ setBusy(false)
+ if (result.ok) {
+ if (typeof result.data.version === 'number') {
+ setVersion(result.data.version)
+ }
+ const savedSlug = result.data.slug ?? form.slug
+ if (savedSlug !== form.slug) {
+ patch({ slug: savedSlug })
+ }
+ if (action === 'update') {
+ setSavedFormSnapshot(JSON.stringify({ ...form, slug: savedSlug }))
+ } else if (action === 'tags') {
+ setSavedTagSnapshot(JSON.stringify(tagIds))
+ } else if (action === 'staff') {
+ setSavedStaffSnapshot(JSON.stringify(staff))
+ } else if (action === 'related') {
+ setSavedRelatedSnapshot(JSON.stringify(relatedIds))
+ }
+ if (
+ Array.isArray(result.data.warnings) &&
+ result.data.warnings.length > 0
+ ) {
+ setMessage(result.data.warnings.join(' '))
+ } else if (successMessage !== undefined) {
+ setMessage(successMessage)
+ }
+ return true
+ }
+ const error = result.error
+ if (error.code === 'auth_required' && error.loginUrl !== undefined) {
+ setLoginUrl(error.loginUrl)
+ return false
+ }
+ if (error.code === 'conflict') {
+ setConflictMessage(error.message)
+ return false
+ }
+ if (error.problems !== undefined) {
+ setProblems(error.problems)
+ return false
+ }
+ setProblems([error.message])
+ return false
+ }
+
+ async function saveDraft(): Promise {
+ if (!confirmMediaWarnings()) {
+ return false
+ }
+ return call(
+ 'update',
+ {
+ title: form.title,
+ slug: form.slug,
+ description: form.description,
+ guests: form.guests,
+ songs: form.songs,
+ videoUrl: form.videoUrl,
+ thumbnailUrl: form.thumbnailUrl,
+ visibility: form.visibility,
+ eventId: form.eventId === '' ? null : form.eventId,
+ recordedAt: form.recordedAt === '' ? null : form.recordedAt,
+ publishedAt:
+ form.publishedAtLocal === ''
+ ? null
+ : new Date(`${form.publishedAtLocal}:00+02:00`).toISOString(),
+ },
+ 'Piszkozat elmentve.',
+ )
+ }
+
+ async function publish() {
+ const saved = await saveCore()
+ if (!saved) return
+ await call('publish', {}, 'Videó publikálva.')
+ }
+
+ /** Save the fields before publishing so the status change works on fresh data. */
+ async function saveCore(): Promise {
+ if (!isDirty) return true
+ return saveDraft()
+ }
+
+ async function archiveAction() {
+ await call('archive', {}, 'Videó archiválva.')
+ }
+
+ async function trashAction() {
+ await call('trash', {}, 'Videó lomtárba helyezve.')
+ }
+
+ async function restoreAction() {
+ await call('restore', {}, 'Videó visszaállítva archivált állapotba.')
+ }
+
+ async function saveTags() {
+ await call('tags', { tagIds }, 'Címkék elmentve.')
+ }
+
+ async function saveStaff() {
+ await call('staff', { assignments: staff }, 'Stáblista elmentve.')
+ }
+
+ async function saveRelated() {
+ await call(
+ 'related',
+ { relatedVideoIds: relatedIds },
+ 'Kapcsolódó videók elmentve.',
+ )
+ }
+
+ const statusLabel = videoStatusLabel(detail.status)
+
+ return (
+
+
+
{detail.title}
+
+
+ {statusLabel} ·
+
+ · v{version}
+
+
+ {/* Public page of a published video in a new tab, so editing isn't lost. */}
+ {detail.status === 'published' ? (
+
+ Megnyitás az oldalon ↗
+
+ ) : (
+
+ A nyilvános oldal csak publikált állapotban érhető el.
+
+ )}
+
+
+ {loginUrl !== null && }
+ {conflictMessage !== null && (
+ void onReload()}
+ />
+ )}
+ {problems.length > 0 && }
+ {message !== null && {message} }
+
+
+ Alapadatok
+
+
patch({ title: value })}
+ required
+ maxLength={200}
+ />
+ patch({ slug: value })}
+ maxLength={200}
+ hint="Módosításkor a régi slug átirányításként megmarad."
+ />
+
+ patch({ description: value })}
+ rows={5}
+ maxLength={10_000}
+ />
+
+
patch({ guests: value })}
+ maxLength={5000}
+ hint="Soronként egy név."
+ />
+ patch({ songs: value })}
+ />
+
+
+
+
+ Média
+ patch({ videoUrl: value })}
+ hint="Csak https://v.bsstudio.hu; publikáláskor hálózati ellenőrzés fut."
+ />
+ patch({ thumbnailUrl: value })}
+ hint="Hibás URL piszkozatban menthető, publikálni nem lehet vele."
+ />
+
+
+
+
+ Besorolás
+
+
patch({ visibility: value })}
+ options={VISIBILITY_OPTIONS}
+ />
+ patch({ eventId: value })}
+ options={eventOptions}
+ placeholder="Nincs esemény"
+ emptyOptionLabel="Nincs esemény"
+ searchPlaceholder="Esemény keresése…"
+ />
+ patch({ recordedAt: value })}
+ hint="Egynapos eseménynél automatikusan kitöltődik, ha üres."
+ />
+ patch({ publishedAtLocal: value })}
+ hint="Üresen hagyva publikáláskor a mostani időpont kerül rá; csak múltbeli időpont adható meg."
+ />
+
+
+
+ void saveTags()}
+ busy={busy}
+ />
+
+ void saveStaff()}
+ busy={busy}
+ />
+
+ [c.id, c.title]))
+ }
+ selectedIds={relatedIds}
+ onChange={setRelatedIds}
+ onSave={() => void saveRelated()}
+ busy={busy}
+ />
+
+
+ Állapotműveletek
+
+ Utolsó módosítás: {formatAdminDateTimeHu(detail.updatedAt)}
+
+
+ {detail.status !== 'published' && (
+
void publish()} disabled={busy}>
+ Publikálás
+
+ )}
+ {(detail.status === 'published' || detail.status === 'draft') && (
+
void archiveAction()}
+ disabled={busy}
+ >
+ Archiválás
+
+ )}
+ {detail.status !== 'trash' && (
+
void trashAction()}
+ disabled={busy}
+ confirm={`Biztosan lomtárba helyezed „${detail.title}" videót? A lomtárból csak vezetőség tudja visszaállítani.`}
+ >
+ Lomtárba helyezés
+
+ )}
+ {detail.status === 'trash' && isLeadership && (
+
void restoreAction()}
+ disabled={busy}
+ >
+ Visszaállítás archivált állapotba
+
+ )}
+
+
+
+
+
void saveDraft()}
+ disabled={busy || !isDirty}
+ >
+ {detail.status === 'published' ? 'Mentés' : 'Piszkozat mentése'}
+
+ {!isDirty ? (
+
+ Nincs mentetlen változás.
+
+ ) : (
+
Mentetlen változások!
+ )}
+
+
+ )
+}
+
+function TagSection({
+ tags,
+ selected,
+ onChange,
+ onSave,
+ busy,
+}: {
+ tags: Array<{ id: string; name: string }>
+ selected: string[]
+ onChange: (ids: string[]) => void
+ onSave: () => void
+ busy: boolean
+}) {
+ return (
+
+ Címkék
+ {tags.length === 0 ? (
+
+ Még nincs címke a katalógusban.
+
+ ) : (
+
+ {tags.map((tag) => (
+
+
+ onChange(
+ selected.includes(tag.id)
+ ? selected.filter((item) => item !== tag.id)
+ : [...selected, tag.id],
+ )
+ }
+ />
+ {tag.name}
+
+ ))}
+
+ )}
+
+ Csak meglévő címke rendelhető; új címkét a vezetőség készíthet a
+ Címkekatalógusban.
+
+
+
+ )
+}
+
+function StaffSection({
+ roles,
+ members,
+ assignments,
+ onChange,
+ onSave,
+ busy,
+}: {
+ roles: Array<{ id: string; name: string }>
+ members: Array<{ sub: string; fullName: string }>
+ assignments: Array<{ roleId: string; memberSub: string }>
+ onChange: (assignments: Array<{ roleId: string; memberSub: string }>) => void
+ onSave: () => void
+ busy: boolean
+}) {
+ const roleOptions: Array = roles.map((role) => ({
+ value: role.id,
+ label: role.name,
+ }))
+ const memberOptions: Array = members.map((member) => ({
+ value: member.sub,
+ label: member.fullName,
+ }))
+
+ return (
+
+ Stáblista
+ {assignments.length === 0 && (
+
+ Még nincs stábtag hozzárendelve.
+
+ )}
+ {assignments.map((assignment, index) => (
+
+
+ onChange(
+ assignments.map((item, i) =>
+ i === index ? { ...item, roleId } : item,
+ ),
+ )
+ }
+ options={roleOptions}
+ placeholder="Válassz szerepet…"
+ searchPlaceholder="Szerep keresése…"
+ />
+
+ onChange(
+ assignments.map((item, i) =>
+ i === index ? { ...item, memberSub } : item,
+ ),
+ )
+ }
+ options={memberOptions}
+ placeholder="Válassz tagot…"
+ searchPlaceholder="Tag keresése…"
+ />
+ onChange(assignments.filter((_, i) => i !== index))}
+ >
+ Eltávolítás
+
+
+ ))}
+
+
+ onChange([
+ ...assignments,
+ { roleId: roles[0]?.id ?? '', memberSub: members[0]?.sub ?? '' },
+ ])
+ }
+ >
+ + Stábtag hozzáadása
+
+
+ Stáblista mentése
+
+
+
+ )
+}
+
+function RelatedSection({
+ candidates,
+ titlesById,
+ selectedIds,
+ onChange,
+ onSave,
+ busy,
+}: {
+ candidates: Array<{ id: string; title: string }>
+ titlesById: Map
+ selectedIds: string[]
+ onChange: (ids: string[]) => void
+ onSave: () => void
+ busy: boolean
+}) {
+ const [pendingId, setPendingId] = useState('')
+ const remaining: Array = candidates
+ .filter((candidate) => !selectedIds.includes(candidate.id))
+ .map((candidate) => ({ value: candidate.id, label: candidate.title }))
+
+ return (
+
+ Kapcsolódó videók
+
+ Ha itt üresen hagyod, automatikus ajánlás jelenik meg (azonos esemény,
+ majd közös címkék). Csak publikált videó választható, sorrendben.
+
+ {selectedIds.length === 0 ? (
+
+ Nincs manuális lista.
+
+ ) : (
+
+ {selectedIds.map((relatedId, index) => (
+
+ {titlesById.get(relatedId) ?? relatedId}
+ {
+ const next = [...selectedIds]
+ ;[next[index - 1], next[index]] = [
+ next[index],
+ next[index - 1],
+ ]
+ onChange(next)
+ }}
+ className="px-1 disabled:opacity-30"
+ >
+ ↑
+
+ {
+ const next = [...selectedIds]
+ ;[next[index + 1], next[index]] = [
+ next[index],
+ next[index + 1],
+ ]
+ onChange(next)
+ }}
+ className="px-1 disabled:opacity-30"
+ >
+ ↓
+
+
+ onChange(selectedIds.filter((id) => id !== relatedId))
+ }
+ className="px-1 text-red-500"
+ >
+ ✕
+
+
+ ))}
+
+ )}
+
+
+
{
+ if (pendingId === '') {
+ return
+ }
+ onChange([...selectedIds, pendingId])
+ setPendingId('')
+ }}
+ >
+ Hozzáadás
+
+
+ Kapcsolódók mentése
+
+
+
+ )
+}
+
+const SONG_INPUT_CLASS =
+ 'h-10 min-w-0 border-b border-(--nav-border-b) bg-(--nav-search-bg) px-2 outline-none focus:border-(--orange)'
+const SONGS_MAX_LENGTH = 5000
+
+/**
+ * "Songs used" field. The stored format is one `Artist - Song title` per line
+ * (spec 5.2); if the existing content is parsable that way, we show a
+ * two-input list, otherwise it stays a free-text field so hand-written
+ * content isn't damaged. In structured mode the dash is the separator,
+ * so it cannot be typed into the fields.
+ */
+function SongsField({
+ value,
+ onChange,
+}: {
+ value: string
+ onChange: (value: string) => void
+}) {
+ const [entries, setEntries] = useState | null>(() =>
+ parseSongList(value),
+ )
+ const [dashBlocked, setDashBlocked] = useState(false)
+
+ function commit(next: Array) {
+ setEntries(next)
+ onChange(serializeSongList(next))
+ }
+
+ function setField(index: number, field: keyof SongEntry, raw: string) {
+ const cleaned = stripSongDashes(raw)
+ setDashBlocked(cleaned !== raw)
+ commit(
+ (entries ?? []).map((entry, i) =>
+ i === index ? { ...entry, [field]: cleaned } : entry,
+ ),
+ )
+ }
+
+ if (entries === null) {
+ const parsable = parseSongList(value) !== null
+ return (
+
+
+
+
{
+ const parsed = parseSongList(value)
+ if (parsed !== null) {
+ setEntries(parsed)
+ }
+ }}
+ >
+ Szerkesztés listaként
+
+ {!parsable && (
+
+ A tartalom nem bontható előadó/cím párokra, ezért csak szabad
+ szövegként szerkeszthető.
+
+ )}
+
+
+ )
+ }
+
+ return (
+
+
+ Felhasznált zenék
+
+ ({SONGS_MAX_LENGTH - value.length} karakter hátra)
+
+
+ {entries.length === 0 && (
+
+ Még nincs tétel a listában.
+
+ )}
+ {entries.map((entry, index) => (
+
+ setField(index, 'artist', event.target.value)}
+ placeholder="Előadó"
+ aria-label={`${index + 1}. tétel előadója`}
+ maxLength={200}
+ className={SONG_INPUT_CLASS}
+ />
+ setField(index, 'title', event.target.value)}
+ placeholder="Szám címe"
+ aria-label={`${index + 1}. tétel címe`}
+ maxLength={200}
+ className={SONG_INPUT_CLASS}
+ />
+ commit(entries.filter((_, i) => i !== index))}
+ className="px-2 text-red-500"
+ >
+ ✕
+
+
+ ))}
+
+
commit([...entries, { artist: '', title: '' }])}
+ >
+ + Tétel hozzáadása
+
+
setEntries(null)}>
+ Szabad szöveges szerkesztés
+
+
+
+ {dashBlocked
+ ? 'A kötőjel az elválasztó karakter, ezért a mezőkben nem használható.'
+ : 'Mentéskor soronként „Előadó - Szám címe" alakban tárolódik.'}
+
+
+ )
+}
diff --git a/src/routes/admin/videos/index.tsx b/src/routes/admin/videos/index.tsx
new file mode 100644
index 0000000..82b7b40
--- /dev/null
+++ b/src/routes/admin/videos/index.tsx
@@ -0,0 +1,382 @@
+import { createFileRoute, Link } from '@tanstack/react-router'
+import { createServerFn } from '@tanstack/react-start'
+import { useQuery } from '@tanstack/react-query'
+import { useState } from 'react'
+import {
+ ADMIN_DEFAULT_PAGE_SIZE,
+ getAdminVideoFilterOptions,
+ getAdminVideoList,
+ parseAdminVideoFilters,
+} from '#/server/admin/video-list.ts'
+import type { AdminVideoListItem } from '#/server/admin/video-list.ts'
+import { getDefaultDb } from '#/server/auth/session-store.ts'
+import {
+ parsePaginationNumber,
+ parseSearchPage,
+} from '#/server/shared/pagination.ts'
+import { ErrorState, LoadingState } from '#/components/PageStates.tsx'
+import { ResponsiveTable } from '#/components/admin/ResponsiveTable.tsx'
+import { VideoVisibility } from '#/components/admin/VideoVisibility.tsx'
+import {
+ AdminSearchSelect,
+ FILTER_LABEL_CLASS,
+} from '#/components/admin/SearchSelect.tsx'
+import type { AdminColumn } from '#/components/admin/ResponsiveTable.tsx'
+import type { SearchSelectOption } from '#/components/admin/SearchSelect.tsx'
+import {
+ VIDEO_STATUS_OPTIONS,
+ VISIBILITY_OPTIONS,
+ videoStatusLabel,
+} from '#/lib/admin-labels.ts'
+import {
+ formatAdminDateTimeHu,
+ formatCalendarDateHu,
+} from '#/lib/format-date.ts'
+
+const loadAdminVideoList = createServerFn({ method: 'GET' })
+ .validator(
+ (input: Record | undefined) =>
+ input ?? {},
+ )
+ .handler(async ({ data }) => {
+ const db = await getDefaultDb()
+ return getAdminVideoList(db, {
+ page: parsePaginationNumber(data['page'], 1),
+ perPage: parsePaginationNumber(data['perPage'], ADMIN_DEFAULT_PAGE_SIZE),
+ filters: parseAdminVideoFilters(data),
+ })
+ })
+
+const loadFilterOptions = createServerFn({ method: 'GET' }).handler(
+ async () => {
+ const db = await getDefaultDb()
+ return getAdminVideoFilterOptions(db)
+ },
+)
+
+// Type alias (not an interface) so it can be passed to the server function's
+// `Record` parameter with an implicit index signature.
+type AdminVideoSearch = {
+ q?: string
+ status?: string
+ visibility?: string
+ event?: string
+ tag?: string
+ page?: number
+}
+
+export const Route = createFileRoute('/admin/videos/')({
+ validateSearch: (search: Record): AdminVideoSearch => ({
+ q: pickString(search, 'q'),
+ status: pickString(search, 'status'),
+ visibility: pickString(search, 'visibility'),
+ event: pickString(search, 'event'),
+ tag: pickString(search, 'tag'),
+ page: parseSearchPage(search['page']),
+ }),
+ loaderDeps: ({ search }) => ({ search }),
+ loader: ({ deps, context }) =>
+ context.queryClient.ensureQueryData({
+ queryKey: ['admin-video-list', deps.search],
+ queryFn: () => loadAdminVideoList({ data: deps.search }),
+ }),
+ component: AdminVideoListPage,
+})
+
+function pickString(
+ search: Record,
+ key: string,
+): string | undefined {
+ const value = search[key]
+ return typeof value === 'string' && value !== '' ? value : undefined
+}
+
+function AdminVideoListPage() {
+ const navigate = Route.useNavigate()
+ const search = Route.useSearch()
+ const listQuery = useQuery({
+ queryKey: ['admin-video-list', search],
+ queryFn: () => loadAdminVideoList({ data: search }),
+ })
+ const optionsQuery = useQuery({
+ queryKey: ['admin-video-filter-options'],
+ queryFn: loadFilterOptions,
+ staleTime: 60_000,
+ })
+
+ return (
+
+
+
Videók
+
+ Új videó
+
+
+
+
+ navigate({
+ search: (prev) => ({ ...prev, ...patch, page: undefined }),
+ })
+ }
+ />
+
+ {listQuery.isPending && }
+ {listQuery.isError && (
+
+ )}
+ {listQuery.isSuccess &&
+ (listQuery.data.items.length === 0 ? (
+
+ {hasActiveFilters(search)
+ ? 'Nincs találat a megadott szűrőkkel.'
+ : 'Még nincs videó. Készíts piszkozatot az Új videó gombbal.'}
+
+ ) : (
+ <>
+
+
+ navigate({
+ search: (prev) => ({
+ ...prev,
+ page: page === 1 ? undefined : page,
+ }),
+ })
+ }
+ />
+ >
+ ))}
+
+ )
+}
+
+function hasActiveFilters(search: AdminVideoSearch): boolean {
+ return (
+ search.q !== undefined ||
+ search.status !== undefined ||
+ search.visibility !== undefined ||
+ search.event !== undefined ||
+ search.tag !== undefined
+ )
+}
+
+const videoColumns: Array> = [
+ {
+ key: 'title',
+ header: 'Cím',
+ primary: true,
+ render: (row) => (
+
+
+
+ {row.title}
+
+ {/* Public page of a published video; nothing to open for a draft. */}
+ {row.status === 'published' && (
+
+ ↗
+
+ )}
+
+ ),
+ },
+ {
+ key: 'status',
+ header: 'Állapot',
+ render: (row) => videoStatusLabel(row.status),
+ },
+ {
+ key: 'visibility',
+ header: 'Láthatóság',
+ render: (row) => ,
+ },
+ {
+ key: 'event',
+ header: 'Esemény',
+ render: (row) => row.eventTitle ?? '—',
+ },
+ {
+ key: 'recordedAt',
+ header: 'Készült',
+ render: (row) =>
+ row.recordedAt !== null ? formatCalendarDateHu(row.recordedAt) : '—',
+ },
+ {
+ key: 'publishedAt',
+ header: 'Feltöltve',
+ render: (row) =>
+ row.publishedAt !== null ? formatAdminDateTimeHu(row.publishedAt) : '—',
+ },
+ {
+ key: 'views',
+ header: 'Nézettség',
+ render: (row) => String(row.viewCount),
+ },
+ {
+ key: 'updated',
+ header: 'Utoljára módosította',
+ render: (row) =>
+ row.updatedByName !== null
+ ? `${row.updatedByName} (${formatAdminDateTimeHu(row.updatedAt)})`
+ : formatAdminDateTimeHu(row.updatedAt),
+ },
+]
+
+function VideoFilters({
+ search,
+ options,
+ onApply,
+}: {
+ search: AdminVideoSearch
+ options?: Awaited>
+ onApply: (patch: Partial>) => void
+}) {
+ const [q, setQ] = useState(search.q ?? '')
+ const eventOptions: Array = (options?.events ?? []).map(
+ (event) => ({ value: event.id, label: event.title }),
+ )
+ const tagOptions: Array = (options?.tags ?? []).map(
+ (tag) => ({ value: tag.id, label: tag.name }),
+ )
+
+ return (
+
+ )
+}
+
+export function AdminPagination({
+ page,
+ totalPages,
+ onPage,
+}: {
+ page: number
+ totalPages: number
+ onPage: (page: number) => void
+}) {
+ if (totalPages <= 1) {
+ return null
+ }
+ return (
+
+ onPage(page - 1)}
+ className="rounded border border-(--nav-border-b) px-3 py-1 disabled:opacity-30"
+ >
+ ‹ Előző
+
+
+ {page}. / {totalPages}. oldal
+
+ onPage(page + 1)}
+ className="rounded border border-(--nav-border-b) px-3 py-1 disabled:opacity-30"
+ >
+ Következő ›
+
+
+ )
+}
diff --git a/src/routes/admin/videos/new.tsx b/src/routes/admin/videos/new.tsx
new file mode 100644
index 0000000..1bfe076
--- /dev/null
+++ b/src/routes/admin/videos/new.tsx
@@ -0,0 +1,82 @@
+import { createFileRoute, useNavigate } from '@tanstack/react-router'
+import { useState } from 'react'
+import { postJson } from '#/lib/admin-api.ts'
+import { AdminPrimaryButton, AdminTextField } from '#/components/admin/form.tsx'
+import {
+ LoginRequiredBanner,
+ ValidationProblems,
+} from '#/components/admin/Alerts.tsx'
+
+export const Route = createFileRoute('/admin/videos/new')({
+ component: NewVideoPage,
+})
+
+function NewVideoPage() {
+ const navigate = useNavigate()
+ const [title, setTitle] = useState('')
+ const [busy, setBusy] = useState(false)
+ const [problems, setProblems] = useState([])
+ const [loginUrl, setLoginUrl] = useState(null)
+ const [errorMessage, setErrorMessage] = useState(null)
+
+ async function submit() {
+ setBusy(true)
+ setProblems([])
+ setErrorMessage(null)
+ setLoginUrl(null)
+ // A draft only needs a title (spec 5.3); a broken media URL can also be saved.
+ const result = await postJson<{ id: string }>('/api/admin/videos', {
+ title,
+ })
+ setBusy(false)
+ if (result.ok) {
+ await navigate({
+ to: '/admin/videos/$id',
+ params: { id: result.data.id },
+ })
+ return
+ }
+ if (result.error.code === 'auth_required' && result.error.loginUrl) {
+ setLoginUrl(result.error.loginUrl)
+ return
+ }
+ if (result.error.problems !== undefined) {
+ setProblems(result.error.problems)
+ return
+ }
+ setErrorMessage(result.error.message)
+ }
+
+ return (
+
+ Új videó
+ {loginUrl !== null && }
+
+
+ )
+}
diff --git a/src/routes/courses.tsx b/src/routes/courses.tsx
index 5ddc4cd..7bef2af 100644
--- a/src/routes/courses.tsx
+++ b/src/routes/courses.tsx
@@ -1,159 +1,15 @@
import { createFileRoute } from '@tanstack/react-router'
-import { useState } from 'react'
+/**
+ * Course (spec 10.2): `/courses` redirects to the course page in the same
+ * browser tab. No local form, data model or fake success message.
+ * The server-side redirect happens at the application entry point
+ * (src/server.ts); this handles client-side navigation.
+ */
export const Route = createFileRoute('/courses')({
- component: RouteComponent,
+ beforeLoad: () => {
+ if (typeof window !== 'undefined') {
+ window.location.assign('https://tanfolyam.bsstudio.hu/')
+ }
+ },
})
-
-function RouteComponent() {
- const [submitMessage, setSubmitMessage] = useState(null)
-
- function handleSubmit(event: {
- preventDefault: () => void
- currentTarget: HTMLFormElement
- }) {
- event.preventDefault()
-
- const formData = new FormData(event.currentTarget)
- const toText = (value: FormDataEntryValue | null) =>
- typeof value === 'string' ? value.trim() : ''
-
- const name = toText(formData.get('name'))
- const email = toText(formData.get('email'))
- const interests = formData.getAll('interest').map(String)
- const greetingName = name ? `, ${name}` : ''
-
- setSubmitMessage(
- `Köszönjük${greetingName}! A jelentkezés sikeresen elküldve.`
- )
-
- console.log('Courses form submitted', {
- name,
- email,
- interests,
- })
- }
-
- return (
-
-
- Tanfolyamok
-
-
-
-
- Célunk elsősorban a média iránt érdeklődő egyetemisták, vagy
- főiskolai hallgatók szakmai fejlődésének elősegítése. Ha érdekel a
- televíziózás, filmgyártás mikéntje, és rendelkezel hallgatói
- jogviszonnyal, csatlakozz te is az öntevékeny körünkhöz!
-
-
- Ebben a félévben a jelentkezési időszak lezárult, de ha a
- következőben nem szeretnél lemaradni a tanfolyamainkról, akkor
- töltsd ki az alábbi adatlapot és értesíteni fogunk a tanfolyam
- indulásáról, valamint a felvételi folyamat részleteiről.
-
-
-
-
-
-
- )
-}
diff --git a/src/routes/demo/tanstack-query.tsx b/src/routes/demo/tanstack-query.tsx
deleted file mode 100644
index 8f38572..0000000
--- a/src/routes/demo/tanstack-query.tsx
+++ /dev/null
@@ -1,40 +0,0 @@
-import { createFileRoute } from '@tanstack/react-router'
-import { useQuery } from '@tanstack/react-query'
-import { db } from '#/db/drizzleConnect.ts'
-import { usersTable } from '#/db/schema.ts'
-import { createServerFn } from '@tanstack/react-start'
-
-const getUsers = createServerFn({ method: 'GET' }).handler(async () => {
- return db.select().from(usersTable)
-})
-
-export const Route = createFileRoute('/demo/tanstack-query')({
- component: TanStackQueryDemo,
-})
-
-function TanStackQueryDemo() {
- const { data } = useQuery({
- queryKey: ['users'],
- queryFn: async () =>
- await getUsers(),
- initialData: [],
- })
-
- return (
-
-
- TanStack Query
-
- TanStack Query Simple Promise Handling
-
-
- {data.map((user) => (
-
- {user.name} {user.age} {user.email}
-
- ))}
-
-
-
- )
-}
diff --git a/src/routes/events/$slug.tsx b/src/routes/events/$slug.tsx
new file mode 100644
index 0000000..f9095ad
--- /dev/null
+++ b/src/routes/events/$slug.tsx
@@ -0,0 +1,215 @@
+import {
+ createFileRoute,
+ Link,
+ notFound,
+ redirect,
+} from '@tanstack/react-router'
+import { createServerFn } from '@tanstack/react-start'
+import { getRequest, getRequestUrl } from '@tanstack/react-start/server'
+import { resolveViewerStateFromRequest } from '#/server/pages/viewer.ts'
+import { getDefaultDb } from '#/server/auth/session-store.ts'
+import { getEventDetail } from '#/server/pages/event-list.ts'
+import { resolvePublicSlug } from '#/server/pages/slug-route.ts'
+import {
+ formatCalendarDateHu,
+ formatEventIntervalHu,
+} from '#/lib/format-date.ts'
+import Thumbnail from '#/components/Thumbnail.tsx'
+
+const loadEventDetail = createServerFn({ method: 'GET' })
+ .validator((input: { slug: string; page?: number }) => input)
+ .handler(async ({ data }) => {
+ const { viewer } = await resolveViewerStateFromRequest(getRequest())
+ const db = await getDefaultDb()
+ const detail = await getEventDetail(db, viewer, data.slug, {
+ page: data.page,
+ })
+ if (detail !== null) {
+ return {
+ detail,
+ redirectSlug: null as string | null,
+ canonical: `${getRequestUrl().origin}/events/${detail.slug}`,
+ }
+ }
+ const resolution = await resolvePublicSlug(db, {
+ entityType: 'event',
+ slug: data.slug,
+ viewer,
+ })
+ return {
+ detail: null,
+ redirectSlug:
+ resolution !== null && resolution.kind === 'redirect'
+ ? resolution.canonicalSlug
+ : null,
+ canonical: '',
+ }
+ })
+
+type EventDetailSearch = { page?: string }
+
+export const Route = createFileRoute('/events/$slug')({
+ validateSearch: (search: Record): EventDetailSearch => ({
+ page:
+ typeof search['page'] === 'string' && search['page'] !== ''
+ ? search['page']
+ : undefined,
+ }),
+ loaderDeps: ({ search }) => ({ search }),
+ loader: async ({ params, deps }) => {
+ const result = await loadEventDetail({
+ data: {
+ slug: params.slug,
+ page:
+ deps.search.page === undefined ? undefined : Number(deps.search.page),
+ },
+ })
+ if (result.redirectSlug !== null) {
+ throw redirect({
+ to: '/events/$slug',
+ params: { slug: result.redirectSlug },
+ replace: true,
+ })
+ }
+ if (result.detail === null) {
+ throw notFound()
+ }
+ return { detail: result.detail, canonical: result.canonical }
+ },
+ component: EventDetailPageComponent,
+})
+
+function EventDetailPageComponent() {
+ const { detail, canonical } = Route.useLoaderData()
+ const description = detail.description?.slice(0, 300) ?? detail.title
+
+ return (
+
+ {detail.title} | BSS
+
+
+
+
+
+
+ {detail.thumbnailUrl !== null && (
+
+ )}
+
+
+
+
+
+
+
+ {detail.title}
+
+ {detail.startDate !== null && (
+
+ {formatEventIntervalHu(detail.startDate, detail.endDate)}
+
+ )}
+ {detail.description !== null && (
+
{detail.description}
+ )}
+
+
+
+ {detail.staffMembers.length > 0 && (
+
+
+ Közreműködtek
+
+
+ {detail.staffMembers.map((member) => (
+
+
+ {member.fullName}
+
+
+ ))}
+
+
+ )}
+
+
+
+ Videók ({detail.videos.total})
+
+ {detail.videos.items.length === 0 ? (
+
+ Ehhez az eseményhez még nincs megtekinthető videó.
+
+ ) : (
+ <>
+
+ {detail.videos.items.map((video) => (
+
+
+
+ {video.title}
+
+ {video.recordedAt !== null && (
+
+ {formatCalendarDateHu(video.recordedAt)}
+
+ )}
+
+ ))}
+
+
+ >
+ )}
+
+
+ )
+}
+
+function EventVideoPagination({
+ slug,
+ total,
+}: {
+ slug: string
+ total: number
+}) {
+ const totalPages = Math.ceil(total / 50)
+ if (totalPages <= 1) {
+ return null
+ }
+ return (
+
+ {Array.from({ length: totalPages }, (_, index) => index + 1).map(
+ (value) => (
+
+ {value}
+
+ ),
+ )}
+
+ )
+}
diff --git a/src/routes/events/index.tsx b/src/routes/events/index.tsx
index c48c841..a078a93 100644
--- a/src/routes/events/index.tsx
+++ b/src/routes/events/index.tsx
@@ -1,18 +1,185 @@
-import { createFileRoute } from '@tanstack/react-router'
-import EventCard from '#/components/EventCard.tsx'
+import { createFileRoute, Link } from '@tanstack/react-router'
+import { createServerFn } from '@tanstack/react-start'
+import { getRequest } from '@tanstack/react-start/server'
+import { useQuery } from '@tanstack/react-query'
+import {
+ DEFAULT_EVENT_PAGE_SIZE,
+ getEventListPage,
+} from '#/server/pages/event-list.ts'
+import { resolveViewerStateFromRequest } from '#/server/pages/viewer.ts'
+import { getDefaultDb } from '#/server/auth/session-store.ts'
+import { EmptyState, ThumbnailGridSkeleton } from '#/components/PageStates.tsx'
+import Thumbnail from '#/components/Thumbnail.tsx'
+
+const loadEventList = createServerFn({ method: 'GET' })
+ .validator((input: { page?: number; perPage?: number }) => input)
+ .handler(async ({ data }) => {
+ const { viewer } = await resolveViewerStateFromRequest(getRequest())
+ const db = await getDefaultDb()
+ return getEventListPage(db, viewer, data)
+ })
+
+const EVENT_GRID_CLASS = 'grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-6'
+
+type EventListSearch = { page?: string; perPage?: string }
export const Route = createFileRoute('/events/')({
- component: RouteComponent,
+ validateSearch: (search: Record): EventListSearch => {
+ const pick = (key: string): string | undefined => {
+ const value = search[key]
+ return typeof value === 'string' && value !== '' ? value : undefined
+ }
+ return { page: pick('page'), perPage: pick('perPage') }
+ },
+ loaderDeps: ({ search }) => ({ search }),
+ loader: ({ deps, context }) =>
+ context.queryClient.ensureQueryData({
+ queryKey: ['event-list', deps.search],
+ queryFn: () =>
+ loadEventList({
+ data: {
+ page:
+ deps.search.page === undefined
+ ? undefined
+ : Number(deps.search.page),
+ perPage:
+ deps.search.perPage === undefined
+ ? undefined
+ : Number(deps.search.perPage),
+ },
+ }),
+ }),
+ component: EventListPageComponent,
+ pendingComponent: EventListSkeleton,
})
-function RouteComponent() {
+/** Placeholder for the event list: header plus a 16:9 card grid. */
+function EventListSkeleton() {
return (
-
-
- {Array.from({ length: 12 }).map((_, index) => (
-
+
+ Események
+
+
+ )
+}
+
+function EventListPageComponent() {
+ const search = Route.useSearch()
+ const listQuery = useQuery({
+ queryKey: ['event-list', search],
+ queryFn: () =>
+ loadEventList({
+ data: {
+ page: search.page === undefined ? undefined : Number(search.page),
+ perPage:
+ search.perPage === undefined ? undefined : Number(search.perPage),
+ },
+ }),
+ })
+
+ const page = Number(search.page ?? '1') || 1
+ const perPage = Number(search.perPage ?? String(DEFAULT_EVENT_PAGE_SIZE))
+
+ return (
+
+ Események
+
+ {listQuery.isPending && (
+
+ )}
+ {listQuery.isError && (
+
+ Hiba történt az események betöltése közben. Próbáld újra később.
+
+ )}
+ {listQuery.isSuccess &&
+ (listQuery.data.items.length === 0 ? (
+
+ ) : (
+ <>
+
+ {listQuery.data.items.map((item) => (
+
+ {/* The video-count badge attaches to the cover image, not the title. */}
+
+
+
+ {item.visibleVideoCount} videó
+
+
+
+ {item.title}
+
+
+ ))}
+
+
+ >
))}
-
)
}
+
+function EventPagination({
+ page,
+ totalPages,
+ perPage,
+}: {
+ page: number
+ totalPages: number
+ perPage: number
+}) {
+ if (totalPages <= 1) {
+ return null
+ }
+ return (
+
+ {Array.from({ length: totalPages }, (_, index) => index + 1).map(
+ (value) => (
+
+ {value}
+
+ ),
+ )}
+
+ )
+}
diff --git a/src/routes/index.tsx b/src/routes/index.tsx
index c937a6e..6a3fffc 100644
--- a/src/routes/index.tsx
+++ b/src/routes/index.tsx
@@ -1,103 +1,322 @@
-import { createFileRoute } from '@tanstack/react-router'
-import { useRef, useEffect } from 'react'
-
-import { usersTable } from '#/db/schema.ts'
-import { db } from '#/db/drizzleConnect.ts'
-import { Button } from '#/components/ui/button.tsx'
+import { createFileRoute, Link } from '@tanstack/react-router'
+import { useEffect, useRef } from 'react'
import { createServerFn } from '@tanstack/react-start'
-import MiniVideo from '#/components/MiniVideo.tsx'
-import Card from "#/components/Card.tsx";
-import Videoplayer from "#/components/Videoplayer.tsx";
-
-const createUser = createServerFn({ method: 'POST' }).handler(async () => {
- const user: typeof usersTable.$inferInsert = {
- name: 'asdf',
- age: 11,
- email: 'asdffdsa',
- }
+import { useQuery } from '@tanstack/react-query'
+import { getHomepagePage } from '#/server/pages/homepage.ts'
+import type {
+ HomepageHeroVideo,
+ HomepageStateDto,
+ HomepageVideoCard,
+} from '#/server/pages/homepage.ts'
+import { getDefaultDb } from '#/server/auth/session-store.ts'
+import { formatEventIntervalHu } from '#/lib/format-date.ts'
+import Thumbnail from '#/components/Thumbnail.tsx'
+import VideoDetailPlayer from '#/components/VideoDetailPlayer.tsx'
+import {
+ SkeletonLine,
+ ThumbnailCardSkeleton,
+} from '#/components/PageStates.tsx'
- await db.insert(usersTable).values(user)
+const loadHomepage = createServerFn({ method: 'GET' }).handler(async () => {
+ const db = await getDefaultDb()
+ return getHomepagePage(db)
})
export const Route = createFileRoute('/')({
- component: App,
+ loader: ({ context }) =>
+ context.queryClient.ensureQueryData({
+ queryKey: ['homepage'],
+ queryFn: loadHomepage,
+ }),
+ component: HomePage,
+ // The placeholder stays visible while the loader is pending, not a plain text line.
+ pendingComponent: HomepageSkeleton,
})
-function App() {
- const scrollContainerRef = useRef(null)
+function HomePage() {
+ // Per-minute check (spec 9.3): the homepage switches without a reload.
+ const homeQuery = useQuery({
+ queryKey: ['homepage'],
+ queryFn: loadHomepage,
+ refetchInterval: 60_000,
+ })
- useEffect(() => {
- const container = scrollContainerRef.current
- if (!container) return
+ if (homeQuery.isPending) {
+ return
+ }
+ if (homeQuery.isError) {
+ return (
+
+
+ Hiba történt a főoldal betöltése közben. Próbáld újra később.
+
+
+ )
+ }
+
+ return
+}
- const handleWheel = (e: WheelEvent) => {
- if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) {
- e.preventDefault()
- container.scrollLeft += e.deltaY
+function HomepageContent({ state }: { state: HomepageStateDto }) {
+ const scrollRef = useRef(null)
+
+ useEffect(() => {
+ const container = scrollRef.current
+ if (container === null) return
+ const handleWheel = (event: WheelEvent) => {
+ if (Math.abs(event.deltaY) > Math.abs(event.deltaX)) {
+ event.preventDefault()
+ container.scrollLeft += event.deltaY
}
}
-
container.addEventListener('wheel', handleWheel, { passive: false })
return () => container.removeEventListener('wheel', handleWheel)
}, [])
return (
-
-
+
+
Budavári Schönherz Studió
-
-
-
- Kiemelt video / adas neve
+
+ {state.upcomingLive !== null && (
+
+ Adás hamarosan
+
+ )}
+
+
+ {/* Hero */}
+ {state.priority === 'live' && state.liveEmbedUrl !== null ? (
+
+
+ Élő adás
+
+
-
-
+ ) : state.hero !== null ? (
+
+
+ Kiemelt videónk
+
+
+
+ ) : (
+
+
+ Legutóbbi videóink
+
+ {state.sideVideos.length > 0 && (
+
+ )}
+
+ )}
+ {/* List next to the hero: five in live mode, six in highlight mode, the remainder otherwise */}
-
- Tovabbi friss videoink
-
-
-
-
-
-
-
-
+
+ További friss videóink
+
+
+ {sideList(state).map((video) => (
+
+ ))}
+
+
+ {/* Events */}
+
+
+
+ Legutóbbi eseményeink
+
+
+ Összes esemény
+
+
+ {state.events.length === 0 ? (
+
+ Jelenleg nincs megjeleníthető esemény.
+
+ ) : (
+
+ {state.events.map((event) => (
+
+
+
+ {event.title}
+ {event.startDate !== null && (
+
+ {formatEventIntervalHu(event.startDate, null)}
+
+ )}
+
+
+ ))}
+
+ )}
+
+
+ )
+}
+
+/**
+ * The homepage loading placeholder. The hero, the side list and the event
+ * strip get the same grid and 16:9 ratio as the real content, so the
+ * layout doesn't jump when content appears.
+ */
+function HomepageSkeleton() {
+ return (
+
+ Főoldal betöltése…
+
+ Budavári Schönherz Studió
-
-
- Legutobbi esemenyek
+
+
+
+
-
-
-
-
-
-
-
+
+
+
+ {Array.from({ length: 4 }, (_, index) => (
+
+ ))}
+
-
+
-
-
-
-
-
-
+
+
+
+ {Array.from({ length: 6 }, (_, index) => (
+
+ ))}
+
+
)
}
+
+/**
+ * In live and highlighted mode five recent public videos appear next to the
+ * hero, in normal mode six; the hero must not repeat (spec 9.1).
+ */
+function sideList(state: HomepageStateDto) {
+ if (state.priority === 'normal') {
+ return state.hero !== null ? [] : state.sideVideos.slice(1, 6)
+ }
+ return state.sideVideos
+}
+
+function HeroCard({ video }: { video: HomepageVideoCard | null }) {
+ if (video === null) {
+ return null
+ }
+ return (
+
+ {/* The hero is the most important image on the page: load it eagerly. */}
+
+
+ {video.title}
+
+
+ )
+}
+
+/**
+ * Highlighted hero: the video plays right here on the homepage, and the title
+ * under it opens the video page for the full view (description, staff,
+ * related videos). Without an MP4 URL only the linked cover image remains.
+ */
+function HeroPlayer({ video }: { video: HomepageHeroVideo }) {
+ if (video.videoUrl === null) {
+ return
+ }
+ return (
+ // The videos are 16:9: the arbitrary variant reserves the frame before the
+ // metadata arrives, so the page layout doesn't jump.
+
+
+
+
{video.title}
+ {/* Points to the video page: there the video can be viewed with all
+ * its data. */}
+
+
+
+
+
+
+ )
+}
+
+function VideoCard({ video }: { video: HomepageVideoCard }) {
+ return (
+
+
+
+ {video.title}
+
+
+ )
+}
diff --git a/src/routes/members/$memberId.tsx b/src/routes/members/$memberId.tsx
deleted file mode 100644
index 89fdb53..0000000
--- a/src/routes/members/$memberId.tsx
+++ /dev/null
@@ -1,193 +0,0 @@
-import { createFileRoute } from '@tanstack/react-router'
-import { useState } from 'react'
-
-export const Route = createFileRoute('/members/$memberId')({
- component: RouteComponent,
-})
-
-function RouteComponent() {
- const [expandedYears, setExpandedYears] = useState
>({
- 2022: true,
- 2021: false,
- 2020: false,
- 2019: false,
- })
-
- const activities: Record> = {
- 2022: [
- { title: 'BSTV adas 2022. majus 20.', role: 'musorvezeto' },
- { title: 'BSTV adas 2022. majus 5.', role: 'musorvezeto' },
- { title: 'BSTV adas 2022. aprilis 21.', role: 'musorvezeto' },
- { title: 'BSTV adas 2022. februar 24.', role: 'rendezö' },
- ],
- 2021: [
- { title: 'BSTV adas 2021. december 15.', role: 'musorvezeto' },
- ],
- 2020: [
- { title: 'BSTV adas 2020. oktober 10.', role: 'producer' },
- ],
- 2019: [
- { title: 'BSTV adas 2019. szeptember 5.', role: 'musorvezeto' },
- ],
- }
-
- function toggleYear(year: number) {
- setExpandedYears((prev) => ({
- ...prev,
- [year]: !prev[year],
- }))
- }
-
- function handleShowAll() {
- setExpandedYears({
- 2022: true,
- 2021: true,
- 2020: true,
- 2019: true,
- })
- }
-
- function handleHideAll() {
- setExpandedYears({
- 2022: false,
- 2021: false,
- 2020: false,
- 2019: false,
- })
- }
-
- return (
-
-
-
-
-
-
- Teljes nev:
-
-
- Salamon Dora
-
-
-
-
-
- Status:
-
-
Studios
-
-
-
- Csatlakozas feleve:
-
-
2019 tavasz
-
-
-
- Bemutatkozas
-
-
- Sziasztok Lorem ipsum dolor sit amet, consectetur adipiscing elit.
- Nunc a tincidunt tellus. Nunc dolor mauris, tincidunt in felis
- quis, bibendum mattis dui. Sed ac dolor eu arcu interdum ultricies
- sit amet at ipsum. Nam ultrices in erat hendrerit rutrum. Mauris
- ut metus diam. Integer nisl lacus, aliquam sit amet metus non,
- suscipit bibendum lacus. Nulla auctor cursus hendrerit.{' '}
-
-
-
-
-
-
-
- Tevekenyseg
-
-
-
- Osszes mutatasa
-
-
- Osszes elrejtese
-
-
-
-
-
- {Object.entries(activities)
- .sort((a, b) => Number(b[0]) - Number(a[0]))
- .map(([yearStr, items]) => {
- const year = Number(yearStr)
- const isExpanded = expandedYears[year]
-
- return (
-
-
toggleYear(year)}
- className="w-full flex items-center justify-between px-4 py-3"
- >
-
- {year}
-
-
-
-
-
-
- {isExpanded && (
-
- {items.map((activity, idx) => (
-
-
-
- {activity.role}
-
-
- ))}
-
- )}
-
- )
- })}
-
-
-
- )
-}
diff --git a/src/routes/members/$slug.tsx b/src/routes/members/$slug.tsx
new file mode 100644
index 0000000..863a46b
--- /dev/null
+++ b/src/routes/members/$slug.tsx
@@ -0,0 +1,332 @@
+import {
+ createFileRoute,
+ Link,
+ notFound,
+ useNavigate,
+} from '@tanstack/react-router'
+import { createServerFn } from '@tanstack/react-start'
+import { getRequest, getRequestUrl } from '@tanstack/react-start/server'
+import { useState } from 'react'
+import { useQuery } from '@tanstack/react-query'
+import { getMemberActivity, getMemberProfile } from '#/server/pages/members.ts'
+import { groupActivity } from '#/lib/activity.ts'
+import type { ActivityRow } from '#/lib/activity.ts'
+import { resolveViewerStateFromRequest } from '#/server/pages/viewer.ts'
+import { fetchViewerState } from '#/server/pages/viewer-fn.ts'
+import { getDefaultDb } from '#/server/auth/session-store.ts'
+import { formatCalendarDateHu } from '#/lib/format-date.ts'
+
+const loadMemberProfile = createServerFn({ method: 'GET' })
+ .validator((slug: string) => slug)
+ .handler(async ({ data: username }) => {
+ const db = await getDefaultDb()
+ return getMemberProfile(db, username)
+ })
+
+const loadMemberMeta = createServerFn({ method: 'GET' })
+ .validator((slug: string) => slug)
+ .handler(async ({ data: username }) => {
+ const db = await getDefaultDb()
+ const profile = await getMemberProfile(db, username)
+ if (profile === null) {
+ return null
+ }
+ const description =
+ profile.introduction?.slice(0, 300) ??
+ `${profile.fullName} profilja a Budavári Schönherz Stúdióban (${profile.statusLabel}).`
+ return {
+ canonical: `${getRequestUrl().origin}/members/${profile.username}`,
+ description,
+ }
+ })
+
+const loadMemberActivity = createServerFn({ method: 'GET' })
+ .validator(
+ (input: { username: string; limit: number; offset: number }) => input,
+ )
+ .handler(async ({ data }) => {
+ const { viewer } = await resolveViewerStateFromRequest(getRequest())
+ const db = await getDefaultDb()
+ const profile = await getMemberProfile(db, data.username)
+ if (profile === null) {
+ return { items: [], total: 0 }
+ }
+ return getMemberActivity(db, viewer, profile.sub, {
+ limit: data.limit,
+ offset: data.offset,
+ })
+ })
+
+export const Route = createFileRoute('/members/$slug')({
+ validateSearch: (
+ search: Record,
+ ): { view?: 'year' | 'role'; offset?: number } => {
+ const view = search['view']
+ const offsetRaw = search['offset']
+ const offset = Number(offsetRaw)
+ const result: { view?: 'year' | 'role'; offset?: number } = {}
+ if (view === 'role') {
+ result.view = 'role'
+ } else if (view === 'year') {
+ result.view = 'year'
+ }
+ if (
+ typeof offsetRaw !== 'undefined' &&
+ Number.isInteger(offset) &&
+ offset > 0
+ ) {
+ result.offset = offset
+ }
+ return result
+ },
+ loader: async ({ params }) => {
+ const [profile, meta] = await Promise.all([
+ loadMemberProfile({ data: params.slug }),
+ loadMemberMeta({ data: params.slug }),
+ ])
+ if (profile === null || meta === null) {
+ throw notFound()
+ }
+ return { profile, meta }
+ },
+ component: MemberProfilePage,
+})
+
+function MemberProfilePage() {
+ const { profile, meta } = Route.useLoaderData()
+ const navigate = useNavigate()
+ const search = Route.useSearch()
+ const view = search.view ?? 'year'
+ const pageSize = 50
+ const [extraRows, setExtraRows] = useState>([])
+
+ const viewerQuery = useQuery({
+ queryKey: ['viewer'],
+ queryFn: fetchViewerState,
+ staleTime: 60_000,
+ })
+ const level = viewerQuery.data?.level ?? 'anonymous'
+
+ const firstPageQuery = useQuery({
+ queryKey: ['member-activity', profile.username, 0, level],
+ queryFn: () =>
+ loadMemberActivity({
+ data: { username: profile.username, limit: pageSize, offset: 0 },
+ }),
+ })
+
+ if (firstPageQuery.isPending) {
+ return (
+
+
+ Betöltés…
+
+
+ )
+ }
+ if (firstPageQuery.isError) {
+ throw notFound()
+ }
+
+ const firstPage = firstPageQuery.data
+ const rows = [...firstPage.items, ...extraRows]
+ const total = firstPage.total
+ const grouped = groupActivity(rows, view)
+
+ function setView(nextView: 'year' | 'role') {
+ void navigate({
+ to: '/members/$slug',
+ params: { slug: profile.username },
+ search: (prev) => ({
+ ...prev,
+ view: nextView === 'year' ? undefined : nextView,
+ }),
+ })
+ }
+
+ async function loadMore() {
+ const nextOffset = rows.length
+ const result = await loadMemberActivity({
+ data: { username: profile.username, limit: pageSize, offset: nextOffset },
+ })
+ setExtraRows((prev) => [...prev, ...result.items])
+ }
+
+ return (
+
+ {profile.fullName} | BSS
+
+
+
+
+
+
+ {profile.avatarUrl !== null && (
+
+ )}
+
+
+
+
+
+ {profile.fullName}
+
+ {profile.nickname !== null && (
+
+
+ Becenév:{' '}
+
+
+ {profile.nickname}
+
+
+ )}
+
+
+ Státusz:{' '}
+
+
+ {profile.statusLabel}
+ {profile.isLeadership ? ', Vezetőség' : ''}
+
+
+ {profile.joinedSemester !== null && (
+
+
+ Csatlakozás féléve:{' '}
+
+
+ {profile.joinedSemester}
+
+
+ )}
+ {profile.introduction !== null && (
+
+
+ Bemutatkozás
+
+
+ {profile.introduction}
+
+
+ )}
+
+
+
+
+
+
+ Tevékenység
+
+
+ setView('year')}
+ className={`nav-link font-semibold ${view === 'year' ? 'text-(--orange)' : 'text-(--bss-text-secondary)'}`}
+ >
+ Év nézet
+
+ setView('role')}
+ className={`nav-link font-semibold ${view === 'role' ? 'text-(--orange)' : 'text-(--bss-text-secondary)'}`}
+ >
+ Szerep nézet
+
+
+
+
+ {rows.length === 0 ? (
+
+ Ehhez a taghoz jelenleg nincs megtekinthető videó.
+
+ ) : view === 'year' ? (
+
+ {grouped.yearGroups.map((yearGroup) => (
+
+
+ {yearGroup.year === 0 ? 'Dátum nélkül' : yearGroup.year}
+
+ {yearGroup.groups.map((group) => (
+
+
+ {group.roleName}
+
+
+
+ ))}
+
+ ))}
+
+ ) : (
+
+ {grouped.roleGroups.map((group) => (
+
+
+ {group.roleName}
+
+
+
+
+
+ ))}
+
+ )}
+
+ {rows.length < total && (
+ void loadMore()}
+ className="solid-btn mx-auto mt-6 block bg-(--orange) px-6 py-2 font-bold text-white"
+ >
+ Továbbiak betöltése
+
+ )}
+
+
+ )
+}
+
+function VideoList({ videos }: { videos: Array }) {
+ return (
+
+ {videos.map((video) => (
+
+
+ {video.title}
+
+
+ {video.recordedAt !== null
+ ? formatCalendarDateHu(video.recordedAt)
+ : 'Dátum nélkül'}
+
+
+ ))}
+
+ )
+}
diff --git a/src/routes/members/archived.tsx b/src/routes/members/archived.tsx
new file mode 100644
index 0000000..be3f7b0
--- /dev/null
+++ b/src/routes/members/archived.tsx
@@ -0,0 +1,122 @@
+import { createFileRoute, Link } from '@tanstack/react-router'
+import { useQuery } from '@tanstack/react-query'
+import { loadArchiveMembersServer } from '#/server/pages/member-archive-fn.ts'
+import { EmptyState } from '#/components/PageStates.tsx'
+
+export const Route = createFileRoute('/members/archived')({
+ validateSearch: (search: Record) => ({
+ page:
+ typeof search['page'] === 'string' && search['page'] !== ''
+ ? Number(search['page'])
+ : undefined,
+ }),
+ loaderDeps: ({ search }) => ({ page: search.page }),
+ loader: ({ deps, context }) =>
+ context.queryClient.ensureQueryData({
+ queryKey: ['members-archive', 'archived', deps.page ?? 1],
+ queryFn: () =>
+ loadArchiveMembersServer({
+ data: { kind: 'archived', page: deps.page },
+ }),
+ }),
+ component: ArchivedMembersPage,
+})
+
+function ArchivedMembersPage() {
+ const search = Route.useSearch()
+ const listQuery = useQuery({
+ queryKey: ['members-archive', 'archived', search.page ?? 1],
+ queryFn: () =>
+ loadArchiveMembersServer({
+ data: { kind: 'archived', page: search.page },
+ }),
+ })
+
+ return (
+
+ {listQuery.isPending && (
+
+ Betöltés…
+
+ )}
+ {listQuery.isError && (
+
+ Hiba történt az adatok betöltése közben. Próbáld újra később.
+
+ )}
+ {listQuery.isSuccess && (
+ <>
+
+ {listQuery.data.title}
+
+ {listQuery.data.items.length === 0 ? (
+
+ ) : (
+ <>
+
+ {listQuery.data.items.map((member) => (
+
+
+
+ {member.fullName}
+
+ {member.nickname !== null && (
+
+ „{member.nickname}”
+
+ )}
+
+ ))}
+
+ {listQuery.data.totalPages > 1 && (
+
+ {Array.from(
+ { length: listQuery.data.totalPages },
+ (_, index) => index + 1,
+ ).map((value) => (
+
+ {value}
+
+ ))}
+
+ )}
+ >
+ )}
+ >
+ )}
+
+ )
+}
diff --git a/src/routes/members/contributors.tsx b/src/routes/members/contributors.tsx
new file mode 100644
index 0000000..19d9415
--- /dev/null
+++ b/src/routes/members/contributors.tsx
@@ -0,0 +1,122 @@
+import { createFileRoute, Link } from '@tanstack/react-router'
+import { useQuery } from '@tanstack/react-query'
+import { loadArchiveMembersServer } from '#/server/pages/member-archive-fn.ts'
+import { EmptyState } from '#/components/PageStates.tsx'
+
+export const Route = createFileRoute('/members/contributors')({
+ validateSearch: (search: Record) => ({
+ page:
+ typeof search['page'] === 'string' && search['page'] !== ''
+ ? Number(search['page'])
+ : undefined,
+ }),
+ loaderDeps: ({ search }) => ({ page: search.page }),
+ loader: ({ deps, context }) =>
+ context.queryClient.ensureQueryData({
+ queryKey: ['members-archive', 'contributors', deps.page ?? 1],
+ queryFn: () =>
+ loadArchiveMembersServer({
+ data: { kind: 'contributors', page: deps.page },
+ }),
+ }),
+ component: ArchivedMembersPage,
+})
+
+function ArchivedMembersPage() {
+ const search = Route.useSearch()
+ const listQuery = useQuery({
+ queryKey: ['members-archive', 'contributors', search.page ?? 1],
+ queryFn: () =>
+ loadArchiveMembersServer({
+ data: { kind: 'contributors', page: search.page },
+ }),
+ })
+
+ return (
+
+ {listQuery.isPending && (
+
+ Betöltés…
+
+ )}
+ {listQuery.isError && (
+
+ Hiba történt az adatok betöltése közben. Próbáld újra később.
+
+ )}
+ {listQuery.isSuccess && (
+ <>
+
+ {listQuery.data.title}
+
+ {listQuery.data.items.length === 0 ? (
+
+ ) : (
+ <>
+
+ {listQuery.data.items.map((member) => (
+
+
+
+ {member.fullName}
+
+ {member.nickname !== null && (
+
+ „{member.nickname}”
+
+ )}
+
+ ))}
+
+ {listQuery.data.totalPages > 1 && (
+
+ {Array.from(
+ { length: listQuery.data.totalPages },
+ (_, index) => index + 1,
+ ).map((value) => (
+
+ {value}
+
+ ))}
+
+ )}
+ >
+ )}
+ >
+ )}
+
+ )
+}
diff --git a/src/routes/members/index.tsx b/src/routes/members/index.tsx
index d64eea0..4da61d0 100644
--- a/src/routes/members/index.tsx
+++ b/src/routes/members/index.tsx
@@ -1,53 +1,143 @@
-import { createFileRoute } from '@tanstack/react-router'
-import MemberCard from '#/components/MemberCard.tsx'
+import { createFileRoute, Link } from '@tanstack/react-router'
+import { createServerFn } from '@tanstack/react-start'
+import { useQuery } from '@tanstack/react-query'
+import { getActiveMemberBlocks } from '#/server/pages/members.ts'
+import { getDefaultDb } from '#/server/auth/session-store.ts'
+
+const loadActiveMembers = createServerFn({ method: 'GET' }).handler(
+ async () => {
+ const db = await getDefaultDb()
+ return getActiveMemberBlocks(db)
+ },
+)
export const Route = createFileRoute('/members/')({
- component: RouteComponent,
+ loader: ({ context }) =>
+ context.queryClient.ensureQueryData({
+ queryKey: ['members-active'],
+ queryFn: loadActiveMembers,
+ staleTime: 60_000,
+ }),
+ component: MembersPage,
})
-function RouteComponent() {
+function MembersPage() {
+ const blocksQuery = useQuery({
+ queryKey: ['members-active'],
+ queryFn: loadActiveMembers,
+ staleTime: 60_000,
+ })
+
return (
-
-
- TAGOK
-
-
- Kik dolgoznak nap mint nap azert, hogy a BSS mukodjon? Kiforgatott
- golyabalon? Hol talalod meg a studiovezeto e-mail cimet?
-
-
- Ez az oldal Neked keszult, ha kivsnics vagy a BSS tagjaira, reszletesebb
- adataikra.
-
- VEZETOSEG
-
-
-
-
-
-
-
-
- {Array.from({ length: 7 }).map((_, index) => (
-
- ))}
-
- STUDIOSOK
-
- {Array.from({ length: 14 }).map((_, index) => (
-
- ))}
-
- UJONCOK
-
- {Array.from({ length: 14 }).map((_, index) => (
-
+
+
+ Tagok
+
+
+ {blocksQuery.isPending && (
+
+ Betöltés…
+
+ )}
+ {blocksQuery.isError && (
+
+ Hiba történt a tagok betöltése közben. Próbáld újra később.
+
+ )}
+ {blocksQuery.isSuccess && (
+ <>
+
+
+
+
+
+
+
+ ({ page: undefined })}
+ className="font-bold text-(--orange) underline"
+ >
+ Archivált öregtagok
+
+ ({ page: undefined })}
+ className="font-bold text-(--orange) underline"
+ >
+ Dolgozott még velünk
+
+
+ >
+ )}
+
+ )
+}
+
+export function MemberBlock({
+ title,
+ members,
+}: {
+ title: string
+ members: Array<{
+ sub: string
+ username: string
+ fullName: string
+ nickname: string | null
+ avatarUrl: string | null
+ }>
+}) {
+ if (members.length === 0) {
+ return null
+ }
+ return (
+
+
+ {title}
+
+
+ {members.map((member) => (
+
+
+
+ {member.fullName}
+
+ {member.nickname !== null && (
+
„{member.nickname}”
+ )}
+
))}
-
+
)
}
diff --git a/src/routes/search.tsx b/src/routes/search.tsx
new file mode 100644
index 0000000..75618e2
--- /dev/null
+++ b/src/routes/search.tsx
@@ -0,0 +1,387 @@
+import { createFileRoute, Link } from '@tanstack/react-router'
+import { createServerFn } from '@tanstack/react-start'
+import { getRequest } from '@tanstack/react-start/server'
+import { MIN_QUERY_LENGTH, search } from '#/server/search/service.ts'
+import { getVideoListPage } from '#/server/pages/video-list.ts'
+import { resolveViewerStateFromRequest } from '#/server/pages/viewer.ts'
+import { getDefaultDb } from '#/server/auth/session-store.ts'
+import Thumbnail from '#/components/Thumbnail.tsx'
+
+const SEARCH_TABS = [
+ { key: 'all', label: 'Összes' },
+ { key: 'videos', label: 'Videók' },
+ { key: 'events', label: 'Események' },
+ { key: 'members', label: 'Tagok' },
+] as const
+
+type SearchTab = (typeof SEARCH_TABS)[number]['key']
+
+const loadSearchResults = createServerFn({ method: 'GET' })
+ .validator((query: string) => query)
+ .handler(async ({ data: query }) => {
+ const { viewer } = await resolveViewerStateFromRequest(getRequest())
+ const db = await getDefaultDb()
+ // The All tab shows at most ten results per type (spec 11.3).
+ return search(db, viewer, query, { limitPerType: 10 })
+ })
+
+const loadVideoHits = createServerFn({ method: 'GET' })
+ .validator((query: string) => query)
+ .handler(async ({ data: query }) => {
+ const { viewer } = await resolveViewerStateFromRequest(getRequest())
+ const db = await getDefaultDb()
+ return getVideoListPage(db, viewer, {
+ q: query,
+ sort: 'published',
+ page: 1,
+ perPage: 50,
+ tagNames: [],
+ eventSlug: '',
+ recordedFrom: '',
+ recordedTo: '',
+ staffMemberSub: '',
+ staffRoleId: '',
+ })
+ })
+
+type SearchRouteSearch = { q?: string; tab?: SearchTab }
+
+export const Route = createFileRoute('/search')({
+ validateSearch: (rawSearch: Record
): SearchRouteSearch => {
+ const q = rawSearch['q']
+ const tab = rawSearch['tab']
+ const knownTabs = SEARCH_TABS.map((entry) => entry.key)
+ return {
+ q: typeof q === 'string' ? q : '',
+ tab:
+ typeof tab === 'string' && (knownTabs as string[]).includes(tab)
+ ? (tab as SearchTab)
+ : undefined,
+ }
+ },
+ loaderDeps: ({ search: routeSearch }) => ({ routeSearch }),
+ loader: ({ deps, context }) =>
+ context.queryClient.ensureQueryData({
+ queryKey: ['search', deps.routeSearch],
+ queryFn: async () => {
+ const query = deps.routeSearch.q?.trim() ?? ''
+ if (query.length < MIN_QUERY_LENGTH) {
+ return null
+ }
+ const [results, videos] = await Promise.all([
+ loadSearchResults({ data: query }),
+ loadVideoHits({ data: query }),
+ ])
+ return { query, results, videos }
+ },
+ }),
+ component: SearchPage,
+})
+
+function SearchPage() {
+ const searchParams = Route.useSearch()
+ const data = Route.useLoaderData()
+ const query = searchParams.q ?? ''
+ const activeTab: SearchTab = searchParams.tab ?? 'all'
+
+ return (
+
+ {/* Technical page: search must not be indexed (spec 16). */}
+
+ Keresés | BSS
+
+ Keresés{query !== '' ? `: „${query}”` : ''}
+
+
+
+
+
+ {SEARCH_TABS.map((tabEntry) => (
+
+ {tabEntry.label}
+
+ ))}
+
+
+ {(data === null || query.trim().length < MIN_QUERY_LENGTH) && (
+
+
+ Kezdd el a keresést legalább két karakterrel.
+
+
+ A keresés kis- és nagybetűtől, valamint ékezettől független, és a
+ kisebb elgépeléseket is kezeli. A felhasznált zenékben nincs
+ keresés.
+
+
+
+ Részletes videószűrő megnyitása
+
+
+
+ )}
+
+ {data !== null && query.trim().length >= MIN_QUERY_LENGTH && (
+ <>
+ {activeTab === 'all' && (
+ <>
+
+
+ {data.results.videos.map(({ item }) => (
+
+
+
+ ))}
+
+
+
+
+ {data.results.events.map(({ item }) => (
+
+
+
+ ))}
+
+
+
+
+ {data.results.members.map(({ item }) => (
+
+
+
+ ))}
+
+
+
+
+ {data.results.tags.map(({ item }) => (
+
+
+
+ ))}
+
+
+ {countAll(data.results) === 0 && }
+ >
+ )}
+
+ {activeTab === 'videos' && (
+ <>
+ {data.videos.items.length === 0 ? (
+
+ ) : (
+
+ {data.videos.items.map((video) => (
+
+
+
+ {video.title}
+
+
+ ))}
+
+ )}
+
+
+ Részletes szűrőkkel folytatás
+
+
+ >
+ )}
+
+ {activeTab === 'events' && (
+ <>
+ {data.results.events.length === 0 ? (
+
+ ) : (
+
+ {data.results.events.map(({ item }) => (
+
+
+
+ ))}
+
+ )}
+ >
+ )}
+
+ {activeTab === 'members' && (
+ <>
+ {data.results.members.length === 0 ? (
+
+ ) : (
+
+ {data.results.members.map(({ item }) => (
+
+
+
+ ))}
+
+ )}
+ >
+ )}
+ >
+ )}
+
+ )
+}
+
+function countAll(results: {
+ videos: unknown[]
+ events: unknown[]
+ members: unknown[]
+ tags: unknown[]
+}): number {
+ return (
+ results.videos.length +
+ results.events.length +
+ results.members.length +
+ results.tags.length
+ )
+}
+
+function ResultSection({
+ title,
+ children,
+}: {
+ title: string
+ children: React.ReactNode
+}) {
+ return (
+
+ )
+}
+
+function HitLink({ href, label }: { href: string; label: string }) {
+ const internal = href.startsWith('/videos?')
+ ? null
+ : href.startsWith('/videos/')
+ ? { to: '/videos/$slug' as const, slug: href.replace('/videos/', '') }
+ : href.startsWith('/events/')
+ ? { to: '/events/$slug' as const, slug: href.replace('/events/', '') }
+ : href.startsWith('/members/')
+ ? {
+ to: '/members/$slug' as const,
+ slug: href.replace('/members/', ''),
+ }
+ : null
+
+ if (internal !== null) {
+ return (
+
+ {label}
+
+ )
+ }
+
+ if (href.startsWith('/videos?tags=')) {
+ const tagName = decodeURIComponent(href.replace('/videos?tags=', ''))
+ return (
+
+ {label}
+
+ )
+ }
+
+ return (
+
+ {label}
+
+ )
+}
+
+function NoResults() {
+ return (
+
+
Nincs találat
+
+ Próbálj másik kifejezést, vagy használd a részletes videószűrőt.
+
+
+ )
+}
diff --git a/src/routes/videos/$slug.tsx b/src/routes/videos/$slug.tsx
new file mode 100644
index 0000000..aafe63d
--- /dev/null
+++ b/src/routes/videos/$slug.tsx
@@ -0,0 +1,222 @@
+import {
+ createFileRoute,
+ Link,
+ notFound,
+ redirect,
+} from '@tanstack/react-router'
+import { createServerFn } from '@tanstack/react-start'
+import { getRequest, getRequestUrl } from '@tanstack/react-start/server'
+import { resolveViewerStateFromRequest } from '#/server/pages/viewer.ts'
+import { getDefaultDb } from '#/server/auth/session-store.ts'
+import { getVideoDetail } from '#/server/pages/video-detail.ts'
+import { resolvePublicSlug } from '#/server/pages/slug-route.ts'
+import VideoDetailPlayer from '#/components/VideoDetailPlayer.tsx'
+import { formatCalendarDateHu, formatDateHu } from '#/lib/format-date.ts'
+import Thumbnail from '#/components/Thumbnail.tsx'
+
+const loadVideoDetail = createServerFn({ method: 'GET' })
+ .validator((slug: string) => slug)
+ .handler(async ({ data: slug }) => {
+ const { viewer } = await resolveViewerStateFromRequest(getRequest())
+ const db = await getDefaultDb()
+ const detail = await getVideoDetail(db, viewer, slug)
+ if (detail !== null) {
+ const origin = getRequestUrl().origin
+ return {
+ detail,
+ redirectSlug: null as string | null,
+ canonical: `${origin}/videos/${detail.slug}`,
+ }
+ }
+ // No public video at the current slug: try an old slug redirect.
+ const resolution = await resolvePublicSlug(db, {
+ entityType: 'video',
+ slug,
+ viewer,
+ })
+ const redirectSlug =
+ resolution !== null && resolution.kind === 'redirect'
+ ? resolution.canonicalSlug
+ : null
+ return { detail: null, redirectSlug, canonical: '' }
+ })
+
+export const Route = createFileRoute('/videos/$slug')({
+ loader: async ({ params }) => {
+ const result = await loadVideoDetail({ data: params.slug })
+ if (result.redirectSlug !== null) {
+ throw redirect({
+ to: '/videos/$slug',
+ params: { slug: result.redirectSlug },
+ replace: true,
+ })
+ }
+ if (result.detail === null) {
+ throw notFound()
+ }
+ return {
+ detail: result.detail,
+ canonical: result.canonical,
+ }
+ },
+ component: VideoDetailPage,
+})
+
+function VideoDetailPage() {
+ const { detail, canonical } = Route.useLoaderData()
+ const description = detail.description?.slice(0, 300) ?? detail.title
+ return (
+
+ {`${detail.title} | BSS`}
+
+
+
+
+
+
+ {detail.thumbnailUrl !== null && (
+
+ )}
+
+
+ {detail.videoUrl !== null ? (
+
+
+
+ ) : (
+
A videó most nem érhető el.
+ )}
+
+
+
+
+ {detail.title}
+
+
+
+ {detail.recordedAt !== null && (
+
+
+ Készült:
+
+ {formatCalendarDateHu(detail.recordedAt)}
+
+ )}
+ {detail.publishedAt !== null && (
+
+
+ Feltöltve:
+
+ {formatDateHu(detail.publishedAt)}
+
+ )}
+
+
+ {detail.event !== null && (
+
+
+ Esemény:{' '}
+
+
+ {detail.event.title}
+
+
+ )}
+
+ {detail.description !== null && (
+
{detail.description}
+ )}
+
+ {detail.guests !== null && (
+
+
+ Vendégek
+
+ {detail.guests}
+
+ )}
+
+ {detail.songs !== null && (
+
+
+ Felhasznált zenék
+
+ {detail.songs}
+
+ )}
+
+ {detail.tags.length > 0 && (
+
+ {detail.tags.map((tag) => (
+
+
+ {tag.name}
+
+
+ ))}
+
+ )}
+
+ {detail.staff.length > 0 && (
+
+ {detail.staff.map((role) => (
+
+
+ {role.roleName}:{' '}
+
+ {role.members.map((member, index) => (
+
+ {index > 0 && ', '}
+
+ {member.fullName}
+
+
+ ))}
+
+ ))}
+
+ )}
+
+ {detail.relatedVideos.length > 0 && (
+
+
+ További videók
+
+
+ {detail.relatedVideos.map((related) => (
+
+
+
+ {related.title}
+
+
+ ))}
+
+
+ )}
+
+
+ )
+}
diff --git a/src/routes/videos/$videoId.tsx b/src/routes/videos/$videoId.tsx
deleted file mode 100644
index c3a1ffd..0000000
--- a/src/routes/videos/$videoId.tsx
+++ /dev/null
@@ -1,65 +0,0 @@
-import { createFileRoute } from '@tanstack/react-router'
-import Videoplayer from '#/components/Videoplayer.tsx'
-import MiniVideo from '#/components/MiniVideo.tsx'
-
-export const Route = createFileRoute('/videos/$videoId')({
- component: RouteComponent,
-})
-
-function RouteComponent() {
- const { videoId } = Route.useParams()
- return (
-
-
-
-
- Video cime
-
-
-
Video description
-
-
-
- Riporter:{' '}
-
- Gipsz Jakab
-
-
-
- Vago:{' '}
-
- Pelda Bela
-
-
-
-
-
Felhasznalt zenek:
-
Alma egyuttes - Valami Dal
-
Alma egyuttes - Valami Dal
-
Alma egyuttes - Valami Dal
-
-
- Az esemeny datuma:{' '} 2022. november 03.
-
-
-
Tovabbi videok
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- )
-}
diff --git a/src/routes/videos/index.tsx b/src/routes/videos/index.tsx
index f5c0eae..739f107 100644
--- a/src/routes/videos/index.tsx
+++ b/src/routes/videos/index.tsx
@@ -1,310 +1,443 @@
-'use client'
+import { createFileRoute, useNavigate } from '@tanstack/react-router'
+import { createServerFn } from '@tanstack/react-start'
+import { getRequest } from '@tanstack/react-start/server'
+import { useState } from 'react'
+import { useQuery } from '@tanstack/react-query'
+import {
+ VIDEO_PAGE_SIZES,
+ VIDEO_SORTS,
+ getVideoFilterOptions,
+ getVideoListPage,
+ parseVideoListSearch,
+ videoSortLabel,
+} from '#/server/pages/video-list.ts'
+import { resolveViewerStateFromRequest } from '#/server/pages/viewer.ts'
+import { getDefaultDb } from '#/server/auth/session-store.ts'
+import { EmptyState, ThumbnailGridSkeleton } from '#/components/PageStates.tsx'
+import Thumbnail from '#/components/Thumbnail.tsx'
+import {
+ AdminSearchSelect,
+ FILTER_LABEL_CLASS,
+} from '#/components/admin/SearchSelect.tsx'
+import type { VideoListRawSearch } from '#/server/pages/video-list.ts'
-import { useState, useRef, useEffect } from 'react'
-import { createFileRoute } from '@tanstack/react-router'
-import MiniVideo from '#/components/MiniVideo.tsx'
+const loadVideoList = createServerFn({ method: 'GET' })
+ .validator((search: VideoListRawSearch) => search)
+ .handler(async ({ data }) => {
+ const { viewer } = await resolveViewerStateFromRequest(getRequest())
+ const db = await getDefaultDb()
+ return getVideoListPage(db, viewer, parseVideoListSearch(data))
+ })
-export const Route = createFileRoute('/videos/')({
- validateSearch: (search: Record) => {
- const rawPage = search.page
- const sort = search.sort
- const parsedPage = Number(rawPage)
+const loadFilterOptions = createServerFn({ method: 'GET' }).handler(
+ async () => {
+ const db = await getDefaultDb()
+ return getVideoFilterOptions(db)
+ },
+)
+
+const VIDEO_GRID_CLASS = 'grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-5'
+const VIDEO_SORT_OPTIONS = VIDEO_SORTS.map((sort) => ({
+ value: sort,
+ label: videoSortLabel(sort),
+}))
+const VIDEO_PAGE_SIZE_OPTIONS = VIDEO_PAGE_SIZES.map((size) => ({
+ value: String(size),
+ label: String(size),
+}))
+export const Route = createFileRoute('/videos/')({
+ validateSearch: (search: Record): VideoListRawSearch => {
+ const pickString = (key: string): string | undefined => {
+ const value = search[key]
+ return typeof value === 'string' && value !== '' ? value : undefined
+ }
+ const tagsValue = search['tags']
+ const tags = Array.isArray(tagsValue)
+ ? tagsValue.filter((tag): tag is string => typeof tag === 'string')
+ : typeof tagsValue === 'string'
+ ? [tagsValue]
+ : undefined
return {
- page: Number.isInteger(parsedPage) && parsedPage > 0 ? parsedPage : 1,
- sort: typeof sort === 'string' ? sort : 'newest',
+ q: pickString('q'),
+ sort: pickString('sort'),
+ page: pickString('page'),
+ perPage: pickString('perPage'),
+ event: pickString('event'),
+ from: pickString('from'),
+ to: pickString('to'),
+ staffMember: pickString('staffMember'),
+ staffRole: pickString('staffRole'),
+ ...(tags !== undefined && tags.length > 0 ? { tags } : {}),
}
},
- component: RouteComponent,
+ loaderDeps: ({ search }) => ({ search }),
+ loader: ({ deps, context }) =>
+ context.queryClient.ensureQueryData({
+ queryKey: ['video-list', deps.search],
+ queryFn: () => loadVideoList({ data: deps.search }),
+ }),
+ component: VideoListPage,
+ pendingComponent: VideoListSkeleton,
})
-function MegaVideo({
- videoName = 'Unknown video',
- thumbnailUrl = '/video-thumbnail.png',
-}: Readonly<{
- videoName?: string
- thumbnailUrl?: string
-}>) {
+/** Placeholder for the video list: header plus a 16:9 card grid. */
+function VideoListSkeleton() {
return (
-
-
+
Videók
+
-
-
-
-
-
- {videoName}
-
-
- Legfrissebb!
-
-
-
+
)
}
-function RouteComponent() {
- const [open, setOpen] = useState(false)
- const menuRef = useRef(null)
- const navigate = Route.useNavigate()
- const { page, sort } = Route.useSearch()
- const currentPage = page
- const totalPages = 29
-
- useEffect(() => {
- function onDocClick(e: MouseEvent) {
- if (!menuRef.current) return
- if (e.target && menuRef.current.contains(e.target as Node)) return
- setOpen(false)
- }
-
- document.addEventListener('click', onDocClick)
- return () => document.removeEventListener('click', onDocClick)
- }, [])
-
- const options: Array<{ value: string; label: string }> = [
- { value: 'newest', label: 'Legujabb' },
- { value: 'popular', label: 'Legnepszerubb' },
- { value: 'viewed', label: 'Legtobbet megnezett' },
- ]
+function VideoListPage() {
+ const navigate = useNavigate()
+ const rawSearch = Route.useSearch()
+ const pageData = useQuery({
+ queryKey: ['video-list', rawSearch],
+ queryFn: () => loadVideoList({ data: rawSearch }),
+ })
+ const optionsQuery = useQuery({
+ queryKey: ['video-filter-options'],
+ queryFn: loadFilterOptions,
+ staleTime: 5 * 60_000,
+ })
+ const parsed = parseVideoListSearch(rawSearch)
- function handleSelect(option: string) {
- setOpen(false)
- // Add your event logic here — e.g., fetch/sort/update state
- // For now we'll just log to the console
- console.log('Selected:', option)
+ function update(patch: Partial) {
navigate({
- search: (prev) => ({
- ...prev,
- sort: option,
- page: 1, // Reset to first page on sort change
- }),
+ to: '/videos',
+ search: (prev) => ({ ...prev, ...patch, page: undefined }),
})
}
- function handlePageChange(nextPage: number) {
- if (nextPage < 1 || nextPage > totalPages || nextPage === currentPage)
- return
+ return (
+
+ Videók
- navigate({
- search: (prev) => ({
- ...prev,
- page: nextPage,
- }),
- })
- }
+ navigate({ to: '/videos', search: {} })}
+ />
- function getPaginationItems(): Array<
- { type: 'page'; value: number } | { type: 'ellipsis'; id: string }
- > {
- if (currentPage <= 4) {
- return [
- { type: 'page', value: 1 },
- { type: 'page', value: 2 },
- { type: 'page', value: 3 },
- { type: 'page', value: 4 },
- { type: 'page', value: 5 },
- { type: 'ellipsis', id: 'end' },
- { type: 'page', value: totalPages },
- ]
- }
+ {pageData.isPending && (
+
+ )}
+ {pageData.isError && (
+
+ Hiba történt a videók betöltése közben. Próbáld újra később.
+
+ )}
+ {pageData.isSuccess &&
+ (pageData.data.items.length === 0 ? (
+
+ ) : (
+ <>
+
+
+ navigate({
+ to: '/videos',
+ search: (prev) => ({
+ ...prev,
+ page: page === 1 ? undefined : String(page),
+ }),
+ })
+ }
+ />
+ >
+ ))}
+
+ )
+}
- if (currentPage >= totalPages - 3) {
- return [
- { type: 'page', value: 1 },
- { type: 'ellipsis', id: 'start' },
- { type: 'page', value: totalPages - 4 },
- { type: 'page', value: totalPages - 3 },
- { type: 'page', value: totalPages - 2 },
- { type: 'page', value: totalPages - 1 },
- { type: 'page', value: totalPages },
- ]
- }
+function VideoFilterBar({
+ parsed,
+ raw,
+ options,
+ onUpdate,
+ onReset,
+}: {
+ parsed: ReturnType
+ raw: VideoListRawSearch
+ options?: Awaited>
+ onUpdate: (patch: Partial) => void
+ onReset: () => void
+}) {
+ const [q, setQ] = useState(parsed.q)
- return [
- { type: 'page', value: 1 },
- { type: 'ellipsis', id: 'start' },
- { type: 'page', value: currentPage - 1 },
- { type: 'page', value: currentPage },
- { type: 'page', value: currentPage + 1 },
- { type: 'ellipsis', id: 'end' },
- { type: 'page', value: totalPages },
- ]
+ function submit(event: React.FormEvent) {
+ event.preventDefault()
+ onUpdate({ q: q.trim() === '' ? undefined : q.trim() })
}
+ const hasActiveFilters =
+ Object.keys(raw).filter((key) => key !== 'page' && key !== 'sort').length >
+ 0
+ const eventOptions =
+ options?.events.map((item) => ({
+ value: item.slug,
+ label: item.title,
+ })) ?? []
+ const staffMemberOptions =
+ options?.staffMembers.map((item) => ({
+ value: item.sub,
+ label: item.fullName,
+ })) ?? []
+ const staffRoleOptions =
+ options?.staffRoles.map((item) => ({
+ value: item.id,
+ label: item.name,
+ })) ?? []
+
return (
-
-
+
+ Szabad szöveg
+ setQ(event.target.value)}
+ placeholder="Cím, leírás, vendég, stábtag"
+ className="h-10 w-56 border-b border-(--nav-border-b) bg-(--nav-search-bg) px-2 outline-none"
+ />
+
+
+
onUpdate({ event: value || undefined })}
+ placeholder="Mind"
+ emptyOptionLabel="Mind"
+ searchPlaceholder="Esemény keresése…"
+ searchThreshold={0}
+ labelClassName={FILTER_LABEL_CLASS}
+ />
+
+
+
onUpdate({ staffMember: value || undefined })}
+ placeholder="Mind"
+ emptyOptionLabel="Mind"
+ searchPlaceholder="Stábtag keresése…"
+ searchThreshold={0}
+ labelClassName={FILTER_LABEL_CLASS}
+ />
+
+
+
onUpdate({ staffRole: value || undefined })}
+ placeholder="Mind"
+ emptyOptionLabel="Mind"
+ searchPlaceholder="Stábszerep keresése…"
+ labelClassName={FILTER_LABEL_CLASS}
+ />
+
+
+ Készült ettől
+
+ onUpdate({ from: event.target.value || undefined })
+ }
+ className="h-10 border-b border-(--nav-border-b) bg-(--nav-search-bg) px-2"
+ />
+
+
+ Készült eddig
+
+ onUpdate({ to: event.target.value || undefined })
+ }
+ className="h-10 border-b border-(--nav-border-b) bg-(--nav-search-bg) px-2"
+ />
+
+
tag.name) ?? []}
+ onChange={(tags) => onUpdate({ tags })}
+ />
+
+
onUpdate({ sort: value })}
+ labelClassName={FILTER_LABEL_CLASS}
+ />
+
+
+
onUpdate({ perPage: value })}
+ labelClassName={FILTER_LABEL_CLASS}
+ />
+
+
+ Szűrés
+
+ {hasActiveFilters && (
+
+ Szűrők törlése
+
+ )}
+ {parsed.tagNames.length > 0 && (
-
-
-
+ {parsed.tagNames.map((tag) => (
setOpen((s) => !s)}
- className="inline-flex w-[288px] max-w-full h-[40px] max-h-full justify-between items-center px-4 py-2 bg-(--videos-search-bg) shadow-sm hover:bg-(--videos-search-bg)"
+ onClick={() =>
+ onUpdate({
+ tags: parsed.tagNames.filter(
+ (selectedTag) => selectedTag !== tag,
+ ),
+ })
+ }
+ aria-label={`${tag} címke eltávolítása`}
+ className="ctrl-btn max-w-full truncate px-2 py-0.5 text-xs text-(--bss-text-secondary) hover:text-(--orange)"
>
-
- {'Rendezés: ' + (sort ? options.find((o) => o.value === sort)?.label : 'Rendezés kiválasztása')}
-
-
-
-
+ {tag} ×
-
- {open && (
-
-
- {options.map((opt) => (
- handleSelect(opt.value)}
- className="w-[256px] max-w-full h-[40px] max-h-full text-left mx-4 py-2 text-sm hover:bg-(--videos-search-bg) text-(--vidoes-search-icon) border-b-1 border-b-(--videos-dropdown-hr) last:border-b-0"
- >
- {opt.label}
-
- ))}
-
-
- )}
-
+ ))}
-
-
- {currentPage === 1 && sort === 'newest' ? (
- <>
-
-
-
- {Array.from({ length: 21 }, (_, i) => (
-
- ))}
- >
- ) : (
- <>
- {Array.from({ length: 30 }, (_, i) => (
-
- ))}
- >
- )}
-
-
-
-
- handlePageChange(currentPage - 1)}
- disabled={currentPage === 1}
- className="flex h-12 w-12 items-center justify-center text-(--bss-text) disabled:cursor-not-allowed disabled:opacity-30"
- aria-label="Previous page"
- >
-
-
-
-
-
-
- {getPaginationItems().map((item) => {
- if (item.type === 'ellipsis') {
- return (
-
- …
-
- )
- }
+ )}
+
+ )
+}
- const isActive = item.value === currentPage
+function TagPicker({
+ selected,
+ allTags,
+ onChange,
+}: {
+ selected: string[]
+ allTags: string[]
+ onChange: (tags: string[]) => void
+}) {
+ const availableTags = allTags
+ .filter((tag) => !selected.includes(tag))
+ .map((tag) => ({ value: tag, label: tag }))
- return (
- handlePageChange(item.value)}
- aria-current={isActive ? 'page' : undefined}
- className={`relative flex h-12 w-12 items-center justify-center text-sm transition-colors ${
- isActive
- ? 'font-semibold after:absolute after:bottom-0 after:left-1/2 after:h-1 after:w-6 after:-translate-x-1/2 after:rounded-full after:bg-(--videos-video-title)'
- : 'text-(--bss-text-secondary) hover:text-(--orange)'
- }`}
- >
- {item.value}
-
- )
- })}
-
+ return (
+
+
{
+ if (tag !== '') {
+ onChange([...selected, tag])
+ }
+ }}
+ placeholder={
+ selected.length > 0 ? `${selected.length} kiválasztva (ÉS)` : 'Mind'
+ }
+ searchPlaceholder="Címke keresése…"
+ labelClassName={FILTER_LABEL_CLASS}
+ />
+
+ )
+}
- handlePageChange(currentPage + 1)}
- disabled={currentPage === totalPages}
- className="flex h-12 w-12 items-center justify-center text-(--bss-text-secondary) disabled:cursor-not-allowed disabled:opacity-30"
- aria-label="Next page"
- >
-
-
-
-
-
-
-
+function Pagination({
+ page,
+ totalPages,
+ onPage,
+}: {
+ page: number
+ totalPages: number
+ onPage: (page: number) => void
+}) {
+ if (totalPages <= 1) {
+ return null
+ }
+ const pages = Array.from({ length: totalPages }, (_, index) => index + 1)
+ return (
+
+ onPage(page - 1)}
+ aria-label="Előző oldal"
+ className="ctrl-btn h-10 rounded px-3"
+ >
+ ‹
+
+ {pages.map((value) => (
+ onPage(value)}
+ aria-current={value === page ? 'page' : undefined}
+ className={`ctrl-btn h-10 w-10 rounded ${value === page ? 'font-bold text-(--orange)' : 'text-(--bss-text-secondary)'}`}
+ >
+ {value}
+
+ ))}
+ onPage(page + 1)}
+ aria-label="Következő oldal"
+ className="ctrl-btn h-10 rounded px-3"
+ >
+ ›
+
+
)
}
diff --git a/src/server.ts b/src/server.ts
new file mode 100644
index 0000000..f62537a
--- /dev/null
+++ b/src/server.ts
@@ -0,0 +1,102 @@
+import {
+ createStartHandler,
+ defaultStreamHandler,
+} from '@tanstack/react-start/server'
+import { handleApiRequest, API_PATH_PREFIXES } from '#/server/api/router.ts'
+import { startBackgroundRunner } from '#/server/jobs/runner.ts'
+import type { BackgroundRunnerHandle } from '#/server/jobs/runner.ts'
+import {
+ COURSE_REDIRECT_TARGET,
+ isCoursesPath,
+} from '#/server/pages/courses-redirect.ts'
+import { securityHeaders, robotsTxt } from '#/server/http/security-headers.ts'
+import { getSitemapEntries, sitemapXml } from '#/server/pages/sitemap.ts'
+import { getDefaultDb } from '#/server/auth/session-store.ts'
+
+const ssrHandler = createStartHandler(defaultStreamHandler)
+
+// Background jobs (startup + hourly sync) start only once.
+// On error the application keeps running; the error goes into the runs table.
+let runnerHandle: BackgroundRunnerHandle | null = null
+
+function ensureBackgroundRunner(): void {
+ if (runnerHandle === null) {
+ try {
+ runnerHandle = startBackgroundRunner()
+ } catch (error) {
+ console.error('[jobs] A háttérfutató indítása nem sikerült:', error)
+ }
+ }
+}
+
+function isApiPath(pathname: string): boolean {
+ return API_PATH_PREFIXES.some((prefix) => pathname.startsWith(prefix))
+}
+
+// In development mode, Vite module and asset requests also pass through
+// this handler. We must let them through to the Vite middleware: the
+// TanStack Router treats route segments starting with `$` as parameters,
+// so it would respond with a 307 to `/src/routes/videos/undefined` for
+// `/src/routes/videos/$slug.tsx`. Because of this the route tree's module
+// graph never loads, the client never hydrates, and no button works.
+const DEV_ASSET_PREFIXES = ['/@', '/src/', '/node_modules/'] as const
+
+function isDevAssetPath(pathname: string): boolean {
+ return (
+ import.meta.env.DEV &&
+ DEV_ASSET_PREFIXES.some((prefix) => pathname.startsWith(prefix))
+ )
+}
+
+/** Augmenting the SSR response (and everything else) with security headers. */
+async function runWithSecurityHeaders(request: Request): Promise {
+ const response = await ssrHandler(request)
+ const headers = new Headers(response.headers)
+ for (const [name, value] of Object.entries(securityHeaders())) {
+ if (!headers.has(name)) {
+ headers.set(name, value)
+ }
+ }
+ return new Response(response.body, {
+ status: response.status,
+ statusText: response.statusText,
+ headers,
+ })
+}
+
+export default {
+ async fetch(request: Request): Promise {
+ const url = new URL(request.url)
+ const { pathname } = url
+ if (isDevAssetPath(pathname)) {
+ // 404 → the request continues to the Vite dev middleware.
+ return new Response(null, { status: 404 })
+ }
+ if (isApiPath(pathname)) {
+ ensureBackgroundRunner()
+ return handleApiRequest(request)
+ }
+ if (isCoursesPath(pathname)) {
+ return new Response(null, {
+ status: 302,
+ headers: { location: COURSE_REDIRECT_TARGET },
+ })
+ }
+ if (pathname === '/robots.txt') {
+ return new Response(robotsTxt(url.origin), {
+ headers: { 'content-type': 'text/plain; charset=utf-8' },
+ })
+ }
+ if (pathname === '/sitemap.xml') {
+ const db = await getDefaultDb()
+ const entries = await getSitemapEntries(db)
+ return new Response(sitemapXml(entries, url.origin), {
+ headers: {
+ 'content-type': 'application/xml; charset=utf-8',
+ 'cache-control': 'public, max-age=600',
+ },
+ })
+ }
+ return runWithSecurityHeaders(request)
+ },
+}
diff --git a/src/server/admin/audit-admin.ts b/src/server/admin/audit-admin.ts
new file mode 100644
index 0000000..3e431c9
--- /dev/null
+++ b/src/server/admin/audit-admin.ts
@@ -0,0 +1,159 @@
+import { and, desc, eq, gte, lte, sql } from 'drizzle-orm'
+import type { SQL } from 'drizzle-orm'
+import { auditLog } from '#/db/schema.ts'
+import type { Executor } from '#/server/shared/db-executor.ts'
+
+/**
+ * Audit log admin (BSS-033, spec 13.2): read-only leadership view with
+ * actor, action, entity and date filters. No editing, deletion or export
+ * (spec 19) — a DB trigger also blocks writes.
+ */
+
+const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/
+
+export interface AuditFilters {
+ actor?: string
+ action?: string
+ entityType?: string
+ entityId?: string
+ dateFrom?: string
+ dateTo?: string
+}
+
+export function parseAuditFilters(raw: Record): AuditFilters {
+ const filters: AuditFilters = {}
+ if (typeof raw['actor'] === 'string' && raw['actor'].trim() !== '') {
+ filters.actor = raw['actor'].trim()
+ }
+ if (typeof raw['action'] === 'string' && raw['action'].trim() !== '') {
+ filters.action = raw['action'].trim()
+ }
+ if (
+ typeof raw['entityType'] === 'string' &&
+ raw['entityType'].trim() !== ''
+ ) {
+ filters.entityType = raw['entityType'].trim()
+ }
+ if (typeof raw['entityId'] === 'string' && raw['entityId'].trim() !== '') {
+ filters.entityId = raw['entityId'].trim()
+ }
+ if (typeof raw['from'] === 'string' && DATE_PATTERN.test(raw['from'])) {
+ filters.dateFrom = raw['from']
+ }
+ if (typeof raw['to'] === 'string' && DATE_PATTERN.test(raw['to'])) {
+ filters.dateTo = raw['to']
+ }
+ return filters
+}
+
+export interface AuditListItem {
+ id: number
+ actor: string
+ entityType: string
+ entityId: string
+ action: string
+ /** Before-and-after values as client-safe JSON text. */
+ beforeJson: string | null
+ afterJson: string | null
+ occurredAt: Date
+}
+
+export async function getAuditPage(
+ executor: Executor,
+ query: { page: number; perPage: number; filters?: AuditFilters },
+): Promise<{
+ items: AuditListItem[]
+ total: number
+ page: number
+ perPage: number
+ totalPages: number
+}> {
+ const conditions: SQL[] = []
+ const filters = query.filters ?? {}
+ if (filters.actor !== undefined) {
+ // Both the `system` actor and members can be searched.
+ conditions.push(eq(auditLog.actor, filters.actor))
+ }
+ if (filters.action !== undefined) {
+ conditions.push(eq(auditLog.action, filters.action))
+ }
+ if (filters.entityType !== undefined) {
+ conditions.push(eq(auditLog.entityType, filters.entityType))
+ }
+ if (filters.entityId !== undefined) {
+ conditions.push(eq(auditLog.entityId, filters.entityId))
+ }
+ if (filters.dateFrom !== undefined) {
+ conditions.push(
+ gte(auditLog.occurredAt, new Date(`${filters.dateFrom}T00:00:00Z`)),
+ )
+ }
+ if (filters.dateTo !== undefined) {
+ conditions.push(
+ lte(auditLog.occurredAt, new Date(`${filters.dateTo}T23:59:59Z`)),
+ )
+ }
+ const where = conditions.length > 0 ? and(...conditions) : undefined
+
+ const [items, countRows] = await Promise.all([
+ executor
+ .select({
+ id: auditLog.id,
+ actor: auditLog.actor,
+ entityType: auditLog.entityType,
+ entityId: auditLog.entityId,
+ action: auditLog.action,
+ beforeValue: auditLog.beforeValue,
+ afterValue: auditLog.afterValue,
+ occurredAt: auditLog.occurredAt,
+ })
+ .from(auditLog)
+ .where(where)
+ .orderBy(desc(auditLog.occurredAt), desc(auditLog.id))
+ .limit(query.perPage)
+ .offset((query.page - 1) * query.perPage),
+ executor
+ .select({ count: sql`count(*)::int` })
+ .from(auditLog)
+ .where(where),
+ ])
+
+ const total = countRows.at(0)?.count ?? 0
+ return {
+ items: items.map((item) => ({
+ ...item,
+ beforeJson:
+ item.beforeValue === null || item.beforeValue === undefined
+ ? null
+ : JSON.stringify(item.beforeValue, null, 2),
+ afterJson:
+ item.afterValue === null || item.afterValue === undefined
+ ? null
+ : JSON.stringify(item.afterValue, null, 2),
+ })),
+ total,
+ page: query.page,
+ perPage: query.perPage,
+ totalPages: query.perPage > 0 ? Math.ceil(total / query.perPage) : 0,
+ }
+}
+
+/** Available action and entity type values for the filters. */
+export async function getAuditFilterValues(
+ executor: Executor,
+): Promise<{ actions: string[]; entityTypes: string[] }> {
+ const [actions, entityTypes] = await Promise.all([
+ executor
+ .selectDistinct({ value: auditLog.action })
+ .from(auditLog)
+ .orderBy(auditLog.action),
+ executor
+ .selectDistinct({ value: auditLog.entityType })
+ .from(auditLog)
+ .orderBy(auditLog.entityType),
+ ])
+ return {
+ actions: actions.map((row) => row.value),
+ entityTypes: entityTypes.map((row) => row.value),
+ }
+}
diff --git a/src/server/admin/event-list.ts b/src/server/admin/event-list.ts
new file mode 100644
index 0000000..1223e43
--- /dev/null
+++ b/src/server/admin/event-list.ts
@@ -0,0 +1,177 @@
+import { and, desc, eq, gte, ilike, lte, or, sql } from 'drizzle-orm'
+import type { SQL } from 'drizzle-orm'
+import { events, memberCache, videos } from '#/db/schema.ts'
+import type { Executor } from '#/server/shared/db-executor.ts'
+
+const EVENT_STATUSES = ['draft', 'published', 'archived'] as const
+
+export interface AdminEventListFilters {
+ q?: string
+ status?: string
+ dateFrom?: string
+ dateTo?: string
+}
+
+export function parseAdminEventFilters(raw: {
+ q?: unknown
+ status?: unknown
+ from?: unknown
+ to?: unknown
+}): AdminEventListFilters {
+ const filters: AdminEventListFilters = {}
+ if (typeof raw.q === 'string' && raw.q.trim() !== '') {
+ filters.q = raw.q.trim()
+ }
+ if (
+ typeof raw.status === 'string' &&
+ (EVENT_STATUSES as readonly string[]).includes(raw.status)
+ ) {
+ filters.status = raw.status
+ }
+ if (typeof raw.from === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(raw.from)) {
+ filters.dateFrom = raw.from
+ }
+ if (typeof raw.to === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(raw.to)) {
+ filters.dateTo = raw.to
+ }
+ return filters
+}
+
+export interface AdminEventListItem {
+ id: string
+ slug: string
+ title: string
+ startDate: string | null
+ endDate: string | null
+ status: string
+ videoCount: number
+ updatedByName: string | null
+ updatedAt: Date
+ version: number
+}
+
+/**
+ * Event admin list (spec 12.3): title, date range, status,
+ * video count (all statuses), last modifier and timestamp.
+ */
+export async function getAdminEventList(
+ executor: Executor,
+ query: { page: number; perPage: number; filters?: AdminEventListFilters },
+): Promise<{
+ items: AdminEventListItem[]
+ total: number
+ page: number
+ perPage: number
+ totalPages: number
+}> {
+ const conditions: SQL[] = []
+ const filters = query.filters ?? {}
+ if (filters.q !== undefined) {
+ const pattern = `%${filters.q}%`
+ const condition = or(
+ ilike(events.title, pattern),
+ ilike(events.slug, pattern),
+ )
+ if (condition !== undefined) {
+ conditions.push(condition)
+ }
+ }
+ if (filters.status !== undefined) {
+ conditions.push(eq(events.status, filters.status as never))
+ }
+ if (filters.dateFrom !== undefined) {
+ conditions.push(gte(events.startDate, filters.dateFrom))
+ }
+ if (filters.dateTo !== undefined) {
+ conditions.push(lte(events.startDate, filters.dateTo))
+ }
+ const where = conditions.length > 0 ? and(...conditions) : undefined
+
+ const videoCountSub = sql`(select count(*)::int from ${videos} where ${videos.eventId} = ${events.id})`
+
+ const items = await executor
+ .select({
+ id: events.id,
+ slug: events.slug,
+ title: events.title,
+ startDate: events.startDate,
+ endDate: events.endDate,
+ status: events.status,
+ videoCount: videoCountSub,
+ updatedByName: memberCache.fullName,
+ updatedAt: events.updatedAt,
+ version: events.version,
+ })
+ .from(events)
+ .leftJoin(memberCache, eq(memberCache.sub, events.updatedBy))
+ .where(where)
+ .orderBy(desc(events.startDate), desc(events.id))
+ .limit(query.perPage)
+ .offset((query.page - 1) * query.perPage)
+
+ const countRows = await executor
+ .select({ count: sql`count(*)::int` })
+ .from(events)
+ .where(where)
+ const total = countRows.at(0)?.count ?? 0
+
+ return {
+ items: items.map((item) => ({
+ ...item,
+ videoCount: Number(item.videoCount),
+ })),
+ total,
+ page: query.page,
+ perPage: query.perPage,
+ totalPages: query.perPage > 0 ? Math.ceil(total / query.perPage) : 0,
+ }
+}
+
+export interface AdminEventDetail {
+ id: string
+ slug: string
+ title: string
+ description: string | null
+ thumbnailUrl: string | null
+ startDate: string | null
+ endDate: string | null
+ status: string
+ version: number
+ updatedAt: Date
+ updatedByName: string | null
+ /** For the hard-delete preview: how many videos will be detached. */
+ attachedVideoIds: string[]
+}
+
+export async function getAdminEventDetail(
+ executor: Executor,
+ eventId: string,
+): Promise {
+ const rows = await executor
+ .select({
+ id: events.id,
+ slug: events.slug,
+ title: events.title,
+ description: events.description,
+ thumbnailUrl: events.thumbnailUrl,
+ startDate: events.startDate,
+ endDate: events.endDate,
+ status: events.status,
+ version: events.version,
+ updatedAt: events.updatedAt,
+ updatedByName: memberCache.fullName,
+ })
+ .from(events)
+ .leftJoin(memberCache, eq(memberCache.sub, events.updatedBy))
+ .where(eq(events.id, eventId))
+ .limit(1)
+ const row = rows.at(0)
+ if (row === undefined) {
+ return null
+ }
+ const attached = await executor
+ .select({ id: videos.id })
+ .from(videos)
+ .where(eq(videos.eventId, eventId))
+ return { ...row, attachedVideoIds: attached.map((item) => item.id) }
+}
diff --git a/src/server/admin/homepage-admin.ts b/src/server/admin/homepage-admin.ts
new file mode 100644
index 0000000..2d8f8e8
--- /dev/null
+++ b/src/server/admin/homepage-admin.ts
@@ -0,0 +1,117 @@
+import { and, asc, desc, eq, inArray } from 'drizzle-orm'
+import { aboutPageVideos, liveStreams, videos } from '#/db/schema.ts'
+import type { Executor } from '#/server/shared/db-executor.ts'
+import { getHighlightedVideoId } from '#/server/homepage/highlight.ts'
+
+/**
+ * Load data for the Live and highlight admin pages (BSS-031). Only
+ * leadership may call it — both the route guard and the API endpoints verify this.
+ */
+
+export interface HomepageAdminData {
+ highlight: {
+ videoId: string | null
+ title: string | null
+ }
+ live: Array<{
+ id: string
+ youtubeVideoId: string
+ startsAt: Date
+ endsAt: Date
+ status: string
+ activationError: string | null
+ }>
+ about: Array<{
+ videoId: string
+ position: number
+ title: string | null
+ /** Marks an invalid (archived/trashed/non-public) entry. */
+ valid: boolean
+ }>
+ /** Videos selectable for highlight and About: published + public. */
+ selectableVideos: Array<{
+ id: string
+ title: string
+ publishedAt: Date | null
+ }>
+}
+
+export async function getHomepageAdminData(
+ executor: Executor,
+): Promise {
+ const highlightedVideoId = await getHighlightedVideoId(executor)
+
+ const [liveRows, aboutRows, selectable] = await Promise.all([
+ executor.select().from(liveStreams).orderBy(desc(liveStreams.startsAt)),
+ executor
+ .select({
+ videoId: aboutPageVideos.videoId,
+ position: aboutPageVideos.position,
+ title: videos.title,
+ status: videos.status,
+ visibility: videos.visibility,
+ })
+ .from(aboutPageVideos)
+ .leftJoin(videos, eq(videos.id, aboutPageVideos.videoId))
+ .orderBy(asc(aboutPageVideos.position)),
+ executor
+ .select({
+ id: videos.id,
+ title: videos.title,
+ publishedAt: videos.publishedAt,
+ })
+ .from(videos)
+ .where(
+ and(eq(videos.status, 'published'), eq(videos.visibility, 'public')),
+ )
+ .orderBy(desc(videos.publishedAt))
+ .limit(500),
+ ])
+
+ let highlightTitle: string | null = null
+ if (highlightedVideoId !== null) {
+ const rows = await executor
+ .select({ title: videos.title })
+ .from(videos)
+ .where(eq(videos.id, highlightedVideoId))
+ .limit(1)
+ highlightTitle = rows.at(0)?.title ?? null
+ }
+
+ return {
+ highlight: { videoId: highlightedVideoId, title: highlightTitle },
+ live: liveRows.map((row) => ({
+ id: row.id,
+ youtubeVideoId: row.youtubeVideoId,
+ startsAt: row.startsAt,
+ endsAt: row.endsAt,
+ status: row.status,
+ activationError: row.activationError,
+ })),
+ about: aboutRows.map((row) => ({
+ videoId: row.videoId,
+ position: row.position,
+ title: row.title,
+ valid:
+ row.status === 'published' &&
+ row.visibility === 'public' &&
+ row.title !== null,
+ })),
+ selectableVideos: selectable,
+ }
+}
+
+/** Leadership preview of the About page: valid entries of the configured list. */
+export async function resolveAboutTitles(
+ executor: Executor,
+ videoIds: readonly string[],
+): Promise> {
+ if (videoIds.length === 0) {
+ return new Map()
+ }
+ const rows = await executor
+ .select({ id: videos.id, title: videos.title })
+ .from(videos)
+ .where(inArray(videos.id, [...videoIds]))
+ return new Map(rows.map((row) => [row.id, row.title]))
+}
diff --git a/src/server/admin/member-diagnostics.ts b/src/server/admin/member-diagnostics.ts
new file mode 100644
index 0000000..ef0c0d5
--- /dev/null
+++ b/src/server/admin/member-diagnostics.ts
@@ -0,0 +1,110 @@
+import { asc, desc, eq, lt, sql } from 'drizzle-orm'
+import { memberCache, memberSyncRuns } from '#/db/schema.ts'
+import type { Executor } from '#/server/shared/db-executor.ts'
+
+/**
+ * Hidden member diagnostics (BSS-032, spec 8.2): leadership can see the
+ * Authentik cache state without local profile editing.
+ */
+
+export interface DiagnosticsProfile {
+ sub: string
+ username: string
+ fullName: string
+ nickname: string | null
+ membershipStatus: string
+ isLeadership: boolean
+ syncStatus: string
+ lastSyncError: string | null
+ joinedSemesterRaw: string | null
+ lastSeenAt: Date
+ /** A member last seen before the most recent successful run has likely vanished. */
+ likelyVanished: boolean
+}
+
+export interface DiagnosticsRun {
+ id: number
+ trigger: string
+ status: string
+ startedAt: Date
+ finishedAt: Date | null
+ totalCount: number
+ changedCount: number
+ errorCount: number
+ message: string | null
+}
+
+export interface MemberDiagnostics {
+ profiles: DiagnosticsProfile[]
+ runs: DiagnosticsRun[]
+ summary: {
+ total: number
+ errorProfiles: number
+ likelyVanished: number
+ lastRunStatus: string | null
+ lastRunMessage: string | null
+ }
+}
+
+export async function getMemberDiagnostics(
+ executor: Executor,
+): Promise {
+ const [profiles, runs, lastOkRun] = await Promise.all([
+ executor.select().from(memberCache).orderBy(asc(memberCache.fullName)),
+ executor
+ .select()
+ .from(memberSyncRuns)
+ .orderBy(desc(memberSyncRuns.startedAt))
+ .limit(20),
+ executor
+ .select({ finishedAt: memberSyncRuns.finishedAt })
+ .from(memberSyncRuns)
+ .where(eq(memberSyncRuns.status, 'ok'))
+ .orderBy(desc(memberSyncRuns.startedAt))
+ .limit(1),
+ ])
+
+ // Profiles not seen since the last successful run have presumably
+ // vanished from Authentik (their last known record is retained).
+ const lastOkFinishedAt = lastOkRun.at(0)?.finishedAt ?? null
+ let likelyVanished = 0
+ if (lastOkFinishedAt !== null) {
+ const vanishedRows = await executor
+ .select({ count: sql`count(*)::int` })
+ .from(memberCache)
+ .where(lt(memberCache.lastSeenAt, lastOkFinishedAt))
+ likelyVanished = vanishedRows.at(0)?.count ?? 0
+ }
+
+ const errorProfiles = profiles.filter(
+ (profile) => profile.syncStatus === 'error',
+ ).length
+
+ return {
+ profiles: profiles.map((profile) => ({
+ ...profile,
+ membershipStatus: profile.membershipStatus,
+ likelyVanished:
+ lastOkFinishedAt !== null &&
+ profile.lastSeenAt.getTime() < lastOkFinishedAt.getTime(),
+ })),
+ runs: runs.map((run) => ({
+ id: run.id,
+ trigger: run.trigger,
+ status: run.status,
+ startedAt: run.startedAt,
+ finishedAt: run.finishedAt,
+ totalCount: run.totalCount,
+ changedCount: run.changedCount,
+ errorCount: run.errorCount,
+ message: run.message,
+ })),
+ summary: {
+ total: profiles.length,
+ errorProfiles,
+ likelyVanished,
+ lastRunStatus: runs.at(0)?.status ?? null,
+ lastRunMessage: runs.at(0)?.message ?? null,
+ },
+ }
+}
diff --git a/src/server/admin/trash-admin.ts b/src/server/admin/trash-admin.ts
new file mode 100644
index 0000000..783cb5e
--- /dev/null
+++ b/src/server/admin/trash-admin.ts
@@ -0,0 +1,93 @@
+import { and, desc, eq, lte, sql } from 'drizzle-orm'
+import { memberCache, videos } from '#/db/schema.ts'
+import type { Executor } from '#/server/shared/db-executor.ts'
+import { TRASH_RETENTION_DAYS } from '#/server/videos/purge.ts'
+
+/**
+ * Video trash admin (BSS-033, spec 13.1): every member can see the trash,
+ * who deleted each item and when; restoring is a leadership privilege.
+ */
+
+export interface TrashListItem {
+ id: string
+ slug: string
+ title: string
+ thumbnailUrl: string | null
+ trashedAt: Date
+ trashedByName: string | null
+ version: number
+}
+
+export interface TrashPage {
+ items: Array
+ total: number
+ page: number
+ perPage: number
+ totalPages: number
+ /** Number of records already due to be permanently deleted by the daily job. */
+ expiredCount: number
+}
+
+export function remainingTrashDays(trashedAt: Date, now: Date): number {
+ const elapsedMs = now.getTime() - trashedAt.getTime()
+ const retentionMs = TRASH_RETENTION_DAYS * 86_400_000
+ return Math.max(0, Math.ceil((retentionMs - elapsedMs) / 86_400_000))
+}
+
+export async function getTrashPage(
+ executor: Executor,
+ query: { page: number; perPage: number },
+): Promise {
+ const where = eq(videos.status, 'trash')
+
+ const [items, countRows, expiredRows] = await Promise.all([
+ executor
+ .select({
+ id: videos.id,
+ slug: videos.slug,
+ title: videos.title,
+ thumbnailUrl: videos.thumbnailUrl,
+ trashedAt: videos.trashedAt,
+ trashedByName: memberCache.fullName,
+ version: videos.version,
+ })
+ .from(videos)
+ .leftJoin(memberCache, eq(memberCache.sub, videos.trashedBy))
+ .where(where)
+ .orderBy(desc(videos.trashedAt), desc(videos.id))
+ .limit(query.perPage)
+ .offset((query.page - 1) * query.perPage),
+ executor
+ .select({ count: sql`count(*)::int` })
+ .from(videos)
+ .where(where),
+ executor
+ .select({ count: sql`count(*)::int` })
+ .from(videos)
+ .where(
+ and(
+ eq(videos.status, 'trash'),
+ lte(
+ videos.trashedAt,
+ new Date(Date.now() - TRASH_RETENTION_DAYS * 86_400_000),
+ ),
+ ),
+ ),
+ ])
+
+ const total = countRows.at(0)?.count ?? 0
+ const now = new Date()
+ return {
+ items: items.map((item) => ({
+ ...item,
+ trashedAt: item.trashedAt as unknown as Date,
+ remainingDays:
+ item.trashedAt !== null ? remainingTrashDays(item.trashedAt, now) : 0,
+ })),
+ total,
+ page: query.page,
+ perPage: query.perPage,
+ totalPages: query.perPage > 0 ? Math.ceil(total / query.perPage) : 0,
+ expiredCount: expiredRows.at(0)?.count ?? 0,
+ }
+}
diff --git a/src/server/admin/video-detail.ts b/src/server/admin/video-detail.ts
new file mode 100644
index 0000000..8867299
--- /dev/null
+++ b/src/server/admin/video-detail.ts
@@ -0,0 +1,196 @@
+import { and, asc, desc, eq, inArray, ne, sql } from 'drizzle-orm'
+import {
+ events,
+ memberCache,
+ relatedVideos,
+ staffRoles,
+ tags,
+ videoStaff,
+ videoTags,
+ videos,
+} from '#/db/schema.ts'
+import type { Executor } from '#/server/shared/db-executor.ts'
+
+export interface AdminVideoDetail {
+ id: string
+ slug: string
+ title: string
+ description: string | null
+ guests: string | null
+ songs: string | null
+ videoUrl: string | null
+ thumbnailUrl: string | null
+ visibility: string
+ status: string
+ eventId: string | null
+ eventTitle: string | null
+ recordedAt: string | null
+ publishedAt: Date | null
+ viewCount: number
+ version: number
+ updatedAt: Date
+ updatedByName: string | null
+ tagIds: string[]
+ staffAssignments: Array<{ roleId: string; memberSub: string }>
+ relatedVideoIds: string[]
+}
+
+/**
+ * Load data for the video editor (BSS-028). Only admins may call it —
+ * the route guard verifies at least membership; the domain operations
+ * re-verify on every save.
+ */
+export async function getAdminVideoDetail(
+ executor: Executor,
+ videoId: string,
+): Promise {
+ const rows = await executor
+ .select({
+ id: videos.id,
+ slug: videos.slug,
+ title: videos.title,
+ description: videos.description,
+ guests: videos.guests,
+ songs: videos.songs,
+ videoUrl: videos.videoUrl,
+ thumbnailUrl: videos.thumbnailUrl,
+ visibility: videos.visibility,
+ status: videos.status,
+ eventId: videos.eventId,
+ eventTitle: events.title,
+ recordedAt: videos.recordedAt,
+ publishedAt: videos.publishedAt,
+ viewCount: videos.viewCount,
+ version: videos.version,
+ updatedAt: videos.updatedAt,
+ updatedByName: memberCache.fullName,
+ })
+ .from(videos)
+ .leftJoin(events, eq(events.id, videos.eventId))
+ .leftJoin(memberCache, eq(memberCache.sub, videos.updatedBy))
+ .where(eq(videos.id, videoId))
+ .limit(1)
+ const row = rows.at(0)
+ if (row === undefined) {
+ return null
+ }
+
+ const [tagRows, staffRows, relatedRows] = await Promise.all([
+ executor
+ .select({ tagId: videoTags.tagId })
+ .from(videoTags)
+ .where(eq(videoTags.videoId, videoId)),
+ executor
+ .select({ roleId: videoStaff.roleId, memberSub: videoStaff.memberSub })
+ .from(videoStaff)
+ .where(eq(videoStaff.videoId, videoId)),
+ executor
+ .select({ relatedVideoId: relatedVideos.relatedVideoId })
+ .from(relatedVideos)
+ .where(eq(relatedVideos.videoId, videoId))
+ .orderBy(asc(relatedVideos.position)),
+ ])
+
+ return {
+ ...row,
+ tagIds: tagRows.map((item) => item.tagId),
+ staffAssignments: staffRows.map((item) => ({
+ roleId: item.roleId,
+ memberSub: item.memberSub,
+ })),
+ relatedVideoIds: relatedRows.map((item) => item.relatedVideoId),
+ }
+}
+
+export interface AdminVideoEditorOptions {
+ /** Events in any status (can also be assigned to drafts). */
+ events: Array<{ id: string; title: string }>
+ tags: Array<{ id: string; name: string }>
+ staffRoles: Array<{ id: string; name: string }>
+ members: Array<{ sub: string; fullName: string }>
+ /** Only published videos can be selected as related (spec 5.6), and not itself. */
+ candidateRelated: Array<{ id: string; title: string }>
+}
+
+export async function getAdminVideoEditorOptions(
+ executor: Executor,
+ excludeVideoId?: string,
+): Promise {
+ const [eventRows, tagRows, roleRows, memberRows, relatedCandidates] =
+ await Promise.all([
+ executor
+ .select({ id: events.id, title: events.title })
+ .from(events)
+ .orderBy(asc(events.title)),
+ executor
+ .select({ id: tags.id, name: tags.name })
+ .from(tags)
+ .orderBy(asc(tags.name)),
+ executor
+ .select({ id: staffRoles.id, name: staffRoles.name })
+ .from(staffRoles)
+ .orderBy(asc(staffRoles.displayOrder), asc(staffRoles.name)),
+ executor
+ .select({ sub: memberCache.sub, fullName: memberCache.fullName })
+ .from(memberCache)
+ .where(eq(memberCache.syncStatus, 'ok'))
+ .orderBy(asc(memberCache.fullName))
+ .limit(2000),
+ executor
+ .select({ id: videos.id, title: videos.title })
+ .from(videos)
+ .where(
+ excludeVideoId === undefined
+ ? eq(videos.status, 'published')
+ : and(
+ eq(videos.status, 'published'),
+ ne(videos.id, excludeVideoId),
+ ),
+ )
+ .orderBy(desc(sql`${videos.publishedAt}`))
+ .limit(1000),
+ ])
+ return {
+ events: eventRows,
+ tags: tagRows,
+ staffRoles: roleRows,
+ members: memberRows,
+ candidateRelated: relatedCandidates,
+ }
+}
+
+/** Resolve tag and staff names for displaying in the editor. */
+export async function resolveAdminVideoNames(
+ executor: Executor,
+ ids: { tagIds: string[]; roleIds: string[]; memberSubs: string[] },
+): Promise<{
+ tagNames: Map
+ roleNames: Map
+ memberNames: Map
+}> {
+ const [tagRows, roleRows, memberRows] = await Promise.all([
+ ids.tagIds.length > 0
+ ? executor
+ .select({ id: tags.id, name: tags.name })
+ .from(tags)
+ .where(inArray(tags.id, ids.tagIds))
+ : Promise.resolve([] as Array<{ id: string; name: string }>),
+ ids.roleIds.length > 0
+ ? executor
+ .select({ id: staffRoles.id, name: staffRoles.name })
+ .from(staffRoles)
+ .where(inArray(staffRoles.id, ids.roleIds))
+ : Promise.resolve([] as Array<{ id: string; name: string }>),
+ ids.memberSubs.length > 0
+ ? executor
+ .select({ sub: memberCache.sub, fullName: memberCache.fullName })
+ .from(memberCache)
+ .where(inArray(memberCache.sub, ids.memberSubs))
+ : Promise.resolve([] as Array<{ sub: string; fullName: string }>),
+ ])
+ return {
+ tagNames: new Map(tagRows.map((row) => [row.id, row.name])),
+ roleNames: new Map(roleRows.map((row) => [row.id, row.name])),
+ memberNames: new Map(memberRows.map((row) => [row.sub, row.fullName])),
+ }
+}
diff --git a/src/server/admin/video-list.ts b/src/server/admin/video-list.ts
new file mode 100644
index 0000000..f57e450
--- /dev/null
+++ b/src/server/admin/video-list.ts
@@ -0,0 +1,182 @@
+import { and, desc, eq, ilike, or, sql } from 'drizzle-orm'
+import type { SQL } from 'drizzle-orm'
+import { events, memberCache, tags, videoTags, videos } from '#/db/schema.ts'
+import type { Executor } from '#/server/shared/db-executor.ts'
+
+export const ADMIN_PAGE_SIZES = [10, 25, 50, 100] as const
+export const ADMIN_DEFAULT_PAGE_SIZE = 25
+
+const CONTENT_STATUSES = ['draft', 'published', 'archived', 'trash'] as const
+const VISIBILITIES = ['public', 'schonherz', 'bss'] as const
+
+export interface AdminVideoListFilters {
+ q?: string
+ status?: string
+ visibility?: string
+ eventId?: string
+ tagId?: string
+}
+
+export interface AdminVideoListItem {
+ id: string
+ slug: string
+ title: string
+ thumbnailUrl: string | null
+ status: string
+ visibility: string
+ eventId: string | null
+ eventTitle: string | null
+ recordedAt: string | null
+ publishedAt: Date | null
+ viewCount: number
+ updatedByName: string | null
+ updatedAt: Date
+ version: number
+}
+
+export interface AdminVideoListQuery {
+ page: number
+ perPage: number
+ filters?: AdminVideoListFilters
+}
+
+/** Parse URL values; an unknown filter value falls back to the default state. */
+export function parseAdminVideoFilters(raw: {
+ q?: unknown
+ status?: unknown
+ visibility?: unknown
+ event?: unknown
+ tag?: unknown
+}): AdminVideoListFilters {
+ const filters: AdminVideoListFilters = {}
+ if (typeof raw.q === 'string' && raw.q.trim() !== '') {
+ filters.q = raw.q.trim()
+ }
+ if (
+ typeof raw.status === 'string' &&
+ (CONTENT_STATUSES as readonly string[]).includes(raw.status)
+ ) {
+ filters.status = raw.status
+ }
+ if (
+ typeof raw.visibility === 'string' &&
+ (VISIBILITIES as readonly string[]).includes(raw.visibility)
+ ) {
+ filters.visibility = raw.visibility
+ }
+ if (typeof raw.event === 'string' && /^[0-9a-f-]{36}$/i.test(raw.event)) {
+ filters.eventId = raw.event
+ }
+ if (typeof raw.tag === 'string' && /^[0-9a-f-]{36}$/i.test(raw.tag)) {
+ filters.tagId = raw.tag
+ }
+ return filters
+}
+
+/**
+ * Admin video list (spec 12.2): all statuses visible, paginated,
+ * with search and status, visibility, event and tag filters.
+ * No bulk operations (spec 19).
+ */
+export async function getAdminVideoList(
+ executor: Executor,
+ query: AdminVideoListQuery,
+): Promise<{
+ items: AdminVideoListItem[]
+ total: number
+ page: number
+ perPage: number
+ totalPages: number
+}> {
+ const conditions: SQL[] = []
+ const filters = query.filters ?? {}
+ if (filters.q !== undefined) {
+ const pattern = `%${filters.q}%`
+ const condition = or(
+ ilike(videos.title, pattern),
+ ilike(videos.slug, pattern),
+ ilike(videos.description, pattern),
+ )
+ if (condition !== undefined) {
+ conditions.push(condition)
+ }
+ }
+ if (filters.status !== undefined) {
+ conditions.push(eq(videos.status, filters.status as never))
+ }
+ if (filters.visibility !== undefined) {
+ conditions.push(eq(videos.visibility, filters.visibility as never))
+ }
+ if (filters.eventId !== undefined) {
+ conditions.push(eq(videos.eventId, filters.eventId))
+ }
+ if (filters.tagId !== undefined) {
+ conditions.push(
+ sql`exists (select 1 from ${videoTags} where ${videoTags.videoId} = ${videos.id} and ${videoTags.tagId} = ${filters.tagId})`,
+ )
+ }
+ const where = conditions.length > 0 ? and(...conditions) : undefined
+
+ const offset = (query.page - 1) * query.perPage
+
+ const items = await executor
+ .select({
+ id: videos.id,
+ slug: videos.slug,
+ title: videos.title,
+ thumbnailUrl: videos.thumbnailUrl,
+ status: videos.status,
+ visibility: videos.visibility,
+ eventId: videos.eventId,
+ eventTitle: events.title,
+ recordedAt: videos.recordedAt,
+ publishedAt: videos.publishedAt,
+ viewCount: videos.viewCount,
+ updatedByName: memberCache.fullName,
+ updatedAt: videos.updatedAt,
+ version: videos.version,
+ })
+ .from(videos)
+ .leftJoin(events, eq(events.id, videos.eventId))
+ .leftJoin(memberCache, eq(memberCache.sub, videos.updatedBy))
+ .where(where)
+ .orderBy(desc(videos.updatedAt), desc(videos.id))
+ .limit(query.perPage)
+ .offset(offset)
+
+ const countRows = await executor
+ .select({ count: sql`count(*)::int` })
+ .from(videos)
+ .where(where)
+ const total = countRows.at(0)?.count ?? 0
+
+ return {
+ items,
+ total,
+ page: query.page,
+ perPage: query.perPage,
+ totalPages: query.perPage > 0 ? Math.ceil(total / query.perPage) : 0,
+ }
+}
+
+export interface AdminVideoFilterOptions {
+ events: Array<{ id: string; title: string }>
+ tags: Array<{ id: string; name: string }>
+}
+
+/** Filter dropdowns for the admin video list. */
+export async function getAdminVideoFilterOptions(
+ executor: Executor,
+): Promise {
+ const [eventRows, tagRows] = await Promise.all([
+ executor
+ .select({ id: events.id, title: events.title })
+ .from(events)
+ .orderBy(events.title),
+ executor
+ .select({ id: tags.id, name: tags.name })
+ .from(tags)
+ .orderBy(tags.name),
+ ])
+ return { events: eventRows, tags: tagRows }
+}
diff --git a/src/server/api/admin/catalog-routes.ts b/src/server/api/admin/catalog-routes.ts
new file mode 100644
index 0000000..019c83e
--- /dev/null
+++ b/src/server/api/admin/catalog-routes.ts
@@ -0,0 +1,227 @@
+import type { Database } from '#/server/auth/session-store.ts'
+import { getDefaultDb } from '#/server/auth/session-store.ts'
+import type { Clock } from '#/lib/clock.ts'
+import { systemClock } from '#/lib/clock.ts'
+import type { Viewer } from '#/server/auth/viewer.ts'
+import { requireLeadership } from '#/server/auth/guards.ts'
+import {
+ createStaffRole,
+ deleteStaffRole,
+ listStaffRolesWithUsage,
+ mergeStaffRole,
+ reorderStaffRoles,
+ renameStaffRole,
+} from '#/server/catalog/staff-roles.ts'
+import {
+ createTag,
+ deleteTag,
+ findAccentSimilarTagNames,
+ listTagsWithUsage,
+ mergeTag,
+ renameTag,
+} from '#/server/catalog/tags.ts'
+import { jsonResponse, readJsonBody, runAdminHandler } from './http.ts'
+
+const UUID_PATTERN =
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
+
+export interface AdminCatalogRouteDeps {
+ db?: Database
+ clock?: Clock
+}
+
+function badId(): Response {
+ return jsonResponse(400, {
+ error: 'bad_request',
+ message: 'Érvénytelen azonosító.',
+ })
+}
+
+function parseId(id: string | undefined): string {
+ if (id === undefined || !UUID_PATTERN.test(id)) {
+ throw badId()
+ }
+ return id
+}
+
+async function stringBody(
+ request: Request,
+ keys: string[],
+): Promise> {
+ const body = await readJsonBody(request)
+ const result: Record = {}
+ for (const key of keys) {
+ const value = body[key]
+ if (typeof value === 'string') {
+ result[key] = value
+ }
+ }
+ return result
+}
+
+/**
+ * Tag catalog and staff role operations (BSS-030). Leadership-only —
+ * members must not be able to modify the catalogs even with direct API calls.
+ */
+export async function handleAdminTagRoutes(
+ request: Request,
+ action: string,
+ id: string | undefined,
+ deps: AdminCatalogRouteDeps = {},
+): Promise {
+ return runCatalogHandler(
+ request,
+ deps,
+ async (viewer, database) => {
+ const catalogDeps = { viewer, clock: deps.clock ?? systemClock }
+
+ switch (action) {
+ case 'list': {
+ return jsonResponse(200, {
+ ok: true,
+ tags: await listTagsWithUsage(database),
+ })
+ }
+ case 'similar': {
+ // Accent-similarity warning (spec 7.1): GET ?name=...
+ const name = new URL(request.url).searchParams.get('name') ?? ''
+ const similar = await findAccentSimilarTagNames(
+ database,
+ name,
+ id !== undefined && UUID_PATTERN.test(id)
+ ? { excludeTagId: id }
+ : {},
+ )
+ return jsonResponse(200, { ok: true, similar })
+ }
+ case 'create': {
+ const body = await stringBody(request, ['name'])
+ const row = await createTag(database, catalogDeps, body['name'] ?? '')
+ return jsonResponse(200, { ok: true, id: row.id })
+ }
+ case 'rename': {
+ const body = await stringBody(request, ['name'])
+ await renameTag(
+ database,
+ catalogDeps,
+ parseId(id),
+ body['name'] ?? '',
+ )
+ return jsonResponse(200, { ok: true })
+ }
+ case 'merge': {
+ const body = await stringBody(request, ['targetTagId'])
+ await mergeTag(
+ database,
+ catalogDeps,
+ parseId(id),
+ parseId(body['targetTagId']),
+ )
+ return jsonResponse(200, { ok: true })
+ }
+ case 'delete': {
+ const body = await stringBody(request, ['confirmation'])
+ const result = await deleteTag(
+ database,
+ catalogDeps,
+ parseId(id),
+ body['confirmation'],
+ )
+ return jsonResponse(200, { ok: true, ...result })
+ }
+ default:
+ return jsonResponse(404, { error: 'not_found' })
+ }
+ },
+ { allowGet: true },
+ )
+}
+
+export async function handleAdminStaffRoleRoutes(
+ request: Request,
+ action: string,
+ id: string | undefined,
+ deps: AdminCatalogRouteDeps = {},
+): Promise {
+ return runCatalogHandler(
+ request,
+ deps,
+ async (viewer, database) => {
+ const catalogDeps = { viewer, clock: deps.clock ?? systemClock }
+
+ switch (action) {
+ case 'create': {
+ const body = await stringBody(request, ['name'])
+ const row = await createStaffRole(
+ database,
+ catalogDeps,
+ body['name'] ?? '',
+ )
+ return jsonResponse(200, { ok: true, id: row.id })
+ }
+ case 'rename': {
+ const body = await stringBody(request, ['name'])
+ await renameStaffRole(
+ database,
+ catalogDeps,
+ parseId(id),
+ body['name'] ?? '',
+ )
+ return jsonResponse(200, { ok: true })
+ }
+ case 'merge': {
+ const body = await stringBody(request, ['targetRoleId'])
+ await mergeStaffRole(
+ database,
+ catalogDeps,
+ parseId(id),
+ parseId(body['targetRoleId']),
+ )
+ return jsonResponse(200, { ok: true })
+ }
+ case 'delete': {
+ await deleteStaffRole(database, catalogDeps, parseId(id))
+ return jsonResponse(200, { ok: true })
+ }
+ case 'reorder': {
+ const body = await readJsonBody(request)
+ const orderedRoleIds = Array.isArray(body['orderedRoleIds'])
+ ? body['orderedRoleIds'].filter(
+ (item): item is string => typeof item === 'string',
+ )
+ : []
+ await reorderStaffRoles(database, catalogDeps, orderedRoleIds)
+ return jsonResponse(200, { ok: true })
+ }
+ case 'list': {
+ return jsonResponse(200, {
+ ok: true,
+ roles: await listStaffRolesWithUsage(database),
+ })
+ }
+ default:
+ return jsonResponse(404, { error: 'not_found' })
+ }
+ },
+ { allowGet: true },
+ )
+}
+
+/** Shared leadership guard for the two catalogs. */
+async function runCatalogHandler(
+ request: Request,
+ deps: AdminCatalogRouteDeps,
+ handler: (viewer: Viewer, database: Database) => Promise,
+ options: { allowGet?: boolean } = {},
+): Promise {
+ return runAdminHandler(
+ request,
+ deps,
+ async (viewer) => {
+ requireLeadership(viewer)
+ const database = deps.db ?? (await getDefaultDb())
+ return handler(viewer, database)
+ },
+ options,
+ )
+}
diff --git a/src/server/api/admin/event-routes.ts b/src/server/api/admin/event-routes.ts
new file mode 100644
index 0000000..ee5c084
--- /dev/null
+++ b/src/server/api/admin/event-routes.ts
@@ -0,0 +1,145 @@
+import type { OobConfig } from '#/server/config/oob-schema.ts'
+import { getCachedOobConfig } from '#/server/config/load.ts'
+import type { Database } from '#/server/auth/session-store.ts'
+import { getDefaultDb } from '#/server/auth/session-store.ts'
+import type { Clock } from '#/lib/clock.ts'
+import { systemClock } from '#/lib/clock.ts'
+import { requireAdmin } from '#/server/auth/guards.ts'
+import {
+ archiveEvent,
+ createEvent,
+ permanentlyDeleteEvent,
+ publishEvent,
+ updateEvent,
+} from '#/server/events/domain.ts'
+import { jsonResponse, readJsonBody, runAdminHandler } from './http.ts'
+
+const UUID_PATTERN =
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
+
+export interface AdminEventRouteDeps {
+ db?: Database
+ config?: OobConfig
+ clock?: Clock
+ fetchImpl?: typeof fetch
+}
+
+function parseVersion(value: unknown): number {
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < 1) {
+ throw jsonResponse(400, {
+ error: 'bad_request',
+ message: 'A verziószám (version) kötelező pozitív egész szám.',
+ })
+ }
+ return value
+}
+
+/**
+ * Event admin operations (BSS-029). The domain layer re-verifies leadership
+ * privileges and writes an audit entry on hard delete.
+ */
+export async function handleAdminEventRoutes(
+ request: Request,
+ action: string,
+ id: string | undefined,
+ deps: AdminEventRouteDeps = {},
+): Promise {
+ return runAdminHandler(request, deps, async (viewer) => {
+ requireAdmin(viewer, new URL(request.url).pathname)
+ const database = deps.db ?? (await getDefaultDb())
+ const domainDeps = {
+ viewer,
+ clock: deps.clock ?? systemClock,
+ mediaConfig: deps.config?.media ?? getCachedOobConfig().media,
+ fetchImpl: deps.fetchImpl,
+ }
+
+ if (action === 'create' && id === undefined) {
+ const body = await readJsonBody(request)
+ const row = await createEvent(database, domainDeps, {
+ title: typeof body['title'] === 'string' ? body['title'] : '',
+ description: optionalNullableString(body['description']),
+ thumbnailUrl: optionalNullableString(body['thumbnailUrl']),
+ startDate: optionalNullableString(body['startDate']),
+ endDate: optionalNullableString(body['endDate']),
+ })
+ return jsonResponse(200, { ok: true, id: row.id, slug: row.slug })
+ }
+
+ if (id === undefined || !UUID_PATTERN.test(id)) {
+ return jsonResponse(400, {
+ error: 'bad_request',
+ message: 'Érvénytelen eseményazonosító.',
+ })
+ }
+ const body = await readJsonBody(request)
+
+ switch (action) {
+ case 'update': {
+ const row = await updateEvent(
+ database,
+ domainDeps,
+ id,
+ parseVersion(body['version']),
+ {
+ title: optionalString(body['title']),
+ description: optionalNullableString(body['description']),
+ thumbnailUrl: optionalNullableString(body['thumbnailUrl']),
+ startDate: optionalNullableString(body['startDate']),
+ endDate: optionalNullableString(body['endDate']),
+ slug: optionalString(body['slug']),
+ },
+ )
+ return jsonResponse(200, {
+ ok: true,
+ version: row.version,
+ slug: row.slug,
+ })
+ }
+ case 'publish': {
+ const row = await publishEvent(
+ database,
+ domainDeps,
+ id,
+ parseVersion(body['version']),
+ )
+ return jsonResponse(200, { ok: true, version: row.version })
+ }
+ case 'archive': {
+ const row = await archiveEvent(
+ database,
+ domainDeps,
+ id,
+ parseVersion(body['version']),
+ )
+ return jsonResponse(200, { ok: true, version: row.version })
+ }
+ case 'delete_permanent': {
+ const result = await permanentlyDeleteEvent(
+ database,
+ domainDeps,
+ id,
+ typeof body['confirmationTitle'] === 'string'
+ ? body['confirmationTitle']
+ : '',
+ )
+ return jsonResponse(200, {
+ ok: true,
+ detachedVideoCount: result.detachedVideoIds.length,
+ })
+ }
+ default:
+ return jsonResponse(404, { error: 'not_found' })
+ }
+ })
+}
+
+function optionalString(value: unknown): string | undefined {
+ return typeof value === 'string' ? value : undefined
+}
+
+function optionalNullableString(value: unknown): string | null | undefined {
+ if (value === undefined) return undefined
+ if (value === null) return null
+ return typeof value === 'string' ? value : undefined
+}
diff --git a/src/server/api/admin/homepage-routes.ts b/src/server/api/admin/homepage-routes.ts
new file mode 100644
index 0000000..9d34611
--- /dev/null
+++ b/src/server/api/admin/homepage-routes.ts
@@ -0,0 +1,215 @@
+import type { Database } from '#/server/auth/session-store.ts'
+import { getDefaultDb } from '#/server/auth/session-store.ts'
+import type { Clock } from '#/lib/clock.ts'
+import { systemClock } from '#/lib/clock.ts'
+import type { OobConfig } from '#/server/config/oob-schema.ts'
+import { jsonResponse, readJsonBody, runAdminHandler } from './http.ts'
+import { requireLeadership } from '#/server/auth/guards.ts'
+import {
+ createLiveSchedule,
+ deleteScheduledLive,
+ endLiveNow,
+ rescheduleLive,
+ startLiveNow,
+} from '#/server/homepage/live.ts'
+import { ABOUT_VIDEO_LIMIT, setAboutVideos } from '#/server/homepage/about.ts'
+import { setHighlightedVideo } from '#/server/homepage/highlight.ts'
+import { videos } from '#/db/schema.ts'
+import { and, eq, inArray } from 'drizzle-orm'
+import { validateYoutubeVideo } from '#/server/media/youtube.ts'
+
+const UUID_PATTERN =
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
+
+export interface AdminHomepageRouteDeps {
+ db?: Database
+ clock?: Clock
+ config?: OobConfig
+ fetchImpl?: typeof fetch
+}
+
+function validation(problems: string[]): Response {
+ return jsonResponse(400, { error: 'validation', problems })
+}
+
+function parseDate(value: unknown, field: string): Date {
+ if (typeof value !== 'string' || value.trim() === '') {
+ throw validation([`${field}: kötelező mező.`])
+ }
+ const date = new Date(value)
+ if (Number.isNaN(date.getTime())) {
+ throw validation([`${field}: érvénytelen időpont.`])
+ }
+ return date
+}
+
+async function databaseOf(deps: AdminHomepageRouteDeps): Promise {
+ return deps.db ?? (await getDefaultDb())
+}
+
+/** Only published + public videos can be selected as highlight or for About. */
+async function assertSelectableVideos(
+ database: Database,
+ ids: readonly string[],
+): Promise {
+ if (ids.length === 0) return
+ const rows = await database
+ .select({ id: videos.id })
+ .from(videos)
+ .where(
+ and(
+ inArray(videos.id, [...ids]),
+ eq(videos.status, 'published'),
+ eq(videos.visibility, 'public'),
+ ),
+ )
+ if (rows.length !== new Set(ids).size) {
+ throw validation([
+ 'Csak publikált, publikus videó választható (a lista tartalmaz érvénytelen elemet).',
+ ])
+ }
+}
+
+/**
+ * Leadership homepage settings (BSS-031): highlight, live schedules and
+ * About videos. Members cannot invoke any operation.
+ */
+export async function handleAdminHighlightRoute(
+ request: Request,
+ deps: AdminHomepageRouteDeps = {},
+): Promise {
+ return runAdminHandler(request, deps, async (viewer) => {
+ requireLeadership(viewer)
+ const body = await readJsonBody(request)
+ let videoId: string | null = null
+ if (body['videoId'] !== null && body['videoId'] !== undefined) {
+ if (
+ typeof body['videoId'] !== 'string' ||
+ !UUID_PATTERN.test(body['videoId'])
+ ) {
+ throw validation(['Érvénytelen videóazonosító.'])
+ }
+ videoId = body['videoId']
+ // Early Hungarian error message; the domain re-checks in a transaction.
+ await assertSelectableVideos(await databaseOf(deps), [videoId])
+ }
+ await setHighlightedVideo(await databaseOf(deps), {
+ viewer,
+ videoId,
+ clock: deps.clock,
+ })
+ return jsonResponse(200, { ok: true })
+ })
+}
+
+const LIVE_ACTION_PATTERN = /^(reschedule|start_now|end_now|delete)$/
+
+export async function handleAdminLiveRoutes(
+ request: Request,
+ id: string | undefined,
+ action: string | undefined,
+ deps: AdminHomepageRouteDeps = {},
+): Promise {
+ return runAdminHandler(request, deps, async (viewer) => {
+ requireLeadership(viewer)
+ const liveDeps = {
+ viewer,
+ clock: deps.clock ?? systemClock,
+ fetchImpl: deps.fetchImpl,
+ }
+ const database = await databaseOf(deps)
+
+ if (action === undefined) {
+ // Create a new schedule.
+ const body = await readJsonBody(request)
+ const youtubeUrl =
+ typeof body['youtubeUrl'] === 'string' ? body['youtubeUrl'] : ''
+ const startsAt = parseDate(body['startsAt'], 'Kezdési idő')
+ const endsAt = parseDate(body['endsAt'], 'Befejezési idő')
+ // Early YouTube validation with a Hungarian error message; the domain
+ // re-checks it on save (oEmbed).
+ const youtubeCheck = await validateYoutubeVideo(
+ youtubeUrl,
+ {
+ oEmbedEndpoint:
+ deps.config?.youtube.oEmbedEndpoint ??
+ 'https://www.youtube.com/oembed',
+ },
+ { fetchImpl: deps.fetchImpl },
+ )
+ if (!youtubeCheck.ok || youtubeCheck.videoId === null) {
+ throw validation(youtubeCheck.problems)
+ }
+ const row = await createLiveSchedule(database, liveDeps, {
+ youtubeUrl,
+ startsAt,
+ endsAt,
+ })
+ return jsonResponse(200, { ok: true, id: row.id })
+ }
+
+ if (id === undefined || !UUID_PATTERN.test(id)) {
+ throw validation(['Érvénytelen live azonosító.'])
+ }
+ if (!LIVE_ACTION_PATTERN.test(action)) {
+ return jsonResponse(404, { error: 'not_found' })
+ }
+ const body = action === 'reschedule' ? await readJsonBody(request) : {}
+
+ switch (action) {
+ case 'reschedule': {
+ await rescheduleLive(database, liveDeps, id, {
+ startsAt: parseDate(body['startsAt'], 'Kezdési idő'),
+ endsAt: parseDate(body['endsAt'], 'Befejezési idő'),
+ })
+ return jsonResponse(200, { ok: true })
+ }
+ case 'start_now': {
+ const result = await startLiveNow(database, liveDeps, id)
+ return jsonResponse(200, { ok: true, activated: result.activated })
+ }
+ case 'end_now': {
+ await endLiveNow(database, liveDeps, id)
+ return jsonResponse(200, { ok: true })
+ }
+ default: {
+ await deleteScheduledLive(database, liveDeps, id)
+ return jsonResponse(200, { ok: true })
+ }
+ }
+ })
+}
+
+export async function handleAdminAboutRoute(
+ request: Request,
+ deps: AdminHomepageRouteDeps = {},
+): Promise {
+ return runAdminHandler(request, deps, async (viewer) => {
+ requireLeadership(viewer)
+ const body = await readJsonBody(request)
+ const orderedVideoIds = Array.isArray(body['orderedVideoIds'])
+ ? body['orderedVideoIds'].filter(
+ (item): item is string => typeof item === 'string',
+ )
+ : []
+ if (orderedVideoIds.length > ABOUT_VIDEO_LIMIT) {
+ throw validation([
+ `Legfeljebb ${ABOUT_VIDEO_LIMIT} videó helyezhető a Rólunk oldalra.`,
+ ])
+ }
+ for (const id of orderedVideoIds) {
+ if (!UUID_PATTERN.test(id)) {
+ throw validation(['Érvénytelen videóazonosító a listában.'])
+ }
+ }
+ // Early Hungarian error message, also for invalid entries.
+ await assertSelectableVideos(await databaseOf(deps), orderedVideoIds)
+
+ await setAboutVideos(await databaseOf(deps), {
+ viewer,
+ orderedVideoIds,
+ clock: deps.clock,
+ })
+ return jsonResponse(200, { ok: true })
+ })
+}
diff --git a/src/server/api/admin/http.ts b/src/server/api/admin/http.ts
new file mode 100644
index 0000000..4f43941
--- /dev/null
+++ b/src/server/api/admin/http.ts
@@ -0,0 +1,183 @@
+import { forbiddenPage, getRequestOrigin } from '#/server/api/http.ts'
+import type { OobConfig } from '#/server/config/oob-schema.ts'
+import type { Database } from '#/server/auth/session-store.ts'
+import type { Viewer } from '#/server/auth/viewer.ts'
+import { resolveViewerStateFromRequest } from '#/server/pages/viewer.ts'
+import {
+ AuthRequiredError,
+ ForbiddenError,
+ requireAdmin,
+ requireLeadership,
+} from '#/server/auth/guards.ts'
+import { CatalogNameConflictError } from '#/server/catalog/tags.ts'
+import { StaffRoleInUseError } from '#/server/catalog/staff-roles.ts'
+import { EventConfirmationError } from '#/server/events/domain.ts'
+import { LiveOverlapError } from '#/server/homepage/live.ts'
+import { EntityNotFoundError, StaleWriteError } from '#/server/shared/write.ts'
+import { TextValidationError } from '#/server/shared/text.ts'
+
+/**
+ * Shared admin API foundations: every endpoint verifies permissions
+ * server-side on every request (spec 14), and translates domain errors into
+ * Hungarian JSON error messages.
+ */
+
+export function jsonResponse(status: number, payload: unknown): Response {
+ return new Response(JSON.stringify(payload), {
+ status,
+ headers: { 'content-type': 'application/json; charset=utf-8' },
+ })
+}
+
+/** Domain error → machine-readable code + Hungarian message. */
+export function errorResponse(error: unknown): Response {
+ if (error instanceof AuthRequiredError) {
+ return jsonResponse(401, {
+ error: 'auth_required',
+ loginUrl: error.loginUrl,
+ message: error.message,
+ })
+ }
+ if (error instanceof ForbiddenError) {
+ return jsonResponse(403, {
+ error: 'forbidden',
+ message: error.message,
+ })
+ }
+ if (error instanceof StaleWriteError) {
+ return jsonResponse(409, {
+ error: 'conflict',
+ message: error.message,
+ })
+ }
+ if (error instanceof CatalogNameConflictError) {
+ return jsonResponse(409, { error: 'name_conflict', message: error.message })
+ }
+ if (error instanceof LiveOverlapError) {
+ return jsonResponse(409, { error: 'overlap', message: error.message })
+ }
+ if (error instanceof StaffRoleInUseError) {
+ return jsonResponse(409, { error: 'role_in_use', message: error.message })
+ }
+ if (
+ error instanceof TextValidationError ||
+ error instanceof EventConfirmationError
+ ) {
+ const problems =
+ error instanceof TextValidationError ? error.problems : [error.message]
+ return jsonResponse(400, {
+ error:
+ error instanceof EventConfirmationError ? 'confirmation' : 'validation',
+ problems,
+ message: problems.join(' '),
+ })
+ }
+ if (error instanceof EntityNotFoundError) {
+ return jsonResponse(404, { error: 'not_found', message: error.message })
+ }
+ // TagNotFoundError / StaffRoleNotFoundError / ConfirmationMismatchError
+ if (error instanceof Error && 'name' in error) {
+ if (
+ error.name === 'TagNotFoundError' ||
+ error.name === 'StaffRoleNotFoundError'
+ ) {
+ return jsonResponse(404, { error: 'not_found', message: error.message })
+ }
+ if (error.name === 'ConfirmationMismatchError') {
+ return jsonResponse(400, {
+ error: 'confirmation',
+ message: error.message,
+ problems: [error.message],
+ })
+ }
+ }
+ return jsonResponse(500, {
+ error: 'internal',
+ message: 'Váratlan szerverhiba történt. Próbáld újra később.',
+ })
+}
+
+export async function readJsonBody(
+ request: Request,
+): Promise> {
+ try {
+ const body = await request.json()
+ if (body === null || typeof body !== 'object' || Array.isArray(body)) {
+ throw new Error('bad body')
+ }
+ return body as Record
+ } catch {
+ throw jsonResponse(400, {
+ error: 'bad_request',
+ message: 'Érvénytelen kéréstörzs.',
+ })
+ }
+}
+
+export function methodNotAllowed(): Response {
+ return jsonResponse(405, { error: 'method_not_allowed' })
+}
+
+/** Same-origin request check (CSRF-like protection, as in the view route). */
+export function assertSameOrigin(request: Request): void {
+ const origin = request.headers.get('origin')
+ if (
+ origin !== null &&
+ origin !== '' &&
+ origin !== getRequestOrigin(request)
+ ) {
+ throw forbiddenPage()
+ }
+}
+
+export interface HandlerDeps {
+ db?: Database
+ config?: OobConfig
+}
+
+export async function runAdminHandler(
+ request: Request,
+ deps: HandlerDeps,
+ handler: (viewer: Viewer) => Promise,
+ options: { allowGet?: boolean } = {},
+): Promise {
+ try {
+ assertSameOrigin(request)
+ const method = request.method.toUpperCase()
+ if (method !== 'POST' && !(options.allowGet === true && method === 'GET')) {
+ return methodNotAllowed()
+ }
+ const { viewer } = await resolveViewerStateFromRequest(request, {
+ db: deps.db,
+ config: deps.config,
+ })
+ return await handler(viewer)
+ } catch (error) {
+ if (error instanceof Response) {
+ return error
+ }
+ return errorResponse(error)
+ }
+}
+
+export async function requireAdminViewer(
+ request: Request,
+ deps: { config?: OobConfig } = {},
+): Promise {
+ const { viewer } = await resolveViewerStateFromRequest(request, {
+ config: deps.config,
+ })
+ requireAdmin(viewer, new URL(request.url).pathname)
+ return viewer
+}
+
+export async function requireLeadershipViewer(
+ request: Request,
+ deps: { config?: OobConfig } = {},
+): Promise {
+ const { viewer } = await resolveViewerStateFromRequest(request, {
+ config: deps.config,
+ })
+ requireLeadership(viewer)
+ return viewer
+}
diff --git a/src/server/api/admin/member-routes.ts b/src/server/api/admin/member-routes.ts
new file mode 100644
index 0000000..92c43fa
--- /dev/null
+++ b/src/server/api/admin/member-routes.ts
@@ -0,0 +1,43 @@
+import type { Database } from '#/server/auth/session-store.ts'
+import type { Clock } from '#/lib/clock.ts'
+import type { OobConfig } from '#/server/config/oob-schema.ts'
+import { requireLeadership } from '#/server/auth/guards.ts'
+import { jsonResponse, runAdminHandler } from './http.ts'
+import { triggerManualMemberSync } from '#/server/members/sync.ts'
+
+export interface AdminMemberRouteDeps {
+ db?: Database
+ clock?: Clock
+ config?: OobConfig
+ fetchImpl?: typeof fetch
+ loadConfig?: () => OobConfig
+}
+
+/**
+ * Manual member sync (BSS-032): can only be triggered by leadership; the
+ * sync runs on the BSS-008 service, is audited and records its status.
+ */
+export async function handleAdminMemberSyncRoute(
+ request: Request,
+ deps: AdminMemberRouteDeps = {},
+): Promise {
+ return runAdminHandler(request, deps, async (viewer) => {
+ requireLeadership(viewer)
+ const result = await triggerManualMemberSync(viewer, syncDeps(deps))
+ return jsonResponse(200, {
+ ok: result.status === 'ok',
+ result,
+ })
+ })
+}
+
+function syncDeps(
+ deps: AdminMemberRouteDeps,
+): Parameters[1] {
+ return {
+ db: deps.db,
+ clock: deps.clock,
+ fetchImpl: deps.fetchImpl,
+ ...(deps.loadConfig !== undefined ? { loadConfig: deps.loadConfig } : {}),
+ }
+}
diff --git a/src/server/api/admin/video-routes.ts b/src/server/api/admin/video-routes.ts
new file mode 100644
index 0000000..196c08c
--- /dev/null
+++ b/src/server/api/admin/video-routes.ts
@@ -0,0 +1,284 @@
+import type { OobConfig } from '#/server/config/oob-schema.ts'
+import { getCachedOobConfig } from '#/server/config/load.ts'
+import type { Database } from '#/server/auth/session-store.ts'
+import { getDefaultDb } from '#/server/auth/session-store.ts'
+import { can } from '#/server/auth/policy.ts'
+import { ForbiddenError, requireAdmin } from '#/server/auth/guards.ts'
+import type { Clock } from '#/lib/clock.ts'
+import { systemClock } from '#/lib/clock.ts'
+import {
+ archiveVideo,
+ createVideoDraft,
+ publishVideo,
+ restoreVideoFromTrash,
+ setVideoStaff,
+ setVideoTags,
+ trashVideo,
+ updateVideo,
+} from '#/server/videos/domain.ts'
+import { setManualRelatedVideos } from '#/server/videos/related.ts'
+import { jsonResponse, readJsonBody, runAdminHandler } from './http.ts'
+
+const UUID_PATTERN =
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
+
+export interface AdminVideoRouteDeps {
+ db?: Database
+ config?: OobConfig
+ clock?: Clock
+ fetchImpl?: typeof fetch
+}
+
+function badRequest(message: string): Response {
+ return jsonResponse(400, { error: 'bad_request', message })
+}
+
+function parseVersion(value: unknown): number {
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < 1) {
+ throw badRequest('A verziószám (version) kötelező pozitív egész szám.')
+ }
+ return value
+}
+
+function parseStringArray(value: unknown): string[] {
+ if (!Array.isArray(value)) {
+ throw badRequest('A mező sztringlista kell legyen.')
+ }
+ return value.filter((item): item is string => typeof item === 'string')
+}
+
+/**
+ * Video admin operations (BSS-028). Every endpoint requires at least
+ * membership; restoring is a leadership privilege (spec 3.2) — the domain
+ * layer re-verifies that as well.
+ */
+export async function handleAdminVideoRoutes(
+ request: Request,
+ action: string,
+ id: string | undefined,
+ deps: AdminVideoRouteDeps = {},
+): Promise {
+ return runAdminHandler(request, deps, async (viewer) => {
+ // Server-side permission check on every request (spec 14):
+ // anonymous → 401 with login URL, authenticated unauthorized → 403.
+ requireAdmin(viewer, new URL(request.url).pathname)
+ const db = deps.db ?? (await getDefaultDb())
+ const mediaConfig = mediaConfigOf(deps)
+ const domainDeps = {
+ viewer,
+ clock: deps.clock ?? systemClock,
+ mediaConfig,
+ fetchImpl: deps.fetchImpl,
+ }
+
+ if (action === 'create' && id === undefined) {
+ const body = await readJsonBody(request)
+ const row = await createVideoDraft(db, domainDeps, {
+ title: typeof body['title'] === 'string' ? body['title'] : '',
+ })
+ return jsonResponse(200, { ok: true, id: row.id, slug: row.slug })
+ }
+
+ if (id === undefined || !UUID_PATTERN.test(id)) {
+ return badRequest('Érvénytelen videóazonosító.')
+ }
+ const body = await readJsonBody(request)
+
+ switch (action) {
+ case 'update': {
+ const result = await updateVideo(
+ db,
+ domainDeps,
+ id,
+ parseVersion(body['version']),
+ {
+ title: optionalString(body['title']),
+ description: optionalNullableString(body['description']),
+ guests: optionalNullableString(body['guests']),
+ songs: optionalNullableString(body['songs']),
+ videoUrl: optionalNullableString(body['videoUrl']),
+ thumbnailUrl: optionalNullableString(body['thumbnailUrl']),
+ visibility: optionalVisibility(body['visibility']),
+ recordedAt: optionalNullableString(body['recordedAt']),
+ eventId: optionalNullableId(body['eventId'], 'eventId'),
+ slug: optionalString(body['slug']),
+ publishedAt: optionalPublishedAt(body['publishedAt']),
+ },
+ )
+ return jsonResponse(200, {
+ ok: true,
+ version: result.row.version,
+ slug: result.row.slug,
+ warnings: result.warnings,
+ })
+ }
+ case 'publish': {
+ const result = await publishVideo(
+ db,
+ domainDeps,
+ id,
+ parseVersion(body['version']),
+ )
+ return jsonResponse(200, {
+ ok: true,
+ version: result.row.version,
+ slug: result.row.slug,
+ warnings: result.warnings,
+ })
+ }
+ case 'archive': {
+ const result = await archiveVideo(
+ db,
+ domainDeps,
+ id,
+ parseVersion(body['version']),
+ )
+ return jsonResponse(200, {
+ ok: true,
+ version: result.row.version,
+ slug: result.row.slug,
+ })
+ }
+ case 'trash': {
+ const result = await trashVideo(
+ db,
+ domainDeps,
+ id,
+ parseVersion(body['version']),
+ )
+ return jsonResponse(200, {
+ ok: true,
+ version: result.row.version,
+ slug: result.row.slug,
+ })
+ }
+ case 'restore': {
+ if (!can.restoreVideo(viewer)) {
+ throw new ForbiddenError(
+ 'A lomtárból való visszaállítás vezetőségi jog.',
+ )
+ }
+ const result = await restoreVideoFromTrash(
+ db,
+ domainDeps,
+ id,
+ parseVersion(body['version']),
+ )
+ return jsonResponse(200, {
+ ok: true,
+ version: result.row.version,
+ slug: result.row.slug,
+ })
+ }
+ case 'tags': {
+ const result = await setVideoTags(
+ db,
+ domainDeps,
+ id,
+ parseVersion(body['version']),
+ parseStringArray(body['tagIds']),
+ )
+ return jsonResponse(200, { ok: true, version: result.row.version })
+ }
+ case 'staff': {
+ const rawAssignments = body['assignments']
+ if (!Array.isArray(rawAssignments)) {
+ return badRequest('A stáblista (assignments) lista kell legyen.')
+ }
+ const assignments = []
+ for (const entry of rawAssignments) {
+ if (
+ entry === null ||
+ typeof entry !== 'object' ||
+ typeof (entry as Record)['roleId'] !== 'string' ||
+ typeof (entry as Record)['memberSub'] !== 'string'
+ ) {
+ return badRequest(
+ 'Minden stábbetetésnek roleId és memberSub mezője kell legyen.',
+ )
+ }
+ assignments.push({
+ roleId: (entry as Record)['roleId'],
+ memberSub: (entry as Record)['memberSub'],
+ })
+ }
+ const result = await setVideoStaff(
+ db,
+ domainDeps,
+ id,
+ parseVersion(body['version']),
+ assignments,
+ )
+ return jsonResponse(200, { ok: true, version: result.row.version })
+ }
+ case 'related': {
+ const result = await setManualRelatedVideos(db, {
+ viewer,
+ videoId: id,
+ expectedVersion: parseVersion(body['version']),
+ relatedVideoIds: parseStringArray(body['relatedVideoIds']),
+ clock: deps.clock,
+ })
+ return jsonResponse(200, { ok: true, version: result.version })
+ }
+ default:
+ return jsonResponse(404, { error: 'not_found' })
+ }
+ })
+}
+
+function optionalString(value: unknown): string | undefined {
+ return typeof value === 'string' ? value : undefined
+}
+
+function optionalNullableString(value: unknown): string | null | undefined {
+ if (value === undefined) return undefined
+ if (value === null) return null
+ return typeof value === 'string' ? value : undefined
+}
+
+function optionalVisibility(
+ value: unknown,
+): 'public' | 'schonherz' | 'bss' | undefined {
+ if (value === 'public' || value === 'schonherz' || value === 'bss') {
+ return value
+ }
+ return undefined
+}
+
+function optionalNullableId(
+ value: unknown,
+ field: string,
+): string | null | undefined {
+ if (value === undefined) return undefined
+ if (value === null) return null
+ if (typeof value !== 'string' || !UUID_PATTERN.test(value)) {
+ throw badRequest(`Érvénytelen azonosító a ${field} mezőben.`)
+ }
+ return value
+}
+
+/** A past `publishedAt` can be given; a future one is rejected by the domain. */
+function optionalPublishedAt(value: unknown): Date | null | undefined {
+ if (value === undefined) return undefined
+ if (value === null) return null
+ if (typeof value !== 'string' || value.trim() === '') return null
+ const date = new Date(value)
+ if (Number.isNaN(date.getTime())) {
+ throw badRequest('A feltöltés időpontja érvénytelen dátum.')
+ }
+ return date
+}
+
+export function mediaConfigOf(deps: AdminVideoRouteDeps): OobConfig['media'] {
+ if (deps.config !== undefined) {
+ return deps.config.media
+ }
+ // In production the cached OOB config is used; if missing, an empty allowlist
+ // is set and the validator reports with its own Hungarian error message.
+ try {
+ return getCachedOobConfig().media
+ } catch {
+ return { allowedHosts: [] }
+ }
+}
diff --git a/src/server/api/auth-routes.ts b/src/server/api/auth-routes.ts
new file mode 100644
index 0000000..a0213b0
--- /dev/null
+++ b/src/server/api/auth-routes.ts
@@ -0,0 +1,327 @@
+import {
+ authFailurePage,
+ authUnavailablePage,
+ badRequestPage,
+ forbiddenPage,
+ getRequestOrigin,
+ internalErrorPage,
+ isSecureRequest,
+ redirectResponse,
+ sanitizeReturnTo,
+ unauthorizedConfigPage,
+} from '#/server/api/http.ts'
+import {
+ buildAuthorizationUrl,
+ buildLoginTransaction,
+ decodeJwtPayload,
+ exchangeCodeForTokens,
+ extractIdentityFromClaims,
+ fetchDiscovery,
+ OidcProtocolError,
+ OidcUnavailableError,
+ safeEquals,
+ validateIdTokenClaims,
+} from '#/server/auth/oidc.ts'
+import type { LoginTransactionData, OidcDiscovery } from '#/server/auth/oidc.ts'
+import {
+ createAuthSession,
+ deleteAuthSession,
+ findActiveAuthSession,
+} from '#/server/auth/session-store.ts'
+import type { Database } from '#/server/auth/session-store.ts'
+import {
+ OIDC_TXN_COOKIE_NAME,
+ OIDC_TXN_TTL_SECONDS,
+ readCookieValue,
+ SESSION_COOKIE_NAME,
+ SESSION_TTL_MS,
+ signOidcTxn,
+ verifyAndReadOidcTxn,
+} from '#/server/auth/session-cookies.ts'
+import type { CookieSpec } from '#/server/auth/session-cookies.ts'
+import type { Clock } from '#/lib/clock.ts'
+import { getCachedOobConfig } from '#/server/config/load.ts'
+import type { OobConfig } from '#/server/config/oob-schema.ts'
+import { viewerFromSession } from '#/server/auth/viewer.ts'
+
+const TXN_MAX_AGE_MS = OIDC_TXN_TTL_SECONDS * 1000
+
+export const CALLBACK_PATH = '/api/auth/callback'
+
+export interface AuthRouteDeps {
+ loadConfig?: () => OobConfig
+ db?: Database
+ clock?: Clock
+}
+
+export interface AuthRouteHandlers {
+ login: (request: Request) => Promise
+ callback: (request: Request) => Promise
+ logout: (request: Request) => Promise
+ me: (request: Request) => Promise
+}
+
+function logAuthFailure(context: string, error: unknown): void {
+ console.error(`[auth] ${context}`, error)
+}
+
+function parseTxn(json: string): LoginTransactionData | null {
+ try {
+ const raw: unknown = JSON.parse(json)
+ if (typeof raw !== 'object' || raw === null) {
+ return null
+ }
+ const record = raw as Record
+ if (
+ typeof record['state'] !== 'string' ||
+ typeof record['codeVerifier'] !== 'string' ||
+ typeof record['nonce'] !== 'string' ||
+ typeof record['returnTo'] !== 'string' ||
+ typeof record['createdAtIso'] !== 'string'
+ ) {
+ return null
+ }
+ return {
+ state: record['state'],
+ codeVerifier: record['codeVerifier'],
+ nonce: record['nonce'],
+ returnTo: sanitizeReturnTo(record['returnTo']),
+ createdAtIso: record['createdAtIso'],
+ }
+ } catch {
+ return null
+ }
+}
+
+function txnIsFresh(txn: LoginTransactionData, nowMs: number): boolean {
+ const createdAt = Date.parse(txn.createdAtIso)
+ if (Number.isNaN(createdAt)) {
+ return false
+ }
+ return nowMs - createdAt <= TXN_MAX_AGE_MS
+}
+
+function loadDepsConfig(
+ deps: AuthRouteDeps,
+): { config: OobConfig } | { errorResponse: Response } {
+ try {
+ return { config: (deps.loadConfig ?? getCachedOobConfigDefault)() }
+ } catch (error) {
+ logAuthFailure('OOB konfiguráció betöltése sikertelen', error)
+ return { errorResponse: unauthorizedConfigPage() }
+ }
+}
+
+function getCachedOobConfigDefault(): OobConfig {
+ return getCachedOobConfig()
+}
+
+export function createAuthRouteHandlers(
+ deps: AuthRouteDeps = {},
+): AuthRouteHandlers {
+ async function login(request: Request): Promise {
+ const loaded = loadDepsConfig(deps)
+ if ('errorResponse' in loaded) {
+ return loaded.errorResponse
+ }
+ const config = loaded.config
+
+ let discovery: OidcDiscovery
+ try {
+ discovery = await fetchDiscovery(config.authentik)
+ } catch (error) {
+ logAuthFailure('Az Authentik discovery nem elérhető', error)
+ return authUnavailablePage()
+ }
+
+ const url = new URL(request.url)
+ const returnTo = sanitizeReturnTo(url.searchParams.get('returnTo'))
+ const transaction = buildLoginTransaction(returnTo)
+ const redirectUri = `${getRequestOrigin(request)}${CALLBACK_PATH}`
+ const authorizeUrl = buildAuthorizationUrl(
+ discovery,
+ config.authentik,
+ redirectUri,
+ transaction,
+ )
+
+ const txnCookie: CookieSpec = {
+ name: OIDC_TXN_COOKIE_NAME,
+ value: signOidcTxn(JSON.stringify(transaction), config.authentik),
+ maxAgeSeconds: OIDC_TXN_TTL_SECONDS,
+ secure: isSecureRequest(request),
+ }
+
+ return redirectResponse(authorizeUrl, [txnCookie])
+ }
+
+ async function callback(request: Request): Promise {
+ const loaded = loadDepsConfig(deps)
+ if ('errorResponse' in loaded) {
+ return loaded.errorResponse
+ }
+ const config = loaded.config
+
+ const url = new URL(request.url)
+ const cookieValue = readCookieValue(request, OIDC_TXN_COOKIE_NAME)
+ const txnJson =
+ cookieValue === null
+ ? null
+ : verifyAndReadOidcTxn(cookieValue, config.authentik)
+
+ if (txnJson === null) {
+ return badRequestPage(
+ 'A bejelentkezési folyamat lejárt vagy érvénytelen. Indítsd el újra a belépést.',
+ )
+ }
+ const transaction = parseTxn(txnJson)
+ if (
+ transaction === null ||
+ !txnIsFresh(transaction, Date.now()) ||
+ !safeEquals(url.searchParams.get('state') ?? '', transaction.state)
+ ) {
+ return badRequestPage(
+ 'A bejelentkezési folyamat állapota nem egyezik. Indítsd el újra a belépést.',
+ )
+ }
+
+ if (url.searchParams.get('error') !== null) {
+ return badRequestPage(
+ 'A bejelentkezés nem fejeződött be a bejelentkezési szolgáltatásnál. Próbáld újra.',
+ )
+ }
+ const code = url.searchParams.get('code')
+ if (code === null || code === '') {
+ return badRequestPage(
+ 'Hiányzik a bejelentkezési kód. Próbáld újra a belépést.',
+ )
+ }
+
+ try {
+ const discovery = await fetchDiscovery(config.authentik)
+ const redirectUri = `${getRequestOrigin(request)}${CALLBACK_PATH}`
+ const tokens = await exchangeCodeForTokens(
+ discovery,
+ config.authentik,
+ redirectUri,
+ code,
+ transaction.codeVerifier,
+ )
+ const claims = decodeJwtPayload(tokens.idToken)
+ validateIdTokenClaims(claims, {
+ issuer: discovery.issuer,
+ clientId: config.authentik.clientId,
+ nonce: transaction.nonce,
+ clock: deps.clock,
+ })
+ const identity = extractIdentityFromClaims(claims, config.authentik)
+
+ const created = await createAuthSession(
+ {
+ memberSub: identity.sub,
+ username: identity.username,
+ groups: identity.groups,
+ accessToken: tokens.accessToken,
+ },
+ { db: deps.db, clock: deps.clock },
+ )
+
+ const secure = isSecureRequest(request)
+ return redirectResponse(transaction.returnTo, [
+ {
+ name: SESSION_COOKIE_NAME,
+ value: created.token,
+ maxAgeSeconds: Math.floor(SESSION_TTL_MS / 1000),
+ secure,
+ },
+ {
+ name: OIDC_TXN_COOKIE_NAME,
+ value: '',
+ maxAgeSeconds: 0,
+ secure,
+ },
+ ])
+ } catch (error) {
+ if (error instanceof OidcUnavailableError) {
+ logAuthFailure(
+ 'Az Authentik nem elérhető a bejelentkezés közben',
+ error,
+ )
+ return authUnavailablePage()
+ }
+ if (error instanceof OidcProtocolError) {
+ logAuthFailure('Protokolli hiba a bejelentkezés közben', error)
+ return authFailurePage(
+ 'A bejelentkezés során hiba történt a bejelentkezési szolgáltatással. Próbáld újra később.',
+ )
+ }
+ logAuthFailure('Váratlan hiba a callback kezelésekor', error)
+ return internalErrorPage(
+ 'Váratlan hiba történt a bejelentkezés során. Próbáld újra.',
+ )
+ }
+ }
+
+ async function logout(request: Request): Promise {
+ if (request.method.toUpperCase() !== 'POST') {
+ return new Response(JSON.stringify({ error: 'method_not_allowed' }), {
+ status: 405,
+ headers: { 'content-type': 'application/json', allow: 'POST' },
+ })
+ }
+ const origin = request.headers.get('origin')
+ if (
+ origin !== null &&
+ origin !== '' &&
+ origin !== getRequestOrigin(request)
+ ) {
+ return forbiddenPage()
+ }
+
+ const token = readCookieValue(request, SESSION_COOKIE_NAME)
+ const secure = isSecureRequest(request)
+ if (token !== null && token !== '') {
+ try {
+ await deleteAuthSession(token, { db: deps.db })
+ } catch (error) {
+ logAuthFailure('A session törlése az adatbázisból nem sikerült', error)
+ }
+ }
+
+ return redirectResponse('/', [
+ { name: SESSION_COOKIE_NAME, value: '', maxAgeSeconds: 0, secure },
+ { name: OIDC_TXN_COOKIE_NAME, value: '', maxAgeSeconds: 0, secure },
+ ])
+ }
+
+ /**
+ * Query the login state for the client. Reads only the local DB and never
+ * calls Authentik (a public request must not depend on an external service).
+ */
+ async function me(request: Request): Promise {
+ const token = readCookieValue(request, SESSION_COOKIE_NAME)
+ const config = loadDepsConfig(deps)
+ if ('errorResponse' in config) {
+ return config.errorResponse
+ }
+ let session = null
+ if (token !== null && token !== '') {
+ try {
+ session = await findActiveAuthSession(token, {
+ db: deps.db,
+ clock: deps.clock,
+ })
+ } catch (error) {
+ logAuthFailure('A session lekérdezése nem sikerült', error)
+ session = null
+ }
+ }
+ const viewer = viewerFromSession(session, config.config.authentik)
+ return new Response(JSON.stringify(viewer), {
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ })
+ }
+
+ return { login, callback, logout, me }
+}
diff --git a/src/server/api/http.ts b/src/server/api/http.ts
new file mode 100644
index 0000000..97e710e
--- /dev/null
+++ b/src/server/api/http.ts
@@ -0,0 +1,106 @@
+import type { CookieSpec } from '#/server/auth/session-cookies.ts'
+import { serializeSetCookie } from '#/server/auth/session-cookies.ts'
+
+export function escapeHtml(value: string): string {
+ return value
+ .replaceAll('&', '&')
+ .replaceAll('<', '<')
+ .replaceAll('>', '>')
+ .replaceAll('"', '"')
+ .replaceAll("'", ''')
+}
+
+function errorPage(status: number, title: string, message: string): Response {
+ const html =
+ `\n\n ` +
+ `${escapeHtml(title)} \n` +
+ `${escapeHtml(title)} ${escapeHtml(message)}
` +
+ `Vissza a főoldalra
\n`
+ return new Response(html, {
+ status,
+ headers: { 'content-type': 'text/html; charset=utf-8' },
+ })
+}
+
+export function badRequestPage(message: string): Response {
+ return errorPage(400, 'Hibás kérés', message)
+}
+
+export function unauthorizedConfigPage(): Response {
+ return errorPage(
+ 500,
+ 'Szerverkonfigurációs hiba',
+ 'A BSS OOB konfiguráció nem elérhető vagy érvénytelen. Indítsd el a `pnpm infra:bootstrap` lépést, majd ellenőrizd a `pnpm check:oob` paranccsal.',
+ )
+}
+
+export function authUnavailablePage(): Response {
+ return errorPage(
+ 503,
+ 'Bejelentkezés nem elérhető',
+ 'A bejelentkezési szolgáltatás (Authentik) most nem érhető el. A publikus oldalak működnek; próbáld újra később.',
+ )
+}
+
+export function authFailurePage(message: string): Response {
+ return errorPage(502, 'Bejelentkezési hiba', message)
+}
+
+export function internalErrorPage(message: string): Response {
+ return errorPage(500, 'Szerverhiba', message)
+}
+
+export function forbiddenPage(): Response {
+ return errorPage(
+ 403,
+ 'Hozzáférés megtagadva',
+ 'Ehhez a művelethez nincs jogosultságod.',
+ )
+}
+
+export function apiNotFoundResponse(): Response {
+ return new Response(JSON.stringify({ error: 'not_found' }), {
+ status: 404,
+ headers: { 'content-type': 'application/json' },
+ })
+}
+
+export function redirectResponse(
+ location: string,
+ cookies: CookieSpec[] = [],
+): Response {
+ const headers = new Headers({ location: location })
+ for (const cookie of cookies) {
+ headers.append('set-cookie', serializeSetCookie(cookie))
+ }
+ return new Response(null, { status: 302, headers })
+}
+
+/** Only a relative, single-level path starting with "/" is allowed (against open redirects). */
+export function sanitizeReturnTo(raw: string | null): string {
+ if (
+ !raw ||
+ !raw.startsWith('/') ||
+ raw.startsWith('//') ||
+ raw.includes('\\')
+ ) {
+ return '/'
+ }
+ if (raw.length > 2048) {
+ return '/'
+ }
+ return raw
+}
+
+export function getRequestOrigin(request: Request): string {
+ const url = new URL(request.url)
+ const forwardedHost = request.headers.get('x-forwarded-host')
+ const forwardedProto = request.headers.get('x-forwarded-proto')
+ const host = forwardedHost ?? request.headers.get('host') ?? url.host
+ const proto = forwardedProto ?? url.protocol.replace(/:$/, '')
+ return `${proto}://${host}`
+}
+
+export function isSecureRequest(request: Request): boolean {
+ return getRequestOrigin(request).startsWith('https://')
+}
diff --git a/src/server/api/router.ts b/src/server/api/router.ts
new file mode 100644
index 0000000..4f9f677
--- /dev/null
+++ b/src/server/api/router.ts
@@ -0,0 +1,127 @@
+import { createAuthRouteHandlers } from '#/server/api/auth-routes.ts'
+import { apiNotFoundResponse } from '#/server/api/http.ts'
+import { livenessResponse, readinessResponse } from '#/server/jobs/health.ts'
+import { handleVideoView } from '#/server/api/view-routes.ts'
+import { handleSearch } from '#/server/api/search-routes.ts'
+import { handleAdminVideoRoutes } from '#/server/api/admin/video-routes.ts'
+import { handleAdminEventRoutes } from '#/server/api/admin/event-routes.ts'
+import {
+ handleAdminStaffRoleRoutes,
+ handleAdminTagRoutes,
+} from '#/server/api/admin/catalog-routes.ts'
+import {
+ handleAdminAboutRoute,
+ handleAdminHighlightRoute,
+ handleAdminLiveRoutes,
+} from '#/server/api/admin/homepage-routes.ts'
+import { handleAdminMemberSyncRoute } from '#/server/api/admin/member-routes.ts'
+
+export const API_PATH_PREFIXES = ['/api/', '/health/']
+
+const authHandlers = createAuthRouteHandlers()
+
+const VIDEO_VIEW_PATTERN = /^\/api\/videos\/([0-9a-f-]+)\/view$/
+
+const ADMIN_VIDEO_ACTION_PATTERN =
+ /^\/api\/admin\/videos\/([0-9a-f-]+)\/(update|publish|archive|trash|restore|tags|staff|related)$/
+
+const ADMIN_EVENT_ACTION_PATTERN =
+ /^\/api\/admin\/events\/([0-9a-f-]+)\/(update|publish|archive|delete_permanent)$/
+
+const LIVE_ACTION_PATTERN =
+ /^\/api\/admin\/live\/([0-9a-f-]+)\/(reschedule|start_now|end_now|delete)$/
+
+export async function handleApiRequest(request: Request): Promise {
+ const pathname = new URL(request.url).pathname.replace(/\/+$/, '') || '/'
+
+ const viewMatch = VIDEO_VIEW_PATTERN.exec(pathname)
+ if (viewMatch !== null) {
+ return handleVideoView(request, viewMatch[1])
+ }
+
+ if (pathname === '/api/admin/videos') {
+ return handleAdminVideoRoutes(request, 'create', undefined)
+ }
+ const adminVideoMatch = ADMIN_VIDEO_ACTION_PATTERN.exec(pathname)
+ if (adminVideoMatch !== null) {
+ return handleAdminVideoRoutes(
+ request,
+ adminVideoMatch[2],
+ adminVideoMatch[1],
+ )
+ }
+
+ if (pathname === '/api/admin/events') {
+ return handleAdminEventRoutes(request, 'create', undefined)
+ }
+ const adminEventMatch = ADMIN_EVENT_ACTION_PATTERN.exec(pathname)
+ if (adminEventMatch !== null) {
+ return handleAdminEventRoutes(
+ request,
+ adminEventMatch[2],
+ adminEventMatch[1],
+ )
+ }
+
+ if (pathname === '/api/admin/tags/similar') {
+ return handleAdminTagRoutes(request, 'similar', undefined)
+ }
+ const adminTagMatch =
+ /^\/api\/admin\/tags(?:\/([0-9a-f-]+)\/(rename|merge|delete))?$/.exec(
+ pathname,
+ )
+ if (adminTagMatch !== null) {
+ return handleAdminTagRoutes(request, adminTagMatch[2], adminTagMatch[1])
+ }
+
+ if (pathname === '/api/admin/staff-roles') {
+ return handleAdminStaffRoleRoutes(request, 'create', undefined)
+ }
+ const staffRoleMatch =
+ /^\/api\/admin\/staff-roles\/([0-9a-f-]+)\/(rename|merge|delete|reorder)$/.exec(
+ pathname,
+ )
+ if (staffRoleMatch !== null) {
+ return handleAdminStaffRoleRoutes(
+ request,
+ staffRoleMatch[2],
+ staffRoleMatch[1],
+ )
+ }
+
+ if (pathname === '/api/admin/highlight') {
+ return handleAdminHighlightRoute(request)
+ }
+ if (pathname === '/api/admin/about') {
+ return handleAdminAboutRoute(request)
+ }
+ if (pathname === '/api/admin/live') {
+ return handleAdminLiveRoutes(request, undefined, undefined)
+ }
+ if (pathname === '/api/admin/members/sync') {
+ return handleAdminMemberSyncRoute(request)
+ }
+ const liveMatch = LIVE_ACTION_PATTERN.exec(pathname)
+ if (liveMatch !== null) {
+ return handleAdminLiveRoutes(request, liveMatch[1], liveMatch[2])
+ }
+
+ switch (pathname) {
+ case '/api/auth/login':
+ return authHandlers.login(request)
+ case '/api/auth/callback':
+ return authHandlers.callback(request)
+ case '/api/auth/logout':
+ return authHandlers.logout(request)
+ case '/api/auth/me':
+ return authHandlers.me(request)
+ case '/api/search':
+ return handleSearch(request)
+ case '/health/live':
+ return livenessResponse()
+ case '/health/ready':
+ return readinessResponse()
+ default:
+ return apiNotFoundResponse()
+ }
+}
diff --git a/src/server/api/search-routes.ts b/src/server/api/search-routes.ts
new file mode 100644
index 0000000..dd8f4c5
--- /dev/null
+++ b/src/server/api/search-routes.ts
@@ -0,0 +1,85 @@
+import { getRequestOrigin } from '#/server/api/http.ts'
+import { resolveViewerStateFromRequest } from '#/server/pages/viewer.ts'
+import { MIN_QUERY_LENGTH, search } from '#/server/search/service.ts'
+import type { Database } from '#/server/auth/session-store.ts'
+import type { OobConfig } from '#/server/config/oob-schema.ts'
+import { getDefaultDb } from '#/server/auth/session-store.ts'
+
+export interface SearchRouteDeps {
+ db?: Database
+ config?: OobConfig
+}
+
+/**
+ * Global search API (spec 11): at most `limit` results per group;
+ * permission filtering happens in SQL, metadata of forbidden videos never
+ * appears in the response. Returns an empty result for empty/short queries.
+ */
+export async function handleSearch(
+ request: Request,
+ deps: SearchRouteDeps = {},
+): Promise {
+ if (request.method.toUpperCase() !== 'GET') {
+ return new Response(JSON.stringify({ error: 'method_not_allowed' }), {
+ status: 405,
+ headers: { 'content-type': 'application/json', allow: 'GET' },
+ })
+ }
+ const origin = request.headers.get('origin')
+ if (
+ origin !== null &&
+ origin !== '' &&
+ origin !== getRequestOrigin(request)
+ ) {
+ return new Response(JSON.stringify({ error: 'forbidden' }), {
+ status: 403,
+ headers: { 'content-type': 'application/json' },
+ })
+ }
+
+ const url = new URL(request.url)
+ const query = url.searchParams.get('q') ?? ''
+ if (query.trim().length < MIN_QUERY_LENGTH) {
+ return new Response(
+ JSON.stringify({ query, videos: [], events: [], members: [], tags: [] }),
+ { status: 200, headers: { 'content-type': 'application/json' } },
+ )
+ }
+
+ const limitParam = Number(url.searchParams.get('limit'))
+ const limit =
+ Number.isInteger(limitParam) && limitParam > 0
+ ? Math.min(limitParam, 10)
+ : 5
+
+ const { viewer } = await resolveViewerStateFromRequest(request, {
+ db: deps.db,
+ config: deps.config,
+ })
+ const db = deps.db ?? (await getDefaultDb())
+ const results = await search(db, viewer, query, { limitPerType: limit })
+
+ return new Response(
+ JSON.stringify({
+ query,
+ videos: results.videos.map(({ item }) => ({
+ slug: item.slug,
+ title: item.title,
+ thumbnailUrl: item.thumbnailUrl,
+ })),
+ events: results.events.map(({ item }) => ({
+ slug: item.slug,
+ title: item.title,
+ startDate: item.startDate,
+ })),
+ members: results.members.map(({ item }) => ({
+ username: item.username,
+ fullName: item.fullName,
+ nickname: item.nickname,
+ avatarUrl: item.avatarUrl,
+ })),
+ tags: results.tags.map(({ item }) => ({ name: item.name })),
+ }),
+ { status: 200, headers: { 'content-type': 'application/json' } },
+ )
+}
diff --git a/src/server/api/view-routes.ts b/src/server/api/view-routes.ts
new file mode 100644
index 0000000..559dd86
--- /dev/null
+++ b/src/server/api/view-routes.ts
@@ -0,0 +1,82 @@
+import { forbiddenPage, getRequestOrigin } from '#/server/api/http.ts'
+import {
+ readCookieValue,
+ serializeSetCookie,
+} from '#/server/auth/session-cookies.ts'
+import type { CookieSpec } from '#/server/auth/session-cookies.ts'
+import { resolveViewerStateFromRequest } from '#/server/pages/viewer.ts'
+import {
+ VIEW_SESSION_COOKIE_NAME,
+ newViewSessionToken,
+ recordVideoView,
+ viewSessionCookieSpec,
+} from '#/server/views/counter.ts'
+import { getDefaultDb } from '#/server/auth/session-store.ts'
+
+import type { Database } from '#/server/auth/session-store.ts'
+
+const UUID_PATTERN =
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
+
+export interface VideoViewDeps {
+ db?: Database
+}
+
+/**
+ * View counter endpoint (spec 5.5): the client calls it on the first successful
+ * `play` event. Counts once per video per browser session.
+ */
+export async function handleVideoView(
+ request: Request,
+ videoId: string,
+ deps: VideoViewDeps = {},
+): Promise {
+ if (request.method.toUpperCase() !== 'POST') {
+ return new Response(JSON.stringify({ error: 'method_not_allowed' }), {
+ status: 405,
+ headers: { 'content-type': 'application/json', allow: 'POST' },
+ })
+ }
+ const origin = request.headers.get('origin')
+ if (
+ origin !== null &&
+ origin !== '' &&
+ origin !== getRequestOrigin(request)
+ ) {
+ return forbiddenPage()
+ }
+ if (!UUID_PATTERN.test(videoId)) {
+ return new Response(JSON.stringify({ error: 'bad_request' }), {
+ status: 400,
+ headers: { 'content-type': 'application/json' },
+ })
+ }
+
+ const { viewer } = await resolveViewerStateFromRequest(request)
+
+ const existingToken = readCookieValue(request, VIEW_SESSION_COOKIE_NAME)
+ const isNewSession = existingToken === null || existingToken === ''
+ const token = isNewSession ? newViewSessionToken() : existingToken
+
+ try {
+ const db = deps.db ?? (await getDefaultDb())
+ await recordVideoView(db, { videoId, viewer, token })
+ } catch {
+ // Unknown, non-published or non-visible video: no information leak.
+ return new Response(JSON.stringify({ error: 'not_found' }), {
+ status: 404,
+ headers: { 'content-type': 'application/json' },
+ })
+ }
+
+ const headers = new Headers({ 'content-type': 'application/json' })
+ if (isNewSession) {
+ const secure = getRequestOrigin(request).startsWith('https://')
+ const cookie: CookieSpec = viewSessionCookieSpec(token, secure)
+ headers.append('set-cookie', serializeSetCookie(cookie))
+ }
+ return new Response(JSON.stringify({ counted: true }), {
+ status: 200,
+ headers,
+ })
+}
diff --git a/src/server/auth/guards.ts b/src/server/auth/guards.ts
new file mode 100644
index 0000000..471b018
--- /dev/null
+++ b/src/server/auth/guards.ts
@@ -0,0 +1,52 @@
+import { anonymousViewer } from '#/server/auth/viewer.ts'
+import type { Viewer } from '#/server/auth/viewer.ts'
+import { isAdminAreaAllowed, isLeadership } from '#/server/auth/policy.ts'
+
+/** Missing or expired session: the client must request a new login. */
+export class AuthRequiredError extends Error {
+ constructor(readonly loginUrl: string) {
+ super('A bejelentkezés lejárt vagy nem történt meg.')
+ this.name = 'AuthRequiredError'
+ }
+}
+
+/** Logged in, but lacks the required permission: 403. */
+export class ForbiddenError extends Error {
+ constructor(message = 'Ehhez a művelethez nincs jogosultságod.') {
+ super(message)
+ this.name = 'ForbiddenError'
+ }
+}
+
+/**
+ * Server-side guard for admin operations and pages.
+ * Anonymous users are redirected to login with the preserved returnTo
+ * (AuthRequiredError.loginUrl); logged-in but unauthorized users get a 403.
+ */
+export function requireAdmin(viewer: Viewer, returnTo: string): void {
+ if (viewer.level === 'anonymous') {
+ throw new AuthRequiredError(loginUrlFor(returnTo))
+ }
+ if (!isAdminAreaAllowed(viewer)) {
+ throw new ForbiddenError()
+ }
+}
+
+export function requireLeadership(viewer: Viewer): void {
+ if (viewer.level === 'anonymous') {
+ // Leadership area while anonymous: redirect to the generic login page.
+ throw new AuthRequiredError(loginUrlFor('/'))
+ }
+ if (!isLeadership(viewer)) {
+ throw new ForbiddenError()
+ }
+}
+
+/** Reading public content never requires a guard. */
+export function viewerOrAnonymous(viewer: Viewer | null): Viewer {
+ return viewer ?? anonymousViewer()
+}
+
+function loginUrlFor(returnTo: string): string {
+ return `/api/auth/login?returnTo=${encodeURIComponent(returnTo)}`
+}
diff --git a/src/server/auth/oidc.ts b/src/server/auth/oidc.ts
new file mode 100644
index 0000000..c811ae1
--- /dev/null
+++ b/src/server/auth/oidc.ts
@@ -0,0 +1,398 @@
+import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
+import { systemClock } from '#/lib/clock.ts'
+import type { Clock } from '#/lib/clock.ts'
+import type { OobConfig } from '#/server/config/oob-schema.ts'
+
+export const DISCOVERY_CACHE_TTL_MS = 60 * 60 * 1000
+export const DISCOVERY_TIMEOUT_MS = 5_000
+export const TOKEN_TIMEOUT_MS = 10_000
+const CLOCK_SKEW_MS = 30_000
+
+export class OidcUnavailableError extends Error {
+ constructor(
+ message: string,
+ readonly detail?: unknown,
+ ) {
+ super(message)
+ this.name = 'OidcUnavailableError'
+ }
+}
+
+export class OidcProtocolError extends Error {
+ constructor(
+ message: string,
+ readonly detail?: unknown,
+ ) {
+ super(message)
+ this.name = 'OidcProtocolError'
+ }
+}
+
+export interface OidcDiscovery {
+ issuer: string
+ authorizationEndpoint: string
+ tokenEndpoint: string
+}
+
+export interface LoginTransactionData {
+ state: string
+ codeVerifier: string
+ nonce: string
+ returnTo: string
+ createdAtIso: string
+}
+
+export interface TokenSet {
+ accessToken: string
+ idToken: string
+}
+
+export interface AuthenticatedIdentity {
+ sub: string
+ username: string
+ fullName: string | null
+ nickname: string | null
+ avatarUrl: string | null
+ groups: string[]
+}
+
+interface DiscoveryCacheEntry {
+ discovery: OidcDiscovery
+ fetchedAt: number
+}
+
+const discoveryCache = new Map()
+
+export function clearDiscoveryCache(): void {
+ discoveryCache.clear()
+}
+
+function base64url(input: Buffer): string {
+ return input.toString('base64url')
+}
+
+export function randomToken(byteLength = 32): string {
+ return base64url(randomBytes(byteLength))
+}
+
+export function pkceChallenge(codeVerifier: string): string {
+ return base64url(createHash('sha256').update(codeVerifier).digest())
+}
+
+export function safeEquals(left: string, right: string): boolean {
+ const leftBuffer = Buffer.from(left)
+ const rightBuffer = Buffer.from(right)
+ if (leftBuffer.length !== rightBuffer.length) {
+ return false
+ }
+ return timingSafeEqual(leftBuffer, rightBuffer)
+}
+
+async function fetchWithTimeout(
+ url: string,
+ init: RequestInit,
+ timeoutMs: number,
+ fetchImpl: typeof fetch,
+): Promise {
+ const controller = new AbortController()
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
+ try {
+ return await fetchImpl(url, { ...init, signal: controller.signal })
+ } finally {
+ clearTimeout(timer)
+ }
+}
+
+export async function fetchDiscovery(
+ authentik: OobConfig['authentik'],
+ options: { fetchImpl?: typeof fetch; clock?: Clock } = {},
+): Promise {
+ const fetchImpl = options.fetchImpl ?? fetch
+ const clock = options.clock ?? systemClock
+ const cacheKey = `${authentik.clientId}@${authentik.issuerUrl}`
+ const cached = discoveryCache.get(cacheKey)
+ const now = clock.now().getTime()
+
+ if (
+ cached &&
+ now - cached.fetchedAt < DISCOVERY_CACHE_TTL_MS &&
+ cached.discovery.authorizationEndpoint &&
+ cached.discovery.tokenEndpoint
+ ) {
+ return cached.discovery
+ }
+
+ const wellKnownUrl = new URL(
+ '.well-known/openid-configuration',
+ authentik.issuerUrl.endsWith('/')
+ ? authentik.issuerUrl
+ : `${authentik.issuerUrl}/`,
+ )
+
+ let response: Response
+ try {
+ response = await fetchWithTimeout(
+ wellKnownUrl.toString(),
+ { method: 'GET', headers: { accept: 'application/json' } },
+ DISCOVERY_TIMEOUT_MS,
+ fetchImpl,
+ )
+ } catch (error) {
+ throw new OidcUnavailableError(
+ `Az Authentik nem elérhető a discovery lekérdezéshez: ${wellKnownUrl.toString()}`,
+ error,
+ )
+ }
+
+ if (!response.ok) {
+ throw new OidcUnavailableError(
+ `Az Authentik discovery válasza hibás: HTTP ${response.status}`,
+ )
+ }
+
+ let raw: unknown
+ try {
+ raw = await response.json()
+ } catch (error) {
+ throw new OidcUnavailableError(
+ 'Az Authentik discovery válasza nem érvényes JSON.',
+ error,
+ )
+ }
+
+ const discovery = parseDiscovery(raw)
+ if (
+ normalizeIssuer(discovery.issuer) !== normalizeIssuer(authentik.issuerUrl)
+ ) {
+ throw new OidcProtocolError(
+ `Az Authentik discovery issuer-e eltér a beállítótól: ${discovery.issuer}`,
+ )
+ }
+
+ discoveryCache.set(cacheKey, { discovery, fetchedAt: now })
+ return discovery
+}
+
+function normalizeIssuer(value: string): string {
+ return value.trim().replace(/\/+$/, '')
+}
+
+function parseDiscovery(raw: unknown): OidcDiscovery {
+ if (typeof raw !== 'object' || raw === null) {
+ throw new OidcProtocolError('A discovery dokumentum nem objektum.')
+ }
+ const record = raw as Record
+ for (const key of ['issuer', 'authorization_endpoint', 'token_endpoint']) {
+ if (typeof record[key] !== 'string' || record[key].trim() === '') {
+ throw new OidcProtocolError(
+ `A discovery dokumentumban hiányzik a(z) "${key}" mező.`,
+ )
+ }
+ }
+ return {
+ issuer: record['issuer'] as string,
+ authorizationEndpoint: record['authorization_endpoint'] as string,
+ tokenEndpoint: record['token_endpoint'] as string,
+ }
+}
+
+export function buildLoginTransaction(returnTo: string): LoginTransactionData {
+ return {
+ state: randomToken(),
+ codeVerifier: randomToken(),
+ nonce: randomToken(),
+ returnTo,
+ createdAtIso: new Date().toISOString(),
+ }
+}
+
+export function buildAuthorizationUrl(
+ discovery: OidcDiscovery,
+ config: OobConfig['authentik'],
+ redirectUri: string,
+ transaction: Pick,
+): string {
+ const url = new URL(discovery.authorizationEndpoint)
+ url.searchParams.set('response_type', 'code')
+ url.searchParams.set('client_id', config.clientId)
+ url.searchParams.set('redirect_uri', redirectUri)
+ url.searchParams.set('scope', config.scopes.join(' '))
+ url.searchParams.set('state', transaction.state)
+ url.searchParams.set('nonce', transaction.nonce)
+ url.searchParams.set(
+ 'code_challenge',
+ pkceChallenge(transaction.codeVerifier),
+ )
+ url.searchParams.set('code_challenge_method', 'S256')
+ return url.toString()
+}
+
+export async function exchangeCodeForTokens(
+ discovery: OidcDiscovery,
+ config: OobConfig['authentik'],
+ redirectUri: string,
+ code: string,
+ codeVerifier: string,
+ options: { fetchImpl?: typeof fetch } = {},
+): Promise {
+ const fetchImpl = options.fetchImpl ?? fetch
+
+ let response: Response
+ try {
+ response = await fetchWithTimeout(
+ discovery.tokenEndpoint,
+ {
+ method: 'POST',
+ headers: {
+ 'content-type': 'application/x-www-form-urlencoded',
+ accept: 'application/json',
+ },
+ body: new URLSearchParams({
+ grant_type: 'authorization_code',
+ code,
+ redirect_uri: redirectUri,
+ client_id: config.clientId,
+ client_secret: config.clientSecret,
+ code_verifier: codeVerifier,
+ }),
+ },
+ TOKEN_TIMEOUT_MS,
+ fetchImpl,
+ )
+ } catch (error) {
+ throw new OidcUnavailableError(
+ 'A token csere nem sikerült, az Authentik nem elérhető.',
+ error,
+ )
+ }
+
+ if (!response.ok) {
+ throw new OidcUnavailableError(
+ `A token csere hibás választ adott: HTTP ${response.status}`,
+ )
+ }
+
+ let raw: unknown
+ try {
+ raw = await response.json()
+ } catch (error) {
+ throw new OidcProtocolError('A token válasz nem érvényes JSON.', error)
+ }
+
+ if (typeof raw !== 'object' || raw === null) {
+ throw new OidcProtocolError('A token válasz nem objektum.')
+ }
+ const record = raw as Record
+ const accessToken = record['access_token']
+ const idToken = record['id_token']
+ if (typeof accessToken !== 'string' || accessToken === '') {
+ throw new OidcProtocolError('A token válaszban hiányzik az access_token.')
+ }
+ if (typeof idToken !== 'string' || idToken === '') {
+ throw new OidcProtocolError('A token válaszban hiányzik az id_token.')
+ }
+ return { accessToken, idToken }
+}
+
+export function decodeJwtPayload(idToken: string): Record {
+ const parts = idToken.split('.')
+ if (parts.length !== 3) {
+ throw new OidcProtocolError('Az id_token nem három részes JWT.')
+ }
+ try {
+ const json = Buffer.from(parts[1], 'base64url').toString('utf-8')
+ const payload: unknown = JSON.parse(json)
+ if (
+ typeof payload !== 'object' ||
+ payload === null ||
+ Array.isArray(payload)
+ ) {
+ throw new Error('a payload nem objektum')
+ }
+ return payload as Record
+ } catch (error) {
+ throw new OidcProtocolError('Az id_token payload nem értelmezhető.', error)
+ }
+}
+
+export function validateIdTokenClaims(
+ claims: Record