IKHSAN
EN Read in EnglishID

homeio

Publik

Fork dari doctor-io/homeio

Branch
main
Commit terakhir
958d74616 Oktober 2026

controls.tsx

Buka raw
"use client";

import {
  AlertTriangle,
  Check,
  ChevronRight,
  Copy,
  Download,
  Eye,
  EyeOff,
  Info,
} from "@/components/icons/platform-icons";
import { SETTINGS_BADGE_SURFACE } from "@/modules/settings/components/panel/surface";
import type { ControlAvailability } from "@/modules/settings/components/panel/types";
import {
  createContext,
  useContext,
  useState,
  type ChangeEvent,
  type ReactNode,
} from "react";

const ControlAvailabilityContext = createContext<ControlAvailability>({
  disabled: false,
});

export function ControlAvailabilityProvider({
  value,
  children,
}: {
  value: ControlAvailability;
  children: ReactNode;
}) {
  return (
    <ControlAvailabilityContext.Provider value={value}>
      {children}
    </ControlAvailabilityContext.Provider>
  );
}

export function useControlAvailability(
  overrideDisabled?: boolean,
  overrideReason?: string,
) {
  const context = useContext(ControlAvailabilityContext);
  const disabled =
    overrideDisabled !== undefined
      ? overrideDisabled
      : Boolean(context.disabled);

  const disabledReason =
    overrideReason !== undefined
      ? overrideReason
      : disabled
        ? context.disabledReason
        : undefined;

  return {
    disabled,
    disabledReason,
  };
}

export function ControlDisabledHint({ text }: { text?: string }) {
  if (!text) return null;
  return <span className="text-xs text-status-amber">{text}</span>;
}

export function Toggle({
  enabled,
  onToggle,
  label,
  description,
  disabled,
  disabledReason,
}: {
  enabled: boolean;
  onToggle: () => void;
  label: string;
  description?: string;
  disabled?: boolean;
  disabledReason?: string;
}) {
  const availability = useControlAvailability(disabled, disabledReason);

  return (
    <div className="flex items-center justify-between py-3">
      <div className="flex flex-col gap-0.5">
        <span className="text-sm text-foreground">{label}</span>
        {description ? (
          <span className="text-xs text-muted-foreground">{description}</span>
        ) : null}
        <ControlDisabledHint text={availability.disabledReason} />
      </div>
      <button
        type="button"
        role="switch"
        aria-checked={enabled}
        aria-label={`Toggle ${label}`}
        onClick={availability.disabled ? undefined : onToggle}
        disabled={availability.disabled}
        className={`relative inline-flex h-5 w-10 shrink-0 items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 ${
          availability.disabled
            ? "cursor-not-allowed opacity-50"
            : "cursor-pointer"
        } ${enabled ? "bg-primary" : "bg-muted-foreground/30"}`}
      >
        <span
          className={`pointer-events-none block size-5 rounded-full bg-white shadow-sm transition-transform ${
            enabled ? "translate-x-5" : "translate-x-0"
          }`}
        />
      </button>
    </div>
  );
}

export function SettingsInput({
  label,
  value,
  placeholder,
  type = "text",
  onChange,
  readOnly,
  copyable,
  description,
  disabled,
  disabledReason,
}: {
  label: string;
  value: string;
  placeholder?: string;
  type?: string;
  onChange?: (value: string) => void;
  readOnly?: boolean;
  copyable?: boolean;
  description?: string;
  disabled?: boolean;
  disabledReason?: string;
}) {
  const [showPassword, setShowPassword] = useState(false);
  const [copied, setCopied] = useState(false);
  const isPassword = type === "password";
  const availability = useControlAvailability(disabled, disabledReason);
  const isReadOnly = readOnly || availability.disabled || !onChange;

  return (
    <div className="flex flex-col gap-1.5 py-2">
      <label className="text-xs font-medium text-muted-foreground">
        {label}
      </label>
      {description ? (
        <span className="text-xs text-muted-foreground/70">{description}</span>
      ) : null}
      <ControlDisabledHint text={availability.disabledReason} />
      <div className="flex items-center gap-2">
        <div className="relative flex-1">
          <input
            type={isPassword && !showPassword ? "password" : "text"}
            value={value}
            placeholder={placeholder}
            readOnly={isReadOnly}
            disabled={availability.disabled}
            onChange={(event) => {
              if (!onChange || isReadOnly || availability.disabled) return;
              onChange(event.target.value);
            }}
            className="h-8 w-full rounded-lg border border-glass-border bg-background/55 px-3 text-xs text-foreground placeholder:text-muted-foreground/60 transition-all focus:border-primary/40 focus:outline-none read-only:opacity-60 disabled:cursor-not-allowed disabled:opacity-50"
          />
          {isPassword ? (
            <button
              onClick={() => setShowPassword(!showPassword)}
              className={`absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground ${
                availability.disabled
                  ? "cursor-not-allowed opacity-50"
                  : "cursor-pointer hover:text-foreground"
              }`}
              disabled={availability.disabled}
            >
              {showPassword ? (
                <EyeOff className="size-3.5" />
              ) : (
                <Eye className="size-3.5" />
              )}
            </button>
          ) : null}
        </div>
        {copyable ? (
          <button
            onClick={() => {
              navigator.clipboard.writeText(value);
              setCopied(true);
              setTimeout(() => setCopied(false), 2000);
            }}
            className={`p-1.5 rounded-lg text-muted-foreground transition-colors ${
              availability.disabled
                ? "cursor-not-allowed opacity-50"
                : "cursor-pointer hover:bg-secondary/50 hover:text-foreground"
            }`}
            disabled={availability.disabled}
          >
            {copied ? (
              <Check className="size-3.5 text-status-green" />
            ) : (
              <Copy className="size-3.5" />
            )}
          </button>
        ) : null}
      </div>
    </div>
  );
}

export function SettingsSelect({
  label,
  value,
  options,
  description,
  onChange,
  disabled,
  disabledReason,
}: {
  label: string;
  value: string;
  options: readonly string[];
  description?: string;
  onChange?: (value: string) => void;
  disabled?: boolean;
  disabledReason?: string;
}) {
  const availability = useControlAvailability(disabled, disabledReason);

  return (
    <div className="flex flex-col gap-1.5 py-2">
      <label className="text-xs font-medium text-muted-foreground">
        {label}
      </label>
      {description ? (
        <span className="text-xs text-muted-foreground/70">{description}</span>
      ) : null}
      <ControlDisabledHint text={availability.disabledReason} />
      <select
        {...(onChange
          ? {
              value,
              onChange: (event: ChangeEvent<HTMLSelectElement>) =>
                onChange(event.target.value),
            }
          : { value })}
        disabled={availability.disabled}
        className="h-8 cursor-pointer appearance-none rounded-lg border border-glass-border bg-background/55 px-3 text-xs text-foreground transition-all focus:border-primary/40 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
      >
        {options.map((option) => (
          <option key={option} value={option}>
            {option}
          </option>
        ))}
      </select>
    </div>
  );
}

export function SectionDivider({ title }: { title: string }) {
  return (
    <div className="flex items-center gap-3 pt-5 pb-2">
      <span className="text-xs font-semibold text-muted-foreground uppercase tracking-widest">
        {title}
      </span>
      <div className="flex-1 h-px bg-glass-border" />
    </div>
  );
}

export function InfoBanner({
  text,
  variant = "info",
}: {
  text: string;
  variant?: "info" | "warning";
}) {
  return (
    <div
      className={`flex items-start gap-2.5 rounded-xl p-3 text-xs ${
        variant === "warning"
          ? "bg-status-amber/10 border border-status-amber/20 text-status-amber"
          : "bg-primary/8 border border-primary/15 text-primary"
      }`}
    >
      {variant === "warning" ? (
        <AlertTriangle className="size-4 shrink-0 mt-0.5" />
      ) : (
        <Info className="size-4 shrink-0 mt-0.5" />
      )}
      <span className="leading-relaxed">{text}</span>
    </div>
  );
}

export function StorageBar({
  label,
  detail,
  pct,
  color,
}: {
  label: string;
  detail: string;
  pct: number;
  color: string;
}) {
  const safePct = Number.isFinite(pct) ? Math.max(0, Math.min(pct, 100)) : 0;

  return (
    <div className="py-2">
      <div className="flex items-center justify-between mb-1.5">
        <span className="text-xs text-foreground">{label}</span>
        <span className="text-xs text-muted-foreground">{detail}</span>
      </div>
      <div className="h-2 overflow-hidden rounded-[var(--radius)] bg-background/65">
        <div
          className="h-full rounded-[var(--radius)] transition-all"
          style={{ width: `${safePct}%`, backgroundColor: color }}
        />
      </div>
    </div>
  );
}

export function formatStorageSize(bytes: number | null | undefined) {
  if (
    bytes === null ||
    bytes === undefined ||
    !Number.isFinite(bytes) ||
    bytes < 0
  ) {
    return "--";
  }

  if (bytes < 1024 ** 2) {
    return `${Math.round(bytes / 1024)} KB`;
  }

  if (bytes < 1024 ** 3) {
    return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
  }

  if (bytes < 1024 ** 4) {
    return `${(bytes / 1024 ** 3).toFixed(1)} GB`;
  }

  return `${(bytes / 1024 ** 4).toFixed(1)} TB`;
}

export function UserRow({
  name,
  role,
  email,
  lastActive,
  avatarColor,
}: {
  name: string;
  role: string;
  email: string;
  lastActive: string;
  avatarColor: string;
}) {
  return (
    <div className="flex items-center gap-3 py-2.5 group">
      <div
        className="size-8 rounded-[var(--radius)] flex items-center justify-center text-xs font-bold text-foreground shrink-0"
        style={{ backgroundColor: avatarColor }}
      >
        {name.charAt(0).toUpperCase()}
      </div>
      <div className="flex-1 min-w-0">
        <div className="flex items-center gap-2">
          <span className="text-sm text-foreground">{name}</span>
          <span
            className={`${SETTINGS_BADGE_SURFACE} px-1.5 py-0.5 text-xs font-medium ${
              role === "Admin"
                ? "bg-primary/15 text-primary"
                : role === "Editor"
                  ? "bg-status-amber/15 text-status-amber"
                  : "bg-secondary text-muted-foreground"
            }`}
          >
            {role}
          </span>
        </div>
        <span className="text-xs text-muted-foreground">{email}</span>
      </div>
      <span className="text-xs text-muted-foreground hidden sm:block">
        {lastActive}
      </span>
      <button className="opacity-0 group-hover:opacity-100 p-1 rounded-lg hover:bg-secondary/50 transition-all text-muted-foreground cursor-pointer">
        <ChevronRight className="size-3.5" />
      </button>
    </div>
  );
}

export function BackupRow({
  date,
  size,
  type,
  status,
  actionLabel,
  onAction,
  actionDisabled,
}: {
  date: string;
  size: string;
  type: string;
  status: "completed" | "failed" | "in-progress";
  actionLabel?: string;
  onAction?: () => void;
  actionDisabled?: boolean;
}) {
  return (
    <div className="flex items-center gap-3 py-2.5">
      <div
        className={`size-2 rounded-[var(--radius)] shrink-0 ${
          status === "completed"
            ? "bg-status-green"
            : status === "in-progress"
              ? "bg-status-amber animate-pulse"
              : "bg-status-red"
        }`}
      />
      <div className="flex-1 min-w-0">
        <span className="text-sm text-foreground">{type}</span>
        <div className="text-xs text-muted-foreground">
          {date} - {size}
        </div>
      </div>
      {actionLabel && onAction ? (
        <button
          onClick={onAction}
          className="cursor-pointer rounded-lg bg-background/55 px-3 py-1.5 text-xs font-medium text-muted-foreground transition-colors disabled:cursor-not-allowed disabled:opacity-50 enabled:hover:bg-background/75 enabled:hover:text-foreground"
          disabled={actionDisabled}
        >
          {actionLabel}
        </button>
      ) : (
        <button className="cursor-pointer rounded-lg p-1.5 text-muted-foreground transition-colors hover:bg-background/75 hover:text-foreground">
          <Download className="size-3.5" />
        </button>
      )}
    </div>
  );
}
byte
12.872
baris
451
Jenis
tsx
Commit terakhir
16 Oktober 2026