bugfix: a small refactor done in folder structure and naming conventions.

This commit is contained in:
2026-05-18 12:31:21 +03:30
parent 4590255b31
commit eb636db653
39 changed files with 68 additions and 68 deletions

View File

@@ -0,0 +1,59 @@
import React from 'react';
export type BadgeVariant = 'success' | 'warning' | 'danger' | 'default';
interface BadgeProps {
children: React.ReactNode;
variant?: BadgeVariant;
className?: string;
/**
* Same pixel width for every badge (table columns).
* Set false only when the pill should shrink to the label.
*/
fixedWidth?: boolean;
}
const variantStyles: Record<BadgeVariant, string> = {
success: 'bg-badge-success-bg text-badge-success-fg border-badge-success-border',
warning: 'bg-badge-warning-bg text-badge-warning-fg border-badge-warning-border',
danger: 'bg-badge-danger-bg text-badge-danger-fg border-badge-danger-border',
default: 'bg-badge-default-bg text-badge-default-fg border-badge-default-border',
};
/** Explicit width + height so every row matches; flex centers label optically. */
const FIXED_LAYOUT_CLASS =
'w-[8rem] min-w-[8rem] max-w-[8rem] shrink-0 h-7 px-2 py-0';
export function Badge({
children,
variant = 'default',
className,
fixedWidth = true,
}: BadgeProps) {
const layoutClass = fixedWidth
? `${FIXED_LAYOUT_CLASS} justify-center text-center`
: 'min-h-[1.75rem] px-2.5 py-1 justify-center';
return (
<span
className={`inline-flex items-center box-border rounded-md border text-xs font-medium leading-none whitespace-nowrap ${variantStyles[variant]} ${layoutClass} ${className ?? ''}`}
>
{children}
</span>
);
}
/** Map organization connection / invitation row status to badge variant. */
export function organizationConnectionStatusVariant(status: string): BadgeVariant {
switch (status) {
case 'ACTIVE':
return 'success';
case 'PENDING':
return 'warning';
case 'REJECTED':
case 'EXPIRED':
return 'danger';
default:
return 'default';
}
}

View File

@@ -0,0 +1,63 @@
import React from 'react';
type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'danger' | 'ghost';
type ButtonSize = 'sm' | 'md' | 'lg';
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: ButtonSize;
isLoading?: boolean;
fullWidth?: boolean;
children: React.ReactNode;
}
export const Button: React.FC<ButtonProps> = ({
variant = 'primary',
size = 'md',
isLoading = false,
fullWidth = false,
children,
className = '',
disabled,
...props
}) => {
const baseClasses =
'inline-flex items-center justify-center rounded-[var(--radius-md)] font-medium transition-all duration-200 ' +
'focus:outline-none focus:ring-2 focus:ring-primary/40 disabled:cursor-not-allowed';
const variantClasses: Record<ButtonVariant, string> = {
primary:
'bg-primary text-white hover:opacity-90 disabled:opacity-60',
secondary:
'bg-surface-elevated text-text-primary border border-border hover:border-border-strong disabled:opacity-50',
outline:
'border border-border text-text-primary hover:bg-background-card/70 disabled:opacity-50',
danger: 'bg-red-600 text-white hover:bg-red-700 disabled:opacity-50',
ghost:
'text-text-secondary hover:text-text-primary hover:bg-background-card/70 disabled:opacity-50',
};
const sizeClasses: Record<ButtonSize, string> = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-sm',
lg: 'px-6 py-3 text-base',
};
const widthClass = fullWidth ? 'w-full' : '';
const loadingClass = isLoading ? 'opacity-70 animate-pulse pointer-events-none' : '';
return (
<button
className={`${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${widthClass} ${loadingClass} ${className}`}
disabled={disabled || isLoading}
aria-busy={isLoading || undefined}
{...props}
>
{children}
</button>
);
};

View File

@@ -0,0 +1,36 @@
import type { ElementType, ReactNode } from 'react';
type CardPadding = 'none' | 'sm' | 'md' | 'lg';
type CardProps<T extends ElementType = 'div'> = {
children: ReactNode;
padding?: CardPadding;
as?: T;
className?: string;
} & Omit<React.ComponentPropsWithoutRef<T>, 'as' | 'children' | 'className'>;
const paddingClassMap: Record<CardPadding, string> = {
none: '',
sm: 'p-3',
md: 'p-4',
lg: 'p-6',
};
export function Card<T extends ElementType = 'div'>({
children,
as,
className = '',
padding = 'md',
...props
}: CardProps<T>) {
const Component = as ?? 'div';
return (
<Component
className={`rounded-[var(--radius-lg)] border border-card-border bg-card text-card-foreground ${paddingClassMap[padding]} ${className}`}
{...props}
>
{children}
</Component>
);
}

View File

@@ -0,0 +1,70 @@
'use client';
import { useId } from 'react';
import { Check } from 'lucide-react';
type CheckboxProps = {
checked: boolean;
onChange: (checked: boolean) => void;
disabled?: boolean;
label: string;
id?: string;
className?: string;
};
/**
* App design-system checkbox: primary fill when checked, rounded, focus-visible ring.
*/
export function Checkbox({
checked,
onChange,
disabled = false,
label,
id,
className = '',
}: CheckboxProps) {
const genId = useId();
const inputId = id ?? genId;
return (
<label
htmlFor={inputId}
className={`
inline-flex items-center gap-2.5 cursor-pointer select-none rounded-[var(--radius-sm)] -m-0.5 p-0.5
has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-primary/45 has-[:focus-visible]:ring-offset-2
has-[:focus-visible]:ring-offset-background-secondary
${disabled ? 'opacity-50 cursor-not-allowed' : ''}
${className}
`}
>
<input
id={inputId}
type="checkbox"
className="sr-only"
checked={checked}
disabled={disabled}
onChange={(e) => onChange(e.target.checked)}
/>
<span
className={`
flex h-5 w-5 shrink-0 items-center justify-center rounded-[var(--radius-sm)] border-2 transition-all duration-200
shadow-[inset_0_1px_0_rgba(255,255,255,0.05)]
${
checked
? 'border-primary bg-primary shadow-[0_0_0_1px_rgba(9,169,188,0.25)]'
: 'border-border-strong bg-background-card/90 hover:border-border'
}
`}
aria-hidden
>
<Check
strokeWidth={3}
className={`h-3.5 w-3.5 text-primary-contrast transition-all duration-200 ${
checked ? 'scale-100 opacity-100' : 'scale-75 opacity-0'
}`}
/>
</span>
<span className="text-sm text-text-secondary">{label}</span>
</label>
);
}

View File

@@ -0,0 +1,21 @@
'use client';
import { X } from 'lucide-react';
type DialogCloseButtonProps = {
onClick: () => void;
className?: string;
};
export function DialogCloseButton({ onClick, className = '' }: DialogCloseButtonProps) {
return (
<button
type="button"
onClick={onClick}
className={`shrink-0 rounded-[var(--radius-sm)] p-1.5 text-text-muted hover:text-text-primary hover:bg-background-secondary/80 focus:outline-none focus:ring-2 focus:ring-primary/35 ${className}`}
aria-label="Close"
>
<X className="h-5 w-5 icon-flat" />
</button>
);
}

View File

@@ -0,0 +1,68 @@
'use client';
import { ChevronDown } from 'lucide-react';
import React, { forwardRef } from 'react';
interface DropdownProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
label?: string;
error?: string;
}
export const Dropdown = forwardRef<HTMLSelectElement, DropdownProps>(
({ label, error, className = '', id, children, ...props }, ref) => {
const selectId = id || `dropdown-${Math.random().toString(36).slice(2, 9)}`;
return (
<div className="w-full">
{label && (
<label
htmlFor={selectId}
className="block text-sm font-medium text-text-secondary mb-1"
>
{label}
</label>
)}
<div className="relative">
<select
ref={ref}
id={selectId}
className={`
w-full appearance-none rounded-[var(--radius-md)] border
${error ? 'border-red-500' : 'border-border'}
bg-background-secondary/90 text-text-primary
pl-4 pr-14 py-2 text-sm
focus:outline-none focus:ring-2 focus:ring-primary/35 focus:border-border-strong
disabled:opacity-50 disabled:cursor-not-allowed
transition-all duration-200 shadow-[inset_0_1px_0_rgba(255,255,255,0.02)]
${className}
`}
{...props}
>
{children}
</select>
<div
className="pointer-events-none absolute inset-y-0 right-5 flex items-center text-text-muted"
aria-hidden
>
<ChevronDown className="h-4 w-4 icon-flat" />
</div>
</div>
{error && (
<p className="mt-1 text-sm text-red-500">
{error}
</p>
)}
</div>
);
},
);
Dropdown.displayName = 'Dropdown';

View File

@@ -0,0 +1,67 @@
// src/components/ui/Input.tsx
import React, { forwardRef } from 'react';
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label?: string;
error?: string;
icon?: React.ReactNode;
}
export const Input = forwardRef<HTMLInputElement, InputProps>(
({ label, error, icon, className = '', id, ...props }, ref) => {
const inputId =
id || `input-${Math.random().toString(36).slice(2, 9)}`;
return (
<div className="w-full">
{label && (
<label
htmlFor={inputId}
className="block text-sm font-medium text-text-secondary mb-1"
>
{label}
</label>
)}
<div className="relative">
{icon && (
<div className="absolute inset-y-0 left-0 pl-3 flex items-center text-text-muted pointer-events-none">
{icon}
</div>
)}
<input
ref={ref}
id={inputId}
className={`
w-full rounded-[var(--radius-md)] border
${error ? 'border-red-500' : 'border-border'}
bg-background-secondary/90 text-text-primary
${icon ? 'pl-10' : 'pl-4'} pr-4 py-2
placeholder:text-text-muted
focus:outline-none focus:ring-2 focus:ring-primary/35 focus:border-border-strong
disabled:opacity-50 disabled:cursor-not-allowed
transition-all duration-200 shadow-[inset_0_1px_0_rgba(255,255,255,0.02)]
${className}
`}
{...props}
/>
</div>
{error && (
<p className="mt-1 text-sm text-red-500">
{error}
</p>
)}
</div>
);
}
);
Input.displayName = 'Input';

View File

@@ -0,0 +1,38 @@
// src/components/ui/OrganizationCard.tsx
import React from 'react';
import { Building2, Beaker, ChevronRight } from 'lucide-react';
import type { Organization } from '@/types/organization';
interface OrganizationCardProps {
organization: Organization;
onSelect: (id: string) => void;
}
export const OrganizationCard: React.FC<OrganizationCardProps> = ({
organization,
onSelect,
}) => {
const Icon = organization.type === 'CLINIC' ? Building2 : Beaker;
const typeText = organization.type === 'CLINIC' ? 'Dental Clinic' : 'Dental Lab';
return (
<button
onClick={() => onSelect(organization.id)}
className="w-full surface-card p-6 hover:border-primary/60 transition-all text-left flex items-center gap-4 group"
>
<div className="p-3 bg-primary-soft rounded-[var(--radius-sm)] text-primary">
<Icon className="h-8 w-8 icon-flat" />
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-text-primary">{organization.name}</h3>
<p className="text-sm text-text-secondary">{typeText}</p>
{organization.plan && (
<p className="text-xs text-text-muted mt-1">
Plan: {organization.plan.name} {organization.plan.maxUsers} users
</p>
)}
</div>
<ChevronRight className="h-5 w-5 icon-flat text-text-muted group-hover:text-primary transition-colors" />
</button>
);
};

View File

@@ -0,0 +1,299 @@
'use client';
import { useEffect, useId, useRef, useState } from 'react';
import { ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react';
import {
addCalendarDays,
compareLocalDayStart,
startOfLocalDay,
} from '@/lib/appointmentTime';
interface ScheduleDayPickerProps {
value: Date;
onChange: (day: Date) => void;
/** Optional lower bound for day selection and previous-day navigation. */
minDate?: Date;
label?: string;
}
const MONTH_LABELS = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
] as const;
function daysInMonth(year: number, month: number): number {
return new Date(year, month + 1, 0).getDate();
}
function buildLocalDay(year: number, month: number, day: number): Date {
return new Date(year, month, day, 0, 0, 0, 0);
}
function clampToValidDay(
year: number,
month: number,
day: number,
min?: Date,
): Date {
const maxDay = daysInMonth(year, month);
let next = buildLocalDay(year, month, Math.min(Math.max(1, day), maxDay));
if (min) {
const floor = startOfLocalDay(min);
if (compareLocalDayStart(next, floor) < 0) {
next = floor;
}
}
return next;
}
function yearRange(min?: Date, anchor?: Date): number[] {
const now = new Date();
const startYear = min ? min.getFullYear() : now.getFullYear() - 5;
const endYear = Math.max(now.getFullYear() + 2, anchor?.getFullYear() ?? now.getFullYear());
const years: number[] = [];
for (let y = startYear; y <= endYear; y += 1) {
years.push(y);
}
return years;
}
const selectClassName = `
w-full appearance-none rounded-[var(--radius-sm)] border border-border
bg-background-card/90 text-text-primary text-sm
pl-2 pr-7 py-1.5
focus:outline-none focus:ring-2 focus:ring-primary/35 focus:border-border-strong
disabled:opacity-50 disabled:cursor-not-allowed
`;
export function ScheduleDayPicker({
value,
onChange,
minDate,
label = 'Schedule date',
}: ScheduleDayPickerProps) {
const panelId = useId();
const rootRef = useRef<HTMLDivElement>(null);
const [panelOpen, setPanelOpen] = useState(false);
const normalizedValue = startOfLocalDay(value);
const normalizedMin = minDate ? startOfLocalDay(minDate) : undefined;
const labelText = normalizedValue.toLocaleDateString(undefined, {
weekday: 'short',
month: 'short',
day: 'numeric',
year: 'numeric',
});
const previousDay = addCalendarDays(normalizedValue, -1);
const canGoPrevious =
!normalizedMin || compareLocalDayStart(previousDay, normalizedMin) >= 0;
const years = yearRange(normalizedMin, normalizedValue);
const selectedYear = normalizedValue.getFullYear();
const selectedMonth = normalizedValue.getMonth();
const selectedDay = normalizedValue.getDate();
const dayCount = daysInMonth(selectedYear, selectedMonth);
function applyParts(year: number, month: number, day: number, closePanel = false) {
onChange(clampToValidDay(year, month, day, normalizedMin));
if (closePanel) {
setPanelOpen(false);
}
}
function handlePreviousDay() {
if (!canGoPrevious) return;
onChange(previousDay);
}
useEffect(() => {
if (!panelOpen) return;
function onPointerDown(event: MouseEvent) {
if (!rootRef.current?.contains(event.target as Node)) {
setPanelOpen(false);
}
}
function onKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') {
setPanelOpen(false);
}
}
document.addEventListener('mousedown', onPointerDown);
document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('mousedown', onPointerDown);
document.removeEventListener('keydown', onKeyDown);
};
}, [panelOpen]);
return (
<div ref={rootRef} className="relative w-full max-w-md">
<p className="text-sm font-medium text-text-secondary mb-2">{label}</p>
<div className="flex items-center gap-1 rounded-[var(--radius-md)] border border-border bg-background-secondary/90 px-1 py-1 shadow-[inset_0_1px_0_rgba(255,255,255,0.02)]">
<button
type="button"
onClick={handlePreviousDay}
disabled={!canGoPrevious}
className="shrink-0 rounded-[var(--radius-sm)] p-2 text-text-muted hover:text-text-primary hover:bg-background-card/80 focus:outline-none focus:ring-2 focus:ring-primary/35 disabled:opacity-40 disabled:pointer-events-none"
aria-label="Previous day"
>
<ChevronLeft className="h-4 w-4 icon-flat" />
</button>
<button
type="button"
onClick={() => setPanelOpen((open) => !open)}
aria-expanded={panelOpen}
aria-controls={panelId}
aria-haspopup="dialog"
className="flex flex-1 min-w-0 items-center justify-center gap-1 rounded-[var(--radius-sm)] px-2 py-1.5 text-sm font-medium text-text-primary tabular-nums hover:bg-background-card/80 focus:outline-none focus:ring-2 focus:ring-primary/35"
>
<span className="truncate">{labelText}</span>
<ChevronDown
className={`h-3.5 w-3.5 shrink-0 text-text-muted icon-flat transition-transform ${panelOpen ? 'rotate-180' : ''}`}
aria-hidden
/>
</button>
<button
type="button"
onClick={() => onChange(addCalendarDays(normalizedValue, 1))}
className="shrink-0 rounded-[var(--radius-sm)] p-2 text-text-muted hover:text-text-primary hover:bg-background-card/80 focus:outline-none focus:ring-2 focus:ring-primary/35"
aria-label="Next day"
>
<ChevronRight className="h-4 w-4 icon-flat" />
</button>
</div>
{panelOpen && (
<div
id={panelId}
role="dialog"
aria-label="Choose schedule date"
className="absolute left-0 right-0 top-full z-50 mt-2 rounded-[var(--radius-md)] border border-border bg-background-secondary p-3 shadow-lg"
>
<div className="grid grid-cols-3 gap-2">
<div>
<label
htmlFor={`${panelId}-year`}
className="mb-1 block text-xs font-medium text-text-muted"
>
Year
</label>
<div className="relative">
<select
id={`${panelId}-year`}
value={selectedYear}
onChange={(e) =>
applyParts(Number(e.target.value), selectedMonth, selectedDay)
}
className={selectClassName}
>
{years.map((year) => (
<option key={year} value={year}>
{year}
</option>
))}
</select>
<ChevronDown
className="pointer-events-none absolute right-1.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-text-muted icon-flat"
aria-hidden
/>
</div>
</div>
<div>
<label
htmlFor={`${panelId}-month`}
className="mb-1 block text-xs font-medium text-text-muted"
>
Month
</label>
<div className="relative">
<select
id={`${panelId}-month`}
value={selectedMonth}
onChange={(e) =>
applyParts(selectedYear, Number(e.target.value), selectedDay)
}
className={selectClassName}
>
{MONTH_LABELS.map((name, index) => {
const disabled =
normalizedMin &&
selectedYear === normalizedMin.getFullYear() &&
index < normalizedMin.getMonth();
return (
<option key={name} value={index} disabled={disabled}>
{name}
</option>
);
})}
</select>
<ChevronDown
className="pointer-events-none absolute right-1.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-text-muted icon-flat"
aria-hidden
/>
</div>
</div>
<div>
<label
htmlFor={`${panelId}-day`}
className="mb-1 block text-xs font-medium text-text-muted"
>
Day
</label>
<div className="relative">
<select
id={`${panelId}-day`}
value={selectedDay}
onChange={(e) =>
applyParts(
selectedYear,
selectedMonth,
Number(e.target.value),
true,
)
}
className={selectClassName}
>
{Array.from({ length: dayCount }, (_, i) => i + 1).map((day) => {
const disabled =
normalizedMin &&
selectedYear === normalizedMin.getFullYear() &&
selectedMonth === normalizedMin.getMonth() &&
day < normalizedMin.getDate();
return (
<option key={day} value={day} disabled={disabled}>
{day}
</option>
);
})}
</select>
<ChevronDown
className="pointer-events-none absolute right-1.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-text-muted icon-flat"
aria-hidden
/>
</div>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,38 @@
import { Search } from 'lucide-react';
import type { ReactNode } from 'react';
import { Input } from './Input';
interface SearchBarProps {
value: string;
onChange: (value: string) => void;
placeholder: string;
onSubmit?: () => void;
actions?: ReactNode;
}
export function SearchBar({
value,
onChange,
placeholder,
onSubmit,
actions,
}: SearchBarProps) {
return (
<div className="surface-card p-4">
<div className="flex gap-4 items-center">
<div className="flex-1">
<Input
placeholder={placeholder}
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') onSubmit?.();
}}
icon={<Search className="h-4 w-4 icon-flat" />}
/>
</div>
{actions && <div className="flex gap-2">{actions}</div>}
</div>
</div>
);
}

View File

@@ -0,0 +1,93 @@
'use client';
import Link from 'next/link';
import { memo, useMemo } from 'react';
import { usePathname } from 'next/navigation';
import {
LayoutDashboard,
Users,
Calendar,
UserCog,
FlaskConical,
FileText,
CreditCard,
} from 'lucide-react';
import { useAuth } from '@/lib/hooks/useAuth';
import { canAccessAppointmentsSection, canViewTab } from '@/components/shared/permissions';
const menu = [
{ name: 'Today', path: '/today', icon: LayoutDashboard, read: 'TAB_TODAY_READ' as const },
{ name: 'Staff', path: '/staff', icon: UserCog, read: 'TAB_STAFF_READ' as const },
{ name: 'Patients', path: '/patients', icon: Users, read: 'TAB_PATIENTS_READ' as const },
{ name: 'Appointment', path: '/appointments', icon: Calendar, read: 'TAB_APPOINTMENTS_READ' as const },
{ name: 'Treatment', path: '/treatment', icon: FlaskConical, read: 'TAB_TREATMENT_READ' as const },
{ name: 'Billing', path: '/billing', icon: CreditCard, read: 'TAB_BILLING_READ' as const },
{ name: 'Reports', path: '/reports', icon: FileText, read: 'TAB_REPORTS_READ' as const },
];
function Sidebar() {
const pathname = usePathname();
const { currentOrganization } = useAuth();
const counterpartLabel = currentOrganization?.type === 'LAB' ? 'Clinics' : 'Labs';
const visibleMenu = useMemo(
() => {
const withCounterpartTab = [
menu[0],
menu[1],
{
name: counterpartLabel,
path: '/organizations',
icon: FlaskConical,
read: 'TAB_ORGANIZATIONS_READ' as const,
},
menu[2],
menu[3],
menu[4],
menu[5],
menu[6],
];
return withCounterpartTab.filter((item) => {
if (item.path === '/appointments') {
return canAccessAppointmentsSection(currentOrganization);
}
return canViewTab(currentOrganization, item.read);
});
},
[counterpartLabel, currentOrganization],
);
return (
<aside className="w-64 bg-background-secondary/90 border-r border-border text-text-primary flex flex-col">
<div className="h-[71px] px-4 flex items-center">
<h1 className="text-lg font-medium tracking-tight">DyoLink</h1>
</div>
<div className="mx-4 border-b border-border/70" />
<nav className="flex flex-col gap-2 p-4">
{visibleMenu.map((item) => {
const Icon = item.icon;
const isActive = pathname === item.path;
return (
<Link
key={item.name}
href={item.path}
prefetch
className={`flex items-center gap-3 px-3 py-2.5 rounded-[var(--radius-sm)] border transition-colors ${
isActive
? 'bg-primary-soft border-primary/60 text-text-primary'
: 'border-border/50 text-text-secondary hover:bg-background-card/70 hover:text-text-primary hover:border-border'
}`}
>
<Icon className="w-[18px] h-[18px] icon-flat" />
<span className="text-sm">{item.name}</span>
</Link>
);
})}
</nav>
</aside>
);
}
export default memo(Sidebar);

View File

@@ -0,0 +1,27 @@
import type { ReactNode } from 'react';
interface TableProps {
headers: ReactNode;
body: ReactNode;
footer?: ReactNode;
}
export function Table({ headers, body, footer }: TableProps) {
return (
<div className="surface-card overflow-hidden">
<table className="w-full">
<thead className="bg-background-secondary/70 border-b border-border">
{headers}
</thead>
<tbody className="divide-y divide-border/60">
{body}
</tbody>
</table>
{footer && (
<div className="px-6 py-3 border-t border-border/60 flex justify-between items-center bg-background-secondary/70">
{footer}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,47 @@
'use client';
import { useEffect, useState } from 'react';
import { Moon, Sun } from 'lucide-react';
import { applyTheme, getStoredTheme, type ThemeMode } from '@/lib/theme';
export function ThemeToggle() {
const [mode, setMode] = useState<ThemeMode | null>(null);
useEffect(() => {
setMode(getStoredTheme());
}, []);
const handleClick = () => {
const current = getStoredTheme();
const next: ThemeMode = current === 'dark' ? 'light' : 'dark';
applyTheme(next);
setMode(next);
};
if (mode === null) {
return (
<span
className="inline-flex h-9 w-9 shrink-0 rounded-[var(--radius-md)] border border-border/60 bg-background-secondary/80"
aria-hidden
/>
);
}
const isDark = mode === 'dark';
return (
<button
type="button"
onClick={handleClick}
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-[var(--radius-md)] border border-border/60 bg-background-secondary/80 text-text-primary hover:border-border-strong hover:bg-background-card/80 transition-colors"
aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
title={isDark ? 'Light mode' : 'Dark mode'}
>
{isDark ? (
<Sun className="h-[18px] w-[18px] icon-flat" />
) : (
<Moon className="h-[18px] w-[18px] icon-flat" />
)}
</button>
);
}

View File

@@ -0,0 +1,93 @@
import type { ReactNode } from 'react';
import type { BadgeVariant } from '@/components/ui/shared/Badge';
interface ToastProps {
children: ReactNode;
variant?: BadgeVariant;
className?: string;
}
const variantStyles: Record<BadgeVariant, string> = {
success: 'bg-badge-success-bg text-badge-success-fg border-badge-success-border',
warning: 'bg-badge-warning-bg text-badge-warning-fg border-badge-warning-border',
danger: 'bg-badge-danger-bg text-badge-danger-fg border-badge-danger-border',
default: 'bg-badge-default-bg text-badge-default-fg border-badge-default-border',
};
export function Toast({ children, variant = 'default', className = '' }: ToastProps) {
return (
<div
role="status"
className={`w-full rounded-[var(--radius-md)] border px-4 py-3 text-sm shadow-lg ${variantStyles[variant]} ${className}`}
>
{children}
</div>
);
}
export type ToastMessages = {
error?: string;
success?: string;
info?: string;
default?: string;
};
export type ToastStackProps = ToastMessages & {
className?: string;
};
function hasToastMessages(messages: ToastMessages): boolean {
return Boolean(messages.error || messages.success || messages.info || messages.default);
}
/** Renders active toast messages with shared badge colors (success / warning / danger / default). */
export function ToastStack({ error, success, info, default: defaultMessage, className = '' }: ToastStackProps) {
if (!hasToastMessages({ error, success, info, default: defaultMessage })) {
return null;
}
return (
<div className={`space-y-2 ${className}`.trim()} aria-live="polite">
{error && <Toast variant="danger">{error}</Toast>}
{info && <Toast variant="warning">{info}</Toast>}
{success && <Toast variant="success">{success}</Toast>}
{defaultMessage && <Toast variant="default">{defaultMessage}</Toast>}
</div>
);
}
export type ToastViewportPosition = 'inline' | 'top' | 'bottom';
export type ToastViewportProps = ToastStackProps & {
position?: ToastViewportPosition;
};
const viewportPositionClass: Record<Exclude<ToastViewportPosition, 'inline'>, string> = {
top: 'fixed top-4 left-0 right-0 z-[70] px-4 pointer-events-none',
bottom: 'fixed bottom-4 left-0 right-0 z-[70] px-4 pointer-events-none',
};
/**
* Positions a ToastStack on the page. Use `inline` below a heading; `bottom` / `top` for overlays.
*/
export function ToastViewport({
position = 'inline',
className = '',
...messages
}: ToastViewportProps) {
if (!hasToastMessages(messages)) {
return null;
}
const stack = <ToastStack {...messages} className={className} />;
if (position === 'inline') {
return stack;
}
return (
<div className={viewportPositionClass[position]}>
<div className="pointer-events-auto w-full">{stack}</div>
</div>
);
}