Sprint: debug, zichtbaarheid componenten (#165)
* feat(debug-store): Zustand store met hydration-flag voor debug-modus * feat(status-bar): dev-only debug-toggle via geïsoleerde sub-component * feat(globals.css): debug-mode overlay CSS voor data-debug-id elementen * feat(shared): data-debug-id+label op navigatie-componenten * feat(shared): data-debug-id+label op form/select-componenten * feat(shared): data-debug-id+label op display-componenten
This commit is contained in:
parent
f7464db837
commit
a16988b957
20 changed files with 154 additions and 35 deletions
|
|
@ -3,3 +3,25 @@
|
||||||
@plugin "@tailwindcss/typography";
|
@plugin "@tailwindcss/typography";
|
||||||
|
|
||||||
@import "./styles/theme.css";
|
@import "./styles/theme.css";
|
||||||
|
|
||||||
|
/* Debug-mode overlay (alleen actief wanneer body.debug-mode is gezet door dev-only toggle) */
|
||||||
|
body.debug-mode [data-debug-id] {
|
||||||
|
outline: 2px dashed var(--info);
|
||||||
|
outline-offset: 1px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
body.debug-mode [data-debug-id]:hover::after {
|
||||||
|
content: attr(data-debug-label);
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
background: var(--info-container);
|
||||||
|
color: var(--info-container-foreground);
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1.2;
|
||||||
|
padding: 2px 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
border-radius: 2px;
|
||||||
|
z-index: 9999;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ export function ActivateProductButton({ productId, isDemo, redirectTo, label = '
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<span data-debug-id="activate-product-button" data-debug-label="ActivateProductButton — shared/activate-product-button.tsx">
|
||||||
<DemoTooltip show={isDemo}>
|
<DemoTooltip show={isDemo}>
|
||||||
<button
|
<button
|
||||||
onClick={() => !isDemo && handleActivate()}
|
onClick={() => !isDemo && handleActivate()}
|
||||||
|
|
@ -37,5 +38,6 @@ export function ActivateProductButton({ productId, isDemo, redirectTo, label = '
|
||||||
{label}
|
{label}
|
||||||
</button>
|
</button>
|
||||||
</DemoTooltip>
|
</DemoTooltip>
|
||||||
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,5 +24,5 @@ export function AlertToast() {
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [alert])
|
}, [alert])
|
||||||
|
|
||||||
return null
|
return <span data-debug-id="alert-toast" data-debug-label="AlertToast — shared/alert-toast.tsx" hidden />
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,8 @@ export function AppIcon({ size = 32, className }: AppIconProps) {
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
className={className}
|
className={className}
|
||||||
aria-label="Scrum4Me"
|
aria-label="Scrum4Me"
|
||||||
|
data-debug-id="app-icon"
|
||||||
|
data-debug-label="AppIcon — shared/app-icon.tsx"
|
||||||
>
|
>
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="s4m-bg" x1="0" y1="0" x2="512" y2="512" gradientUnits="userSpaceOnUse">
|
<linearGradient id="s4m-bg" x1="0" y1="0" x2="512" y2="512" gradientUnits="userSpaceOnUse">
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ export function CodeBadge({ code, className }: CodeBadgeProps) {
|
||||||
if (!code) return null
|
if (!code) return null
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
|
data-debug-id="code-badge"
|
||||||
|
data-debug-label="CodeBadge — shared/code-badge.tsx"
|
||||||
className={cn(
|
className={cn(
|
||||||
'inline-flex items-center rounded-md border border-border bg-surface-container px-1.5 py-0.5 font-mono text-[11px] leading-none text-muted-foreground',
|
'inline-flex items-center rounded-md border border-border bg-surface-container px-1.5 py-0.5 font-mono text-[11px] leading-none text-muted-foreground',
|
||||||
className,
|
className,
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,10 @@ interface DemoTooltipProps {
|
||||||
// Wraps children with a "Niet beschikbaar in demo-modus" tooltip when show=true.
|
// Wraps children with a "Niet beschikbaar in demo-modus" tooltip when show=true.
|
||||||
// Uses a span trigger so tooltip works on disabled elements.
|
// Uses a span trigger so tooltip works on disabled elements.
|
||||||
export function DemoTooltip({ show, children }: DemoTooltipProps) {
|
export function DemoTooltip({ show, children }: DemoTooltipProps) {
|
||||||
if (!show) return <>{children}</>
|
if (!show) return <span data-debug-id="demo-tooltip" data-debug-label="DemoTooltip — shared/demo-tooltip.tsx">{children}</span>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<span data-debug-id="demo-tooltip" data-debug-label="DemoTooltip — shared/demo-tooltip.tsx">
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger render={<span className="inline-flex" />}>
|
<TooltipTrigger render={<span className="inline-flex" />}>
|
||||||
|
|
@ -21,5 +22,6 @@ export function DemoTooltip({ show, children }: DemoTooltipProps) {
|
||||||
<TooltipContent>Niet beschikbaar in demo-modus</TooltipContent>
|
<TooltipContent>Niet beschikbaar in demo-modus</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,11 @@
|
||||||
// Shows a warning banner on screens narrower than 1024px.
|
// Shows a warning banner on screens narrower than 1024px.
|
||||||
export function MinWidthBanner() {
|
export function MinWidthBanner() {
|
||||||
return (
|
return (
|
||||||
<div className="lg:hidden bg-warning/10 border-b border-warning/30 px-4 py-2 text-center text-xs text-warning">
|
<div
|
||||||
|
data-debug-id="min-width-banner"
|
||||||
|
data-debug-label="MinWidthBanner — shared/min-width-banner.tsx"
|
||||||
|
className="lg:hidden bg-warning/10 border-b border-warning/30 px-4 py-2 text-center text-xs text-warning"
|
||||||
|
>
|
||||||
Scrum4Me is ontworpen voor schermen van minimaal 1024px breed. Sommige functies zijn mogelijk niet goed bruikbaar op dit scherm.
|
Scrum4Me is ontworpen voor schermen van minimaal 1024px breed. Sommige functies zijn mogelijk niet goed bruikbaar op dit scherm.
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -111,7 +111,11 @@ export function NavBar({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="bg-surface-container-low border-b border-border h-14 flex items-center px-4 shrink-0">
|
<header
|
||||||
|
data-debug-id="nav-bar"
|
||||||
|
data-debug-label="NavBar — shared/nav-bar.tsx"
|
||||||
|
className="bg-surface-container-low border-b border-border h-14 flex items-center px-4 shrink-0"
|
||||||
|
>
|
||||||
{/* Links: logo + nav */}
|
{/* Links: logo + nav */}
|
||||||
<div className="flex items-center gap-4 flex-1">
|
<div className="flex items-center gap-4 flex-1">
|
||||||
<Link href="/" className="flex items-center gap-2 font-medium text-foreground">
|
<Link href="/" className="flex items-center gap-2 font-medium text-foreground">
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ export function NotificationsBell({ currentUserId, isDemo }: NotificationsBellPr
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<span data-debug-id="notifications-bell" data-debug-label="NotificationsBell — shared/notifications-bell.tsx">
|
||||||
<NotificationsSheet
|
<NotificationsSheet
|
||||||
currentUserId={currentUserId}
|
currentUserId={currentUserId}
|
||||||
isDemo={isDemo}
|
isDemo={isDemo}
|
||||||
|
|
@ -53,5 +54,6 @@ export function NotificationsBell({ currentUserId, isDemo }: NotificationsBellPr
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,11 @@ interface PanelNavBarProps {
|
||||||
|
|
||||||
export function PanelNavBar({ title, actions, className }: PanelNavBarProps) {
|
export function PanelNavBar({ title, actions, className }: PanelNavBarProps) {
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex items-center justify-between px-4 py-2 border-b border-border bg-surface-container-low shrink-0', className)}>
|
<div
|
||||||
|
data-debug-id="panel-nav-bar"
|
||||||
|
data-debug-label="PanelNavBar — shared/panel-nav-bar.tsx"
|
||||||
|
className={cn('flex items-center justify-between px-4 py-2 border-b border-border bg-surface-container-low shrink-0', className)}
|
||||||
|
>
|
||||||
<span className="text-sm font-medium text-foreground">{title}</span>
|
<span className="text-sm font-medium text-foreground">{title}</span>
|
||||||
{actions && <div className="flex items-center gap-2">{actions}</div>}
|
{actions && <div className="flex items-center gap-2">{actions}</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ interface PbiStatusSelectProps {
|
||||||
|
|
||||||
export function PbiStatusSelect({ value, onChange, className }: PbiStatusSelectProps) {
|
export function PbiStatusSelect({ value, onChange, className }: PbiStatusSelectProps) {
|
||||||
return (
|
return (
|
||||||
|
<span data-debug-id="pbi-status-select" data-debug-label="PbiStatusSelect — shared/pbi-status-select.tsx">
|
||||||
<Select
|
<Select
|
||||||
value={value}
|
value={value}
|
||||||
onValueChange={(v) => { if (v) onChange(v as PbiStatusApi) }}
|
onValueChange={(v) => { if (v) onChange(v as PbiStatusApi) }}
|
||||||
|
|
@ -39,5 +40,6 @@ export function PbiStatusSelect({ value, onChange, className }: PbiStatusSelectP
|
||||||
<SelectItem value="done">Afgerond</SelectItem>
|
<SelectItem value="done">Afgerond</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ interface PrioritySelectProps {
|
||||||
|
|
||||||
export function PrioritySelect({ value, onChange, className }: PrioritySelectProps) {
|
export function PrioritySelect({ value, onChange, className }: PrioritySelectProps) {
|
||||||
return (
|
return (
|
||||||
|
<span data-debug-id="priority-select" data-debug-label="PrioritySelect — shared/priority-select.tsx">
|
||||||
<Select
|
<Select
|
||||||
value={String(value)}
|
value={String(value)}
|
||||||
onValueChange={(v) => { if (v) onChange(parseInt(v)) }}
|
onValueChange={(v) => { if (v) onChange(parseInt(v)) }}
|
||||||
|
|
@ -39,5 +40,6 @@ export function PrioritySelect({ value, onChange, className }: PrioritySelectPro
|
||||||
<SelectItem value="4">Laag</SelectItem>
|
<SelectItem value="4">Laag</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,5 +11,5 @@ export function SetCurrentProduct({ id, name }: { id: string; name: string }) {
|
||||||
return () => clearCurrentProduct()
|
return () => clearCurrentProduct()
|
||||||
}, [id, name, setCurrentProduct, clearCurrentProduct])
|
}, [id, name, setCurrentProduct, clearCurrentProduct])
|
||||||
|
|
||||||
return null
|
return <span data-debug-id="set-current-product" data-debug-label="SetCurrentProduct — shared/set-current-product.tsx" hidden />
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,7 @@ export function SprintSwitcher({
|
||||||
|
|
||||||
if (sprints.length === 0) {
|
if (sprints.length === 0) {
|
||||||
return (
|
return (
|
||||||
|
<span data-debug-id="sprint-switcher" data-debug-label="SprintSwitcher — shared/sprint-switcher.tsx">
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger
|
<TooltipTrigger
|
||||||
|
|
@ -79,10 +80,12 @@ export function SprintSwitcher({
|
||||||
<TooltipContent>Maak een sprint aan vanuit de Product Backlog</TooltipContent>
|
<TooltipContent>Maak een sprint aan vanuit de Product Backlog</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<span data-debug-id="sprint-switcher" data-debug-label="SprintSwitcher — shared/sprint-switcher.tsx">
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger
|
<DropdownMenuTrigger
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
|
|
@ -152,5 +155,6 @@ export function SprintSwitcher({
|
||||||
)}
|
)}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
31
components/shared/status-bar-debug-toggle.tsx
Normal file
31
components/shared/status-bar-debug-toggle.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
import { useDebugStore } from '@/stores/debug-store'
|
||||||
|
|
||||||
|
export function DebugToggle() {
|
||||||
|
const { debugMode, _hydrated, hydrate, toggleDebugMode } = useDebugStore()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
hydrate(localStorage.getItem('scrum4me:debug-mode') === 'true')
|
||||||
|
}, [hydrate])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!_hydrated) return
|
||||||
|
localStorage.setItem('scrum4me:debug-mode', String(debugMode))
|
||||||
|
document.body.classList.toggle('debug-mode', debugMode)
|
||||||
|
}, [debugMode, _hydrated])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleDebugMode}
|
||||||
|
aria-label="Debug-modus togglen"
|
||||||
|
aria-pressed={debugMode}
|
||||||
|
data-active={debugMode}
|
||||||
|
className="ml-2 cursor-pointer rounded px-1 text-xs opacity-40 transition-opacity hover:opacity-100 data-[active=true]:text-info"
|
||||||
|
>
|
||||||
|
{'{ }'}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,7 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { DebugToggle } from './status-bar-debug-toggle'
|
||||||
|
|
||||||
const buildDate = process.env.NEXT_PUBLIC_BUILD_DATE
|
const buildDate = process.env.NEXT_PUBLIC_BUILD_DATE
|
||||||
? new Date(process.env.NEXT_PUBLIC_BUILD_DATE).toLocaleDateString('nl-NL', {
|
? new Date(process.env.NEXT_PUBLIC_BUILD_DATE).toLocaleDateString('nl-NL', {
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
|
|
@ -7,12 +11,17 @@ const buildDate = process.env.NEXT_PUBLIC_BUILD_DATE
|
||||||
: '—'
|
: '—'
|
||||||
|
|
||||||
const version = process.env.NEXT_PUBLIC_APP_VERSION ?? '0.0.0'
|
const version = process.env.NEXT_PUBLIC_APP_VERSION ?? '0.0.0'
|
||||||
|
const isDev = process.env.NODE_ENV !== 'production'
|
||||||
|
|
||||||
export function StatusBar() {
|
export function StatusBar() {
|
||||||
return (
|
return (
|
||||||
<footer className="shrink-0 border-t border-border bg-surface-container-low h-14 px-4 flex items-center justify-between text-sm text-muted-foreground select-none">
|
<footer
|
||||||
|
className="shrink-0 border-t border-border bg-surface-container-low h-14 px-4 flex items-center justify-between text-sm text-muted-foreground select-none"
|
||||||
|
data-debug-id="status-bar"
|
||||||
|
data-debug-label="StatusBar — shared/status-bar.tsx"
|
||||||
|
>
|
||||||
<span>© {new Date().getFullYear()} Scrum4Me</span>
|
<span>© {new Date().getFullYear()} Scrum4Me</span>
|
||||||
<span>v{version} · gebouwd op {buildDate}</span>
|
<span>v{version} · gebouwd op {buildDate}{isDev && <DebugToggle />}</span>
|
||||||
</footer>
|
</footer>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,14 +34,22 @@ const TYPE_STYLES: Record<string, { bg: string; label: string; labelColor: strin
|
||||||
export function StoryLog({ logs, repoUrl }: StoryLogProps) {
|
export function StoryLog({ logs, repoUrl }: StoryLogProps) {
|
||||||
if (logs.length === 0) {
|
if (logs.length === 0) {
|
||||||
return (
|
return (
|
||||||
<p className="text-sm text-muted-foreground text-center py-4">
|
<p
|
||||||
|
data-debug-id="story-log"
|
||||||
|
data-debug-label="StoryLog — shared/story-log.tsx"
|
||||||
|
className="text-sm text-muted-foreground text-center py-4"
|
||||||
|
>
|
||||||
Nog geen activiteit. Gebruik de REST API om logs toe te voegen.
|
Nog geen activiteit. Gebruik de REST API om logs toe te voegen.
|
||||||
</p>
|
</p>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div
|
||||||
|
data-debug-id="story-log"
|
||||||
|
data-debug-label="StoryLog — shared/story-log.tsx"
|
||||||
|
className="space-y-3"
|
||||||
|
>
|
||||||
{logs.map(log => {
|
{logs.map(log => {
|
||||||
const style = TYPE_STYLES[log.type] ?? TYPE_STYLES.IMPLEMENTATION_PLAN
|
const style = TYPE_STYLES[log.type] ?? TYPE_STYLES.IMPLEMENTATION_PLAN
|
||||||
const isTestResult = log.type === 'TEST_RESULT'
|
const isTestResult = log.type === 'TEST_RESULT'
|
||||||
|
|
|
||||||
|
|
@ -23,11 +23,13 @@ export function UserAvatar({ userId, username, size = 'md', className }: UserAva
|
||||||
const initials = username.slice(0, 2).toUpperCase()
|
const initials = username.slice(0, 2).toUpperCase()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<span data-debug-id="user-avatar" data-debug-label="UserAvatar — shared/user-avatar.tsx">
|
||||||
<Avatar className={cn(SIZE_CLASSES[size], className)}>
|
<Avatar className={cn(SIZE_CLASSES[size], className)}>
|
||||||
<AvatarImage src={`/api/users/${userId}/avatar`} alt={username} />
|
<AvatarImage src={`/api/users/${userId}/avatar`} alt={username} />
|
||||||
<AvatarFallback className="bg-primary-container text-primary-container-foreground font-medium">
|
<AvatarFallback className="bg-primary-container text-primary-container-foreground font-medium">
|
||||||
{initials}
|
{initials}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ export function UserMenu({ userId, username, email, roles }: UserMenuProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<span data-debug-id="user-menu" data-debug-label="UserMenu — shared/user-menu.tsx">
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger
|
<DropdownMenuTrigger
|
||||||
className="rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
className="rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
||||||
|
|
@ -137,5 +138,6 @@ export function UserMenu({ userId, username, email, roles }: UserMenuProps) {
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
15
stores/debug-store.ts
Normal file
15
stores/debug-store.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { create } from 'zustand'
|
||||||
|
|
||||||
|
type DebugStore = {
|
||||||
|
debugMode: boolean
|
||||||
|
_hydrated: boolean
|
||||||
|
hydrate: (value: boolean) => void
|
||||||
|
toggleDebugMode: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useDebugStore = create<DebugStore>((set, get) => ({
|
||||||
|
debugMode: false,
|
||||||
|
_hydrated: false,
|
||||||
|
hydrate: (v) => set({ debugMode: v, _hydrated: true }),
|
||||||
|
toggleDebugMode: () => set({ debugMode: !get().debugMode }),
|
||||||
|
}))
|
||||||
Loading…
Add table
Add a link
Reference in a new issue