1823 lines
60 KiB
TypeScript
1823 lines
60 KiB
TypeScript
"use client";
|
||
|
||
import Link from "next/link";
|
||
import { FormEvent, useCallback, useEffect, useMemo, useState } from "react";
|
||
import { Nav, useAuth } from "@/components/Nav";
|
||
|
||
type Device = {
|
||
id: string;
|
||
name: string;
|
||
platform: string;
|
||
lastSeenAt: string | null;
|
||
createdAt: string;
|
||
};
|
||
|
||
type GooglePlaySyncStatus =
|
||
| "NOT_ENABLED"
|
||
| "PENDING_ADD"
|
||
| "ACTIVE"
|
||
| "PENDING_REMOVE"
|
||
| "REMOVED"
|
||
| "ERROR";
|
||
|
||
type ViewerIptv = {
|
||
id: string;
|
||
label: string | null;
|
||
baseUrl: string;
|
||
username: string;
|
||
hasPassword: boolean;
|
||
enableLive: boolean;
|
||
enableVod: boolean;
|
||
enableSeries: boolean;
|
||
lastOkAt: string | null;
|
||
lastError: string | null;
|
||
ok: boolean;
|
||
};
|
||
|
||
type AddonTokenRow = {
|
||
id: string;
|
||
label: string | null;
|
||
revoked: boolean;
|
||
createdAt: string;
|
||
hasOwnIptv: boolean;
|
||
};
|
||
|
||
type Viewer = {
|
||
id: string;
|
||
email: string;
|
||
name: string | null;
|
||
enabled: boolean;
|
||
appAccess: boolean;
|
||
iptvAccess: boolean;
|
||
iptvAddonTokenId: string | null;
|
||
googlePlayEmail: string | null;
|
||
googlePlayAccess: boolean;
|
||
googlePlayMembershipId: string | null;
|
||
googlePlaySyncStatus: GooglePlaySyncStatus;
|
||
googlePlayLastSyncedAt: string | null;
|
||
googlePlaySyncError: string | null;
|
||
googlePlayResolvedEmail: string;
|
||
createdAt: string;
|
||
deviceCount: number;
|
||
devices: Device[];
|
||
iptv?: ViewerIptv | null;
|
||
addonTokens?: AddonTokenRow[];
|
||
loginFailedCount?: number;
|
||
loginLocked?: boolean;
|
||
loginLockedUntil?: string | null;
|
||
};
|
||
|
||
type IptvFormState = {
|
||
label: string;
|
||
baseUrl: string;
|
||
username: string;
|
||
password: string;
|
||
enableLive: boolean;
|
||
enableVod: boolean;
|
||
enableSeries: boolean;
|
||
};
|
||
|
||
type PlayConfig = {
|
||
configured: boolean;
|
||
groupEmail: string | null;
|
||
groupId: string | null;
|
||
optInUrl: string | null;
|
||
};
|
||
|
||
const MASTER_BASE = "https://master.vonas.nl";
|
||
|
||
const SYNC_LABELS: Record<GooglePlaySyncStatus, string> = {
|
||
NOT_ENABLED: "Uit",
|
||
PENDING_ADD: "Bezig…",
|
||
ACTIVE: "Actief",
|
||
PENDING_REMOVE: "Bezig…",
|
||
REMOVED: "Uit",
|
||
ERROR: "Fout",
|
||
};
|
||
|
||
function formatWhen(iso: string | null) {
|
||
if (!iso) return null;
|
||
return new Date(iso).toLocaleString("nl-NL", {
|
||
day: "numeric",
|
||
month: "short",
|
||
year: "numeric",
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
});
|
||
}
|
||
|
||
function emptyIptvForm(): IptvFormState {
|
||
return {
|
||
label: "",
|
||
baseUrl: "",
|
||
username: "",
|
||
password: "",
|
||
enableLive: true,
|
||
enableVod: true,
|
||
enableSeries: true,
|
||
};
|
||
}
|
||
|
||
function formFromIptv(iptv: ViewerIptv | null | undefined): IptvFormState {
|
||
if (!iptv) return emptyIptvForm();
|
||
return {
|
||
label: iptv.label ?? "",
|
||
baseUrl: iptv.baseUrl ?? "",
|
||
username: iptv.username ?? "",
|
||
password: "",
|
||
enableLive: iptv.enableLive ?? true,
|
||
enableVod: iptv.enableVod ?? true,
|
||
enableSeries: iptv.enableSeries ?? true,
|
||
};
|
||
}
|
||
|
||
function iptvBadge(iptv: ViewerIptv | null | undefined): { label: string; tone: "ok" | "bad" | "off" } {
|
||
if (!iptv) return { label: "IPTV geen", tone: "off" };
|
||
if (iptv.ok) return { label: "IPTV OK", tone: "ok" };
|
||
return { label: "IPTV fout", tone: "bad" };
|
||
}
|
||
|
||
function playTone(v: Viewer): "ok" | "warn" | "bad" | "off" {
|
||
if (v.googlePlaySyncStatus === "ERROR") return "bad";
|
||
if (v.googlePlayAccess && v.googlePlaySyncStatus === "ACTIVE") return "ok";
|
||
if (v.googlePlayAccess) return "warn";
|
||
return "off";
|
||
}
|
||
|
||
export default function ViewersPage() {
|
||
useAuth();
|
||
const [viewers, setViewers] = useState<Viewer[]>([]);
|
||
const [playConfig, setPlayConfig] = useState<PlayConfig | null>(null);
|
||
const [email, setEmail] = useState("");
|
||
const [password, setPassword] = useState("");
|
||
const [name, setName] = useState("");
|
||
const [busy, setBusy] = useState(false);
|
||
const [bulkBusy, setBulkBusy] = useState(false);
|
||
const [msg, setMsg] = useState<string | null>(null);
|
||
const [err, setErr] = useState<string | null>(null);
|
||
const [playEmails, setPlayEmails] = useState<Record<string, string>>({});
|
||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||
const [mobileShowDetail, setMobileShowDetail] = useState(false);
|
||
const [query, setQuery] = useState("");
|
||
const [showCreate, setShowCreate] = useState(false);
|
||
const [actionId, setActionId] = useState<string | null>(null);
|
||
const [iptvForms, setIptvForms] = useState<Record<string, IptvFormState>>({});
|
||
const [iptvLocalMsg, setIptvLocalMsg] = useState<Record<string, string | null>>({});
|
||
const [iptvLocalErr, setIptvLocalErr] = useState<Record<string, string | null>>({});
|
||
const [addonInstallUrls, setAddonInstallUrls] = useState<Record<string, string | null>>({});
|
||
const [claimCodes, setClaimCodes] = useState<Record<string, string>>({});
|
||
|
||
const load = useCallback(() => {
|
||
fetch("/api/v1/admin/viewers", { credentials: "include" })
|
||
.then((r) => r.json())
|
||
.then((d) => {
|
||
const list: Viewer[] = d.viewers ?? [];
|
||
setViewers(list);
|
||
setPlayEmails((prev) => {
|
||
const next = { ...prev };
|
||
for (const v of list) {
|
||
if (next[v.id] === undefined) next[v.id] = v.googlePlayEmail ?? "";
|
||
}
|
||
return next;
|
||
});
|
||
})
|
||
.catch(() => setErr("Kon gebruikers niet laden"));
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
load();
|
||
fetch("/api/v1/admin/google-play-access/config", { credentials: "include" })
|
||
.then((r) => r.json())
|
||
.then((d) => setPlayConfig(d.config ?? null))
|
||
.catch(() => undefined);
|
||
}, [load]);
|
||
|
||
const stats = useMemo(() => {
|
||
const active = viewers.filter((v) => v.enabled).length;
|
||
const appOn = viewers.filter((v) => v.appAccess).length;
|
||
const playOn = viewers.filter((v) => v.googlePlayAccess).length;
|
||
const playErr = viewers.filter((v) => v.googlePlaySyncStatus === "ERROR").length;
|
||
return { total: viewers.length, active, appOn, playOn, playErr };
|
||
}, [viewers]);
|
||
|
||
const filtered = useMemo(() => {
|
||
const q = query.trim().toLowerCase();
|
||
if (!q) return viewers;
|
||
return viewers.filter((v) => {
|
||
const hay = `${v.name ?? ""} ${v.email}`.toLowerCase();
|
||
return hay.includes(q);
|
||
});
|
||
}, [viewers, query]);
|
||
|
||
const selected = useMemo(
|
||
() => viewers.find((v) => v.id === selectedId) ?? null,
|
||
[viewers, selectedId]
|
||
);
|
||
|
||
useEffect(() => {
|
||
if (viewers.length === 0) {
|
||
setSelectedId(null);
|
||
return;
|
||
}
|
||
if (selectedId && viewers.some((v) => v.id === selectedId)) return;
|
||
const first = viewers[0];
|
||
setSelectedId(first.id);
|
||
setIptvForms((prev) =>
|
||
prev[first.id] ? prev : { ...prev, [first.id]: formFromIptv(first.iptv) }
|
||
);
|
||
}, [viewers, selectedId]);
|
||
|
||
function selectViewer(v: Viewer) {
|
||
setSelectedId(v.id);
|
||
setMobileShowDetail(true);
|
||
setIptvForms((prev) => ({ ...prev, [v.id]: formFromIptv(v.iptv) }));
|
||
setIptvLocalMsg((prev) => ({ ...prev, [v.id]: null }));
|
||
setIptvLocalErr((prev) => ({ ...prev, [v.id]: null }));
|
||
}
|
||
|
||
function patchIptvForm(id: string, patch: Partial<IptvFormState>) {
|
||
setIptvForms((prev) => ({
|
||
...prev,
|
||
[id]: { ...(prev[id] ?? emptyIptvForm()), ...patch },
|
||
}));
|
||
}
|
||
|
||
async function createViewer(e: FormEvent) {
|
||
e.preventDefault();
|
||
setBusy(true);
|
||
setMsg(null);
|
||
setErr(null);
|
||
try {
|
||
const res = await fetch("/api/v1/admin/viewers", {
|
||
method: "POST",
|
||
credentials: "include",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ email, password, name: name || undefined, appAccess: true }),
|
||
});
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok) {
|
||
setErr(data.error?.message ?? "Aanmaken mislukt");
|
||
return;
|
||
}
|
||
setMsg(`${data.viewer.email} aangemaakt`);
|
||
setEmail("");
|
||
setPassword("");
|
||
setName("");
|
||
setShowCreate(false);
|
||
setSelectedId(data.viewer.id);
|
||
setMobileShowDetail(true);
|
||
setIptvForms((prev) => ({ ...prev, [data.viewer.id]: emptyIptvForm() }));
|
||
load();
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
async function toggle(v: Viewer, field: "enabled" | "appAccess" | "iptvAccess") {
|
||
setActionId(v.id);
|
||
setErr(null);
|
||
try {
|
||
const res = await fetch(`/api/v1/admin/viewers/${v.id}`, {
|
||
method: "PATCH",
|
||
credentials: "include",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ [field]: !v[field] }),
|
||
});
|
||
if (!res.ok) {
|
||
const data = await res.json().catch(() => ({}));
|
||
setErr(data.error?.message ?? "Bijwerken mislukt");
|
||
}
|
||
load();
|
||
} finally {
|
||
setActionId(null);
|
||
}
|
||
}
|
||
|
||
async function saveIptv(v: Viewer) {
|
||
const form = iptvForms[v.id] ?? emptyIptvForm();
|
||
setActionId(v.id);
|
||
setIptvLocalMsg((prev) => ({ ...prev, [v.id]: null }));
|
||
setIptvLocalErr((prev) => ({ ...prev, [v.id]: null }));
|
||
setMsg(null);
|
||
setErr(null);
|
||
try {
|
||
const res = await fetch(`/api/v1/admin/viewers/${v.id}/iptv`, {
|
||
method: "PUT",
|
||
credentials: "include",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
label: form.label.trim() || undefined,
|
||
baseUrl: form.baseUrl.trim(),
|
||
username: form.username.trim(),
|
||
password: form.password.trim() || undefined,
|
||
enableLive: form.enableLive,
|
||
enableVod: form.enableVod,
|
||
enableSeries: form.enableSeries,
|
||
}),
|
||
});
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok) {
|
||
const message = data.error?.message ?? "IPTV opslaan mislukt";
|
||
setIptvLocalErr((prev) => ({ ...prev, [v.id]: message }));
|
||
setErr(message);
|
||
return;
|
||
}
|
||
setIptvLocalMsg((prev) => ({ ...prev, [v.id]: "IPTV-lijn opgeslagen" }));
|
||
setMsg("IPTV-lijn opgeslagen");
|
||
patchIptvForm(v.id, { password: "" });
|
||
load();
|
||
} finally {
|
||
setActionId(null);
|
||
}
|
||
}
|
||
|
||
async function testIptv(v: Viewer) {
|
||
setActionId(v.id);
|
||
setIptvLocalMsg((prev) => ({ ...prev, [v.id]: null }));
|
||
setIptvLocalErr((prev) => ({ ...prev, [v.id]: null }));
|
||
setMsg(null);
|
||
setErr(null);
|
||
try {
|
||
const res = await fetch(`/api/v1/admin/viewers/${v.id}/iptv/test`, {
|
||
method: "POST",
|
||
credentials: "include",
|
||
});
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok) {
|
||
const message = data.error?.message ?? "IPTV-test mislukt";
|
||
setIptvLocalErr((prev) => ({ ...prev, [v.id]: message }));
|
||
setErr(message);
|
||
load();
|
||
return;
|
||
}
|
||
const summary = `OK · live ${data.liveCategories ?? 0} · vod ${data.vodCategories ?? 0} · series ${data.seriesCategories ?? 0}`;
|
||
setIptvLocalMsg((prev) => ({ ...prev, [v.id]: summary }));
|
||
setMsg("IPTV-test gelukt");
|
||
load();
|
||
} finally {
|
||
setActionId(null);
|
||
}
|
||
}
|
||
|
||
async function deleteIptv(v: Viewer) {
|
||
if (!confirm("IPTV-lijn ontkoppelen voor deze gebruiker?")) return;
|
||
setActionId(v.id);
|
||
setIptvLocalMsg((prev) => ({ ...prev, [v.id]: null }));
|
||
setIptvLocalErr((prev) => ({ ...prev, [v.id]: null }));
|
||
setMsg(null);
|
||
setErr(null);
|
||
try {
|
||
const res = await fetch(`/api/v1/admin/viewers/${v.id}/iptv`, {
|
||
method: "DELETE",
|
||
credentials: "include",
|
||
});
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok) {
|
||
const message = data.error?.message ?? "Ontkoppelen mislukt";
|
||
setIptvLocalErr((prev) => ({ ...prev, [v.id]: message }));
|
||
setErr(message);
|
||
return;
|
||
}
|
||
setIptvForms((prev) => ({ ...prev, [v.id]: emptyIptvForm() }));
|
||
setIptvLocalMsg((prev) => ({ ...prev, [v.id]: "IPTV-lijn ontkoppeld" }));
|
||
setMsg("IPTV-lijn ontkoppeld");
|
||
load();
|
||
} finally {
|
||
setActionId(null);
|
||
}
|
||
}
|
||
|
||
async function createAddonToken(v: Viewer) {
|
||
setActionId(v.id);
|
||
setMsg(null);
|
||
setErr(null);
|
||
try {
|
||
const res = await fetch(`/api/v1/admin/viewers/${v.id}/addon-tokens`, {
|
||
method: "POST",
|
||
credentials: "include",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ label: v.name || v.email }),
|
||
});
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok) {
|
||
setErr(data.error?.message ?? "Addon aanmaken mislukt");
|
||
return;
|
||
}
|
||
const installPath = typeof data.installUrl === "string" ? data.installUrl : "";
|
||
const fullUrl = installPath.startsWith("http")
|
||
? installPath
|
||
: `${MASTER_BASE}${installPath.startsWith("/") ? "" : "/"}${installPath}`;
|
||
setAddonInstallUrls((prev) => ({ ...prev, [v.id]: fullUrl }));
|
||
setMsg("Stremio-addon aangemaakt — kopieer de installatie-URL (eenmalig)");
|
||
load();
|
||
} finally {
|
||
setActionId(null);
|
||
}
|
||
}
|
||
|
||
async function copyText(text: string) {
|
||
try {
|
||
await navigator.clipboard.writeText(text);
|
||
setMsg("Gekopieerd naar klembord");
|
||
} catch {
|
||
setErr("Kopiëren mislukt");
|
||
}
|
||
}
|
||
|
||
async function setPlayAccess(v: Viewer, enabled: boolean) {
|
||
setActionId(v.id);
|
||
setMsg(null);
|
||
setErr(null);
|
||
try {
|
||
const res = await fetch(`/api/v1/admin/viewers/${v.id}/google-play-access`, {
|
||
method: "PATCH",
|
||
credentials: "include",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
enabled,
|
||
email: playEmails[v.id]?.trim() || null,
|
||
}),
|
||
});
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok) {
|
||
setErr(data.error?.message ?? "Google Play bijwerken mislukt");
|
||
} else {
|
||
setMsg(
|
||
enabled
|
||
? data.syncError
|
||
? "Instelling opgeslagen — synchronisatie mislukt, probeer opnieuw"
|
||
: "Google Play-toegang ingeschakeld"
|
||
: data.syncError
|
||
? "Instelling opgeslagen — synchronisatie mislukt, probeer opnieuw"
|
||
: "Google Play-toegang uitgeschakeld"
|
||
);
|
||
if (data.syncError) setErr(data.syncError);
|
||
}
|
||
load();
|
||
} finally {
|
||
setActionId(null);
|
||
}
|
||
}
|
||
|
||
async function savePlayEmail(v: Viewer) {
|
||
setActionId(v.id);
|
||
setMsg(null);
|
||
setErr(null);
|
||
try {
|
||
const res = await fetch(`/api/v1/admin/viewers/${v.id}/google-play-access`, {
|
||
method: "PATCH",
|
||
credentials: "include",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
enabled: v.googlePlayAccess,
|
||
email: playEmails[v.id]?.trim() || null,
|
||
}),
|
||
});
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok) {
|
||
setErr(data.error?.message ?? "E-mail opslaan mislukt");
|
||
} else {
|
||
setMsg("Google Play-e-mail opgeslagen");
|
||
if (data.syncError) setErr(data.syncError);
|
||
}
|
||
load();
|
||
} finally {
|
||
setActionId(null);
|
||
}
|
||
}
|
||
|
||
async function retryPlaySync(v: Viewer) {
|
||
setActionId(v.id);
|
||
setMsg(null);
|
||
setErr(null);
|
||
try {
|
||
const res = await fetch(`/api/v1/admin/viewers/${v.id}/google-play-access/retry`, {
|
||
method: "POST",
|
||
credentials: "include",
|
||
});
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok) {
|
||
setErr(data.error?.message ?? "Synchronisatie mislukt");
|
||
} else {
|
||
setMsg(data.syncError ? "Synchronisatie mislukt" : "Synchronisatie gelukt");
|
||
if (data.syncError) setErr(data.syncError);
|
||
}
|
||
load();
|
||
} finally {
|
||
setActionId(null);
|
||
}
|
||
}
|
||
|
||
async function bulkAction(path: "sync-all" | "reconcile") {
|
||
setBulkBusy(true);
|
||
setMsg(null);
|
||
setErr(null);
|
||
try {
|
||
const res = await fetch(`/api/v1/admin/google-play-access/${path}`, {
|
||
method: "POST",
|
||
credentials: "include",
|
||
});
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok) {
|
||
setErr(data.error?.message ?? "Actie mislukt");
|
||
return;
|
||
}
|
||
const s = data.summary;
|
||
setMsg(
|
||
`${path === "sync-all" ? "Sync" : "Reconcile"}: ${s.scanned} gecontroleerd · ${s.correct} ok · ${s.added} toegevoegd · ${s.removed} verwijderd · ${s.errors} fouten`
|
||
);
|
||
load();
|
||
} finally {
|
||
setBulkBusy(false);
|
||
}
|
||
}
|
||
|
||
async function revokeDevice(deviceId: string) {
|
||
if (!confirm("Apparaat loskoppelen? De app moet opnieuw worden gekoppeld.")) return;
|
||
setActionId(deviceId);
|
||
setErr(null);
|
||
try {
|
||
const res = await fetch(`/api/v1/admin/viewers/devices/${deviceId}/revoke`, {
|
||
method: "POST",
|
||
credentials: "include",
|
||
});
|
||
if (!res.ok) {
|
||
const data = await res.json().catch(() => ({}));
|
||
setErr(data.error?.message ?? "Apparaat loskoppelen mislukt");
|
||
return;
|
||
}
|
||
setMsg("Apparaat losgekoppeld");
|
||
load();
|
||
} finally {
|
||
setActionId(null);
|
||
}
|
||
}
|
||
|
||
async function claimDeviceCode(viewerId: string) {
|
||
const raw = (claimCodes[viewerId] ?? "").trim();
|
||
if (!raw) {
|
||
setErr("Voer de koppelcode van het apparaat in");
|
||
return;
|
||
}
|
||
setActionId(`claim-${viewerId}`);
|
||
setErr(null);
|
||
try {
|
||
const res = await fetch(`/api/v1/admin/viewers/${viewerId}/devices/claim`, {
|
||
method: "POST",
|
||
credentials: "include",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ code: raw }),
|
||
});
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok) {
|
||
setErr(data.error?.message ?? "Code koppelen mislukt");
|
||
return;
|
||
}
|
||
const deviceName = data.deviceName ? ` (${data.deviceName})` : "";
|
||
setMsg(`Apparaat gekoppeld${deviceName} — de app/TV rondt zelf af`);
|
||
setClaimCodes((prev) => ({ ...prev, [viewerId]: "" }));
|
||
load();
|
||
} finally {
|
||
setActionId(null);
|
||
}
|
||
}
|
||
|
||
async function unlockLogin(v: Viewer) {
|
||
setActionId(`unlock-${v.id}`);
|
||
setErr(null);
|
||
try {
|
||
const res = await fetch(`/api/v1/admin/viewers/${v.id}/unlock-login`, {
|
||
method: "POST",
|
||
credentials: "include",
|
||
});
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok) {
|
||
setErr(data.error?.message ?? "Ontgrendelen mislukt");
|
||
return;
|
||
}
|
||
setMsg(`Login ontgrendeld voor ${v.email}`);
|
||
load();
|
||
} finally {
|
||
setActionId(null);
|
||
}
|
||
}
|
||
|
||
async function deleteViewer(v: Viewer) {
|
||
const label = v.name || v.email;
|
||
if (
|
||
!confirm(
|
||
`Gebruiker “${label}” definitief verwijderen?\n\nApparaten, profielen, IPTV-lijn en voortgang verdwijnen. Stremio-addons blijven bestaan maar zonder koppeling.`
|
||
)
|
||
) {
|
||
return;
|
||
}
|
||
setActionId(v.id);
|
||
setErr(null);
|
||
try {
|
||
const res = await fetch(`/api/v1/admin/viewers/${v.id}`, {
|
||
method: "DELETE",
|
||
credentials: "include",
|
||
});
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok) {
|
||
setErr(data.error?.message ?? "Verwijderen mislukt");
|
||
return;
|
||
}
|
||
if (selectedId === v.id) {
|
||
setSelectedId(null);
|
||
setMobileShowDetail(false);
|
||
}
|
||
setMsg(`${label} verwijderd`);
|
||
load();
|
||
} finally {
|
||
setActionId(null);
|
||
}
|
||
}
|
||
|
||
const iptv = selected?.iptv ?? null;
|
||
const iptvForm = selected
|
||
? iptvForms[selected.id] ?? formFromIptv(iptv)
|
||
: emptyIptvForm();
|
||
const tokens = selected?.addonTokens ?? [];
|
||
const installUrl = selected ? addonInstallUrls[selected.id] ?? null : null;
|
||
const badge = iptvBadge(iptv);
|
||
const pTone = selected ? playTone(selected) : "off";
|
||
|
||
return (
|
||
<>
|
||
<Nav />
|
||
<div className="container viewers-page">
|
||
<header className="page-header">
|
||
<div>
|
||
<p className="page-kicker">Mensen</p>
|
||
<h1>Gebruikers</h1>
|
||
<p className="page-lead muted">
|
||
Kies links een gebruiker · beheer rechts in blokken · apparaten via code of{" "}
|
||
<Link href="/link">/link</Link>.
|
||
</p>
|
||
</div>
|
||
<button type="button" className="btn-primary" onClick={() => setShowCreate((v) => !v)}>
|
||
{showCreate ? "Sluiten" : "Nieuwe gebruiker"}
|
||
</button>
|
||
</header>
|
||
|
||
<div className="stat-strip">
|
||
<span>
|
||
<strong>{stats.total}</strong> totaal
|
||
</span>
|
||
<span>
|
||
<strong>{stats.active}</strong> actief
|
||
</span>
|
||
<span>
|
||
<strong>{stats.appOn}</strong> app
|
||
</span>
|
||
<span>
|
||
<strong>{stats.playOn}</strong> play
|
||
{stats.playErr > 0 ? <em className="stat-warn"> · {stats.playErr} fout</em> : null}
|
||
</span>
|
||
</div>
|
||
|
||
{(msg || err) && (
|
||
<div className={`flash ${err ? "flash-err" : "flash-ok"}`} role="status">
|
||
{err ? err : msg}
|
||
<button
|
||
type="button"
|
||
className="flash-close"
|
||
onClick={() => {
|
||
setMsg(null);
|
||
setErr(null);
|
||
}}
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{showCreate && (
|
||
<section className="card create-card">
|
||
<h2 className="card-subtitle">Nieuwe gebruiker</h2>
|
||
<form className="form-grid" onSubmit={createViewer}>
|
||
<label>
|
||
Naam
|
||
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="Jos" />
|
||
</label>
|
||
<label>
|
||
Account-e-mail
|
||
<input
|
||
type="email"
|
||
required
|
||
value={email}
|
||
onChange={(e) => setEmail(e.target.value)}
|
||
autoComplete="off"
|
||
/>
|
||
</label>
|
||
<label>
|
||
Wachtwoord
|
||
<input
|
||
type="password"
|
||
required
|
||
minLength={8}
|
||
value={password}
|
||
onChange={(e) => setPassword(e.target.value)}
|
||
autoComplete="new-password"
|
||
/>
|
||
</label>
|
||
<div className="create-actions">
|
||
<button type="submit" className="btn-primary" disabled={busy}>
|
||
{busy ? "Bezig…" : "Aanmaken"}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</section>
|
||
)}
|
||
|
||
<div className={`workspace${mobileShowDetail && selected ? " show-detail" : ""}`}>
|
||
<aside className="sidebar">
|
||
<div className="sidebar-top">
|
||
<label className="search-field">
|
||
<span className="sr-only">Zoeken</span>
|
||
<input
|
||
type="search"
|
||
placeholder="Zoek naam of e-mail…"
|
||
value={query}
|
||
onChange={(e) => setQuery(e.target.value)}
|
||
/>
|
||
</label>
|
||
<div className="sidebar-tools">
|
||
{playConfig?.configured ? (
|
||
<>
|
||
<button type="button" disabled={bulkBusy} onClick={() => void bulkAction("sync-all")}>
|
||
Sync Play
|
||
</button>
|
||
<button type="button" disabled={bulkBusy} onClick={() => void bulkAction("reconcile")}>
|
||
Reconcile
|
||
</button>
|
||
</>
|
||
) : (
|
||
<Link href="/settings?tab=google-play" className="setup-link">
|
||
Play instellen →
|
||
</Link>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{filtered.length === 0 ? (
|
||
<p className="muted empty">
|
||
{viewers.length === 0
|
||
? "Nog geen gebruikers."
|
||
: "Geen treffers voor deze zoekopdracht."}
|
||
</p>
|
||
) : (
|
||
<ul className="user-list">
|
||
{filtered.map((v) => {
|
||
const active = selectedId === v.id;
|
||
const ib = iptvBadge(v.iptv);
|
||
return (
|
||
<li key={v.id}>
|
||
<button
|
||
type="button"
|
||
className={`user-row${active ? " active" : ""}${!v.enabled ? " dim" : ""}`}
|
||
onClick={() => selectViewer(v)}
|
||
aria-current={active ? "true" : undefined}
|
||
>
|
||
<div className="user-row-main">
|
||
<strong>{v.name || v.email}</strong>
|
||
{v.name ? <span>{v.email}</span> : null}
|
||
</div>
|
||
<div className="user-row-meta">
|
||
<span className={`dot ${v.enabled ? "ok" : "off"}`} title="Account" />
|
||
<span className={`dot ${v.appAccess ? "ok" : "off"}`} title="App" />
|
||
<span className={`dot ${ib.tone}`} title={ib.label} />
|
||
<span className={`dot ${playTone(v)}`} title="Play" />
|
||
<span className="device-count">{v.deviceCount}</span>
|
||
</div>
|
||
</button>
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
)}
|
||
</aside>
|
||
|
||
<section className="detail-pane" aria-live="polite">
|
||
{!selected ? (
|
||
<div className="detail-empty">
|
||
<p className="muted">Selecteer links een gebruiker om te beheren.</p>
|
||
</div>
|
||
) : (
|
||
<>
|
||
<div className="detail-head">
|
||
<button
|
||
type="button"
|
||
className="back-btn"
|
||
onClick={() => setMobileShowDetail(false)}
|
||
>
|
||
← Lijst
|
||
</button>
|
||
<div className="detail-who">
|
||
<h2>{selected.name || selected.email}</h2>
|
||
{selected.name ? <p className="muted">{selected.email}</p> : null}
|
||
</div>
|
||
<div className="detail-status">
|
||
<span className={`pill ${selected.enabled ? "ok" : "off"}`}>
|
||
{selected.enabled ? "Account aan" : "Account uit"}
|
||
</span>
|
||
<span className={`pill ${selected.appAccess ? "ok" : "off"}`}>
|
||
{selected.appAccess ? "App aan" : "App uit"}
|
||
</span>
|
||
<span className={`pill ${badge.tone}`}>{badge.label}</span>
|
||
<span className={`pill ${pTone}`}>
|
||
Play {SYNC_LABELS[selected.googlePlaySyncStatus]}
|
||
</span>
|
||
{selected.loginLocked ? <span className="pill bad">Login vergrendeld</span> : null}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="blocks">
|
||
<article className="block">
|
||
<header className="block-head">
|
||
<h3>Apparaten</h3>
|
||
<span className="muted">
|
||
{selected.deviceCount === 0
|
||
? "geen gekoppeld"
|
||
: `${selected.deviceCount} actief`}
|
||
</span>
|
||
</header>
|
||
{selected.devices.length === 0 ? (
|
||
<p className="muted detail-copy">Nog geen gekoppelde apparaten.</p>
|
||
) : (
|
||
<ul className="device-rows">
|
||
{selected.devices.map((d) => (
|
||
<li key={d.id}>
|
||
<div>
|
||
<strong>{d.name}</strong>
|
||
<span className="muted">
|
||
{d.platform}
|
||
{d.lastSeenAt
|
||
? ` · laatst gezien ${formatWhen(d.lastSeenAt)}`
|
||
: ""}
|
||
</span>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className="danger"
|
||
disabled={actionId === d.id}
|
||
onClick={() => void revokeDevice(d.id)}
|
||
>
|
||
Loskoppelen
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
<div className="claim-code-row">
|
||
<p className="muted detail-copy">
|
||
Openstaande code van TV/app (zonder gebruikerswachtwoord).
|
||
</p>
|
||
<div className="claim-code-form">
|
||
<input
|
||
type="text"
|
||
inputMode="text"
|
||
autoComplete="off"
|
||
spellCheck={false}
|
||
placeholder="ABCD-EFGH"
|
||
value={claimCodes[selected.id] ?? ""}
|
||
onChange={(e) =>
|
||
setClaimCodes((prev) => ({
|
||
...prev,
|
||
[selected.id]: e.target.value.toUpperCase(),
|
||
}))
|
||
}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter") {
|
||
e.preventDefault();
|
||
void claimDeviceCode(selected.id);
|
||
}
|
||
}}
|
||
/>
|
||
<button
|
||
type="button"
|
||
className="btn-primary"
|
||
disabled={
|
||
actionId === `claim-${selected.id}` ||
|
||
!(claimCodes[selected.id] ?? "").trim()
|
||
}
|
||
onClick={() => void claimDeviceCode(selected.id)}
|
||
>
|
||
{actionId === `claim-${selected.id}` ? "Koppelen…" : "Code koppelen"}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</article>
|
||
|
||
<article className="block">
|
||
<header className="block-head">
|
||
<h3>Account</h3>
|
||
</header>
|
||
<p className="muted detail-copy">
|
||
Bepaalt of deze gebruiker kan inloggen en de app-inhoud mag gebruiken.
|
||
</p>
|
||
{(selected.loginLocked || (selected.loginFailedCount ?? 0) > 0) && (
|
||
<div className="login-lock-box">
|
||
<p>
|
||
{selected.loginLocked
|
||
? `Login vergrendeld tot ${
|
||
selected.loginLockedUntil
|
||
? new Date(selected.loginLockedUntil).toLocaleString("nl-NL")
|
||
: "…"
|
||
} (3 foute pogingen).`
|
||
: `${selected.loginFailedCount} foute wachtwoordpoging${
|
||
(selected.loginFailedCount ?? 0) === 1 ? "" : "en"
|
||
} (lock na 3).`}
|
||
</p>
|
||
<button
|
||
type="button"
|
||
disabled={actionId === `unlock-${selected.id}`}
|
||
onClick={() => void unlockLogin(selected)}
|
||
>
|
||
Login ontgrendelen
|
||
</button>
|
||
</div>
|
||
)}
|
||
<div className="toggle-rows">
|
||
<div className="toggle-row">
|
||
<div>
|
||
<strong>Account actief</strong>
|
||
<span className="muted">Uitschakelen blokkeert login</span>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className={`switch ${selected.enabled ? "on" : ""}`}
|
||
disabled={actionId === selected.id}
|
||
onClick={() => void toggle(selected, "enabled")}
|
||
aria-pressed={selected.enabled}
|
||
>
|
||
<span />
|
||
</button>
|
||
</div>
|
||
<div className="toggle-row">
|
||
<div>
|
||
<strong>App-toegang</strong>
|
||
<span className="muted">Toegang tot content in de app</span>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className={`switch ${selected.appAccess ? "on" : ""}`}
|
||
disabled={actionId === selected.id}
|
||
onClick={() => void toggle(selected, "appAccess")}
|
||
aria-pressed={selected.appAccess}
|
||
>
|
||
<span />
|
||
</button>
|
||
</div>
|
||
<div className="toggle-row">
|
||
<div>
|
||
<strong>Live TV (IPTV)</strong>
|
||
<span className="muted">Aan alleen met IPTV-lijn hiernaast</span>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className={`switch ${selected.iptvAccess ? "on" : ""}`}
|
||
disabled={actionId === selected.id || !selected.appAccess}
|
||
onClick={() => void toggle(selected, "iptvAccess")}
|
||
aria-pressed={selected.iptvAccess}
|
||
>
|
||
<span />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div className="detail-actions">
|
||
<button
|
||
type="button"
|
||
className="danger"
|
||
disabled={actionId === selected.id}
|
||
onClick={() => void deleteViewer(selected)}
|
||
>
|
||
Gebruiker verwijderen
|
||
</button>
|
||
</div>
|
||
</article>
|
||
|
||
<article className="block">
|
||
<header className="block-head">
|
||
<h3>IPTV · Xtream / XUI</h3>
|
||
</header>
|
||
<p className="muted detail-copy">
|
||
Eigen IPTV-lijn voor apps en gekoppelde Stremio-addons.
|
||
</p>
|
||
<div className="form-group">
|
||
<label className="field">
|
||
Label
|
||
<input
|
||
value={iptvForm.label}
|
||
onChange={(e) => patchIptvForm(selected.id, { label: e.target.value })}
|
||
placeholder="Thuis / XUI"
|
||
/>
|
||
</label>
|
||
<label className="field">
|
||
Base URL
|
||
<input
|
||
value={iptvForm.baseUrl}
|
||
onChange={(e) => patchIptvForm(selected.id, { baseUrl: e.target.value })}
|
||
placeholder="http://host:port"
|
||
inputMode="url"
|
||
/>
|
||
</label>
|
||
<label className="field">
|
||
Gebruikersnaam
|
||
<input
|
||
value={iptvForm.username}
|
||
onChange={(e) =>
|
||
patchIptvForm(selected.id, { username: e.target.value })
|
||
}
|
||
autoComplete="off"
|
||
/>
|
||
</label>
|
||
<label className="field">
|
||
Wachtwoord
|
||
<input
|
||
type="password"
|
||
value={iptvForm.password}
|
||
onChange={(e) =>
|
||
patchIptvForm(selected.id, { password: e.target.value })
|
||
}
|
||
placeholder={
|
||
iptv?.hasPassword ? "•••••••• (leeg = behouden)" : "Wachtwoord"
|
||
}
|
||
autoComplete="new-password"
|
||
/>
|
||
</label>
|
||
<div className="check-rows">
|
||
<label className="check-row">
|
||
<input
|
||
type="checkbox"
|
||
checked={iptvForm.enableLive}
|
||
onChange={(e) =>
|
||
patchIptvForm(selected.id, { enableLive: e.target.checked })
|
||
}
|
||
/>
|
||
Live
|
||
</label>
|
||
<label className="check-row">
|
||
<input
|
||
type="checkbox"
|
||
checked={iptvForm.enableVod}
|
||
onChange={(e) =>
|
||
patchIptvForm(selected.id, { enableVod: e.target.checked })
|
||
}
|
||
/>
|
||
VOD
|
||
</label>
|
||
<label className="check-row">
|
||
<input
|
||
type="checkbox"
|
||
checked={iptvForm.enableSeries}
|
||
onChange={(e) =>
|
||
patchIptvForm(selected.id, { enableSeries: e.target.checked })
|
||
}
|
||
/>
|
||
Series
|
||
</label>
|
||
</div>
|
||
</div>
|
||
{iptv?.lastError && (
|
||
<p className="text-bad sync-err">Laatste fout: {iptv.lastError}</p>
|
||
)}
|
||
{iptvLocalErr[selected.id] && (
|
||
<p className="text-bad sync-err">{iptvLocalErr[selected.id]}</p>
|
||
)}
|
||
{iptvLocalMsg[selected.id] && (
|
||
<p className="muted detail-copy">{iptvLocalMsg[selected.id]}</p>
|
||
)}
|
||
{iptv?.lastOkAt && !iptv.lastError && (
|
||
<p className="muted field-hint">Laatst OK: {formatWhen(iptv.lastOkAt)}</p>
|
||
)}
|
||
<div className="detail-actions">
|
||
<button
|
||
type="button"
|
||
className="btn-primary"
|
||
disabled={actionId === selected.id}
|
||
onClick={() => void saveIptv(selected)}
|
||
>
|
||
Opslaan
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={actionId === selected.id || !iptv}
|
||
onClick={() => void testIptv(selected)}
|
||
>
|
||
Testen
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="danger"
|
||
disabled={actionId === selected.id || !iptv}
|
||
onClick={() => void deleteIptv(selected)}
|
||
>
|
||
Ontkoppelen
|
||
</button>
|
||
</div>
|
||
</article>
|
||
|
||
<article className="block">
|
||
<header className="block-head">
|
||
<h3>Stremio-addon</h3>
|
||
</header>
|
||
<p className="muted detail-copy">
|
||
Stremio gebruikt de IPTV-lijn van deze gebruiker.
|
||
</p>
|
||
{tokens.length === 0 ? (
|
||
<p className="muted">Nog geen addon-tokens.</p>
|
||
) : (
|
||
<ul className="addon-rows">
|
||
{tokens.map((t) => (
|
||
<li key={t.id}>
|
||
<div>
|
||
<strong>{t.label || "Addon"}</strong>
|
||
<span className="muted">
|
||
{t.revoked ? "Ingetrokken" : "Actief"}
|
||
{t.hasOwnIptv ? " · eigen IPTV" : ""}
|
||
{t.createdAt ? ` · aangemaakt ${formatWhen(t.createdAt)}` : ""}
|
||
</span>
|
||
</div>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
<div className="detail-actions">
|
||
<button
|
||
type="button"
|
||
className="btn-primary"
|
||
disabled={actionId === selected.id}
|
||
onClick={() => void createAddonToken(selected)}
|
||
>
|
||
Addon aanmaken
|
||
</button>
|
||
</div>
|
||
{installUrl && (
|
||
<div className="install-box">
|
||
<p className="muted field-hint">
|
||
Installatie-URL (eenmalig zichtbaar — kopieer nu):
|
||
</p>
|
||
<code className="install-url">{installUrl}</code>
|
||
<button type="button" onClick={() => void copyText(installUrl)}>
|
||
Kopiëren
|
||
</button>
|
||
</div>
|
||
)}
|
||
</article>
|
||
|
||
<article className="block">
|
||
<header className="block-head">
|
||
<h3>Google Play</h3>
|
||
</header>
|
||
{!playConfig?.configured ? (
|
||
<p className="muted detail-copy">
|
||
Nog niet geconfigureerd. Ga naar{" "}
|
||
<Link href="/settings?tab=google-play">Instellingen → Google Play</Link>.
|
||
</p>
|
||
) : (
|
||
<>
|
||
<p className="muted detail-copy">
|
||
Alleen installatierecht via Closed Testing — los van app-login.
|
||
</p>
|
||
<label className="field">
|
||
Google Play-e-mail
|
||
<input
|
||
type="email"
|
||
placeholder={selected.email}
|
||
value={playEmails[selected.id] ?? ""}
|
||
onChange={(e) =>
|
||
setPlayEmails((prev) => ({
|
||
...prev,
|
||
[selected.id]: e.target.value,
|
||
}))
|
||
}
|
||
/>
|
||
</label>
|
||
<p className="field-hint muted">
|
||
Leeg = account-e-mail (
|
||
{selected.googlePlayResolvedEmail || selected.email})
|
||
</p>
|
||
<div className="play-status-line">
|
||
<span>
|
||
Status:{" "}
|
||
<strong className={pTone === "bad" ? "text-bad" : ""}>
|
||
{SYNC_LABELS[selected.googlePlaySyncStatus]}
|
||
</strong>
|
||
</span>
|
||
{selected.googlePlayLastSyncedAt && (
|
||
<span className="muted">
|
||
Laatst: {formatWhen(selected.googlePlayLastSyncedAt)}
|
||
</span>
|
||
)}
|
||
</div>
|
||
{selected.googlePlaySyncError && (
|
||
<p className="text-bad sync-err">{selected.googlePlaySyncError}</p>
|
||
)}
|
||
<div className="detail-actions">
|
||
<button
|
||
type="button"
|
||
disabled={actionId === selected.id}
|
||
onClick={() => void savePlayEmail(selected)}
|
||
>
|
||
E-mail opslaan
|
||
</button>
|
||
{selected.googlePlayAccess ? (
|
||
<button
|
||
type="button"
|
||
className="danger"
|
||
disabled={actionId === selected.id}
|
||
onClick={() => void setPlayAccess(selected, false)}
|
||
>
|
||
Play uit
|
||
</button>
|
||
) : (
|
||
<button
|
||
type="button"
|
||
className="btn-primary"
|
||
disabled={actionId === selected.id}
|
||
onClick={() => void setPlayAccess(selected, true)}
|
||
>
|
||
Play aan
|
||
</button>
|
||
)}
|
||
{selected.googlePlaySyncStatus === "ERROR" && (
|
||
<button
|
||
type="button"
|
||
disabled={actionId === selected.id}
|
||
onClick={() => void retryPlaySync(selected)}
|
||
>
|
||
Opnieuw syncen
|
||
</button>
|
||
)}
|
||
{selected.googlePlayAccess &&
|
||
selected.googlePlaySyncStatus === "ACTIVE" &&
|
||
playConfig.optInUrl && (
|
||
<a
|
||
href={playConfig.optInUrl}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="optin-link"
|
||
>
|
||
Opt-in link
|
||
</a>
|
||
)}
|
||
</div>
|
||
</>
|
||
)}
|
||
</article>
|
||
</div>
|
||
</>
|
||
)}
|
||
</section>
|
||
</div>
|
||
</div>
|
||
|
||
<style jsx>{`
|
||
.viewers-page {
|
||
max-width: 1440px;
|
||
}
|
||
.stat-strip {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 0.35rem 1.25rem;
|
||
margin: -0.5rem 0 1.1rem;
|
||
padding: 0.65rem 0.9rem;
|
||
border-radius: var(--radius);
|
||
border: 1px solid var(--border-dim);
|
||
background: var(--bg-panel);
|
||
font-size: 0.85rem;
|
||
color: var(--text-muted);
|
||
}
|
||
.stat-strip strong {
|
||
color: var(--text-bright);
|
||
font-family: var(--font-mono);
|
||
font-weight: 600;
|
||
margin-right: 0.25rem;
|
||
}
|
||
.stat-warn {
|
||
color: var(--warn);
|
||
font-style: normal;
|
||
}
|
||
.flash {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: space-between;
|
||
gap: 0.75rem;
|
||
padding: 0.75rem 1rem;
|
||
border-radius: var(--radius);
|
||
margin-bottom: 1rem;
|
||
font-size: 0.9rem;
|
||
border: 1px solid transparent;
|
||
}
|
||
.flash-ok {
|
||
background: var(--ok-bg);
|
||
border-color: rgba(52, 211, 153, 0.35);
|
||
color: var(--ok);
|
||
}
|
||
.flash-err {
|
||
background: var(--danger-bg);
|
||
border-color: rgba(244, 63, 94, 0.35);
|
||
color: #fda4af;
|
||
}
|
||
.flash-close {
|
||
border: none;
|
||
background: transparent;
|
||
color: inherit;
|
||
font-size: 1.2rem;
|
||
line-height: 1;
|
||
padding: 0 0.2rem;
|
||
cursor: pointer;
|
||
opacity: 0.8;
|
||
}
|
||
.create-card {
|
||
margin-bottom: 1rem;
|
||
}
|
||
.card-subtitle {
|
||
font-family: var(--font-mono);
|
||
font-size: 0.72rem;
|
||
letter-spacing: 0.12em;
|
||
text-transform: uppercase;
|
||
color: var(--text-dim);
|
||
margin: 0 0 0.85rem;
|
||
}
|
||
.create-actions {
|
||
display: flex;
|
||
align-items: end;
|
||
}
|
||
.workspace {
|
||
display: grid;
|
||
grid-template-columns: minmax(240px, 300px) minmax(0, 1fr);
|
||
gap: 1rem;
|
||
align-items: start;
|
||
min-height: 60vh;
|
||
}
|
||
.sidebar {
|
||
position: sticky;
|
||
top: 1rem;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.65rem;
|
||
max-height: calc(100vh - 2rem);
|
||
padding: 0.75rem;
|
||
border-radius: var(--radius);
|
||
border: 1px solid var(--border);
|
||
background:
|
||
linear-gradient(160deg, rgba(34, 211, 238, 0.04), transparent 45%),
|
||
var(--bg-panel);
|
||
box-shadow: var(--shadow-panel);
|
||
}
|
||
.sidebar-top {
|
||
display: grid;
|
||
gap: 0.55rem;
|
||
flex-shrink: 0;
|
||
}
|
||
.search-field input {
|
||
width: 100%;
|
||
}
|
||
.sidebar-tools {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 0.4rem;
|
||
}
|
||
.sidebar-tools button {
|
||
font-size: 0.75rem;
|
||
padding: 0.35rem 0.55rem;
|
||
}
|
||
.setup-link {
|
||
font-size: 0.8rem;
|
||
}
|
||
.empty {
|
||
padding: 1rem 0.25rem;
|
||
text-align: center;
|
||
}
|
||
.user-list {
|
||
list-style: none;
|
||
margin: 0;
|
||
padding: 0;
|
||
overflow-y: auto;
|
||
display: grid;
|
||
gap: 0.3rem;
|
||
min-height: 0;
|
||
}
|
||
.user-row {
|
||
width: 100%;
|
||
display: grid;
|
||
gap: 0.35rem;
|
||
padding: 0.7rem 0.75rem;
|
||
border-radius: 8px;
|
||
border: 1px solid transparent;
|
||
background: transparent;
|
||
color: inherit;
|
||
text-align: left;
|
||
cursor: pointer;
|
||
transition: background 0.12s ease, border-color 0.12s ease;
|
||
}
|
||
.user-row:hover {
|
||
background: rgba(255, 255, 255, 0.03);
|
||
}
|
||
.user-row.active {
|
||
background: var(--accent-dim);
|
||
border-color: var(--border-strong);
|
||
}
|
||
.user-row.dim strong {
|
||
color: var(--text-muted);
|
||
}
|
||
.user-row-main {
|
||
display: grid;
|
||
gap: 0.1rem;
|
||
min-width: 0;
|
||
}
|
||
.user-row-main strong {
|
||
color: var(--text-bright);
|
||
font-size: 0.92rem;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.user-row-main span {
|
||
font-size: 0.75rem;
|
||
color: var(--text-muted);
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.user-row-meta {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.35rem;
|
||
}
|
||
.dot {
|
||
width: 7px;
|
||
height: 7px;
|
||
border-radius: 50%;
|
||
background: var(--text-dim);
|
||
flex-shrink: 0;
|
||
}
|
||
.dot.ok {
|
||
background: var(--ok);
|
||
}
|
||
.dot.warn {
|
||
background: var(--warn);
|
||
}
|
||
.dot.bad {
|
||
background: var(--danger);
|
||
}
|
||
.dot.off {
|
||
background: rgba(148, 163, 184, 0.35);
|
||
}
|
||
.device-count {
|
||
margin-left: auto;
|
||
font-family: var(--font-mono);
|
||
font-size: 0.7rem;
|
||
color: var(--text-dim);
|
||
}
|
||
.detail-pane {
|
||
min-width: 0;
|
||
padding: 1rem 1.1rem 1.25rem;
|
||
border-radius: var(--radius);
|
||
border: 1px solid var(--border);
|
||
background:
|
||
linear-gradient(145deg, rgba(34, 211, 238, 0.03), transparent 35%),
|
||
var(--bg-panel);
|
||
box-shadow: var(--shadow-panel);
|
||
}
|
||
.detail-empty {
|
||
display: grid;
|
||
place-items: center;
|
||
min-height: 280px;
|
||
}
|
||
.detail-head {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: flex-start;
|
||
gap: 0.75rem 1rem;
|
||
margin-bottom: 1.1rem;
|
||
padding-bottom: 0.9rem;
|
||
border-bottom: 1px solid var(--border-dim);
|
||
}
|
||
.back-btn {
|
||
display: none;
|
||
}
|
||
.detail-who {
|
||
flex: 1 1 12rem;
|
||
min-width: 0;
|
||
}
|
||
.detail-who h2 {
|
||
margin: 0;
|
||
font-size: 1.35rem;
|
||
font-weight: 700;
|
||
letter-spacing: -0.02em;
|
||
color: var(--text-bright);
|
||
font-family: var(--font-ui);
|
||
text-transform: none;
|
||
}
|
||
.detail-who p {
|
||
margin: 0.15rem 0 0;
|
||
}
|
||
.detail-status {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 0.35rem;
|
||
align-items: center;
|
||
}
|
||
.pill {
|
||
font-family: var(--font-mono);
|
||
font-size: 0.65rem;
|
||
letter-spacing: 0.04em;
|
||
text-transform: uppercase;
|
||
padding: 0.28rem 0.5rem;
|
||
border-radius: 999px;
|
||
border: 1px solid transparent;
|
||
white-space: nowrap;
|
||
}
|
||
.pill.ok {
|
||
color: var(--ok);
|
||
background: var(--ok-bg);
|
||
border-color: rgba(52, 211, 153, 0.25);
|
||
}
|
||
.pill.warn {
|
||
color: var(--warn);
|
||
background: rgba(251, 191, 36, 0.12);
|
||
border-color: rgba(251, 191, 36, 0.28);
|
||
}
|
||
.pill.bad {
|
||
color: #fda4af;
|
||
background: var(--danger-bg);
|
||
border-color: rgba(244, 63, 94, 0.3);
|
||
}
|
||
.pill.off {
|
||
color: var(--text-muted);
|
||
background: rgba(148, 163, 184, 0.08);
|
||
border-color: var(--border-dim);
|
||
}
|
||
.blocks {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: 0.85rem;
|
||
}
|
||
.block {
|
||
display: grid;
|
||
gap: 0.65rem;
|
||
align-content: start;
|
||
padding: 0.95rem 1rem;
|
||
border-radius: 10px;
|
||
border: 1px solid var(--border-dim);
|
||
background: var(--bg-panel-2);
|
||
}
|
||
.block:first-child {
|
||
grid-column: 1 / -1;
|
||
}
|
||
.block-head {
|
||
display: flex;
|
||
align-items: baseline;
|
||
justify-content: space-between;
|
||
gap: 0.75rem;
|
||
}
|
||
.block-head h3 {
|
||
margin: 0;
|
||
font-size: 0.95rem;
|
||
color: var(--text-bright);
|
||
}
|
||
.detail-copy {
|
||
font-size: 0.82rem;
|
||
line-height: 1.4;
|
||
}
|
||
.login-lock-box {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
gap: 0.75rem;
|
||
padding: 0.75rem 0.9rem;
|
||
border-radius: 10px;
|
||
border: 1px solid rgba(248, 113, 113, 0.35);
|
||
background: rgba(248, 113, 113, 0.08);
|
||
}
|
||
.login-lock-box p {
|
||
margin: 0;
|
||
flex: 1;
|
||
min-width: 180px;
|
||
font-size: 0.88rem;
|
||
}
|
||
.toggle-rows {
|
||
display: grid;
|
||
gap: 0.55rem;
|
||
}
|
||
.toggle-row {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
gap: 1rem;
|
||
padding: 0.7rem 0.8rem;
|
||
border-radius: 8px;
|
||
border: 1px solid var(--border-dim);
|
||
background: var(--bg-elevated);
|
||
}
|
||
.toggle-row strong {
|
||
display: block;
|
||
font-size: 0.9rem;
|
||
}
|
||
.toggle-row .muted {
|
||
display: block;
|
||
font-size: 0.75rem;
|
||
margin-top: 0.1rem;
|
||
}
|
||
.switch {
|
||
width: 44px;
|
||
height: 26px;
|
||
border-radius: 999px;
|
||
border: 1px solid var(--border-dim);
|
||
background: rgba(148, 163, 184, 0.18);
|
||
padding: 2px;
|
||
cursor: pointer;
|
||
flex-shrink: 0;
|
||
}
|
||
.switch span {
|
||
display: block;
|
||
width: 20px;
|
||
height: 20px;
|
||
border-radius: 50%;
|
||
background: #cbd5e1;
|
||
transition: transform 0.15s ease;
|
||
}
|
||
.switch.on {
|
||
background: rgba(52, 211, 153, 0.35);
|
||
border-color: rgba(52, 211, 153, 0.55);
|
||
}
|
||
.switch.on span {
|
||
transform: translateX(18px);
|
||
background: var(--ok);
|
||
}
|
||
.form-group {
|
||
display: grid;
|
||
gap: 0.55rem;
|
||
}
|
||
.field {
|
||
display: grid;
|
||
gap: 0.3rem;
|
||
font-size: 0.82rem;
|
||
color: var(--text-muted);
|
||
}
|
||
.field input {
|
||
width: 100%;
|
||
}
|
||
.field-hint {
|
||
font-size: 0.75rem;
|
||
margin-top: -0.2rem;
|
||
}
|
||
.check-rows {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 0.75rem 1rem;
|
||
}
|
||
.check-row {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 0.4rem;
|
||
font-size: 0.85rem;
|
||
color: var(--text-muted);
|
||
cursor: pointer;
|
||
}
|
||
.play-status-line {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 0.75rem;
|
||
font-size: 0.85rem;
|
||
}
|
||
.text-bad {
|
||
color: #fda4af;
|
||
}
|
||
.sync-err {
|
||
font-size: 0.8rem;
|
||
word-break: break-word;
|
||
}
|
||
.detail-actions {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 0.45rem;
|
||
align-items: center;
|
||
}
|
||
.optin-link {
|
||
font-size: 0.85rem;
|
||
}
|
||
.addon-rows,
|
||
.device-rows {
|
||
list-style: none;
|
||
margin: 0;
|
||
padding: 0;
|
||
display: grid;
|
||
gap: 0.45rem;
|
||
}
|
||
.addon-rows li,
|
||
.device-rows li {
|
||
padding: 0.65rem 0.75rem;
|
||
border-radius: 8px;
|
||
border: 1px solid var(--border-dim);
|
||
background: var(--bg-elevated);
|
||
}
|
||
.device-rows li {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
gap: 1rem;
|
||
}
|
||
.addon-rows strong,
|
||
.device-rows strong {
|
||
display: block;
|
||
font-size: 0.9rem;
|
||
}
|
||
.addon-rows .muted,
|
||
.device-rows .muted {
|
||
display: block;
|
||
font-size: 0.75rem;
|
||
margin-top: 0.1rem;
|
||
}
|
||
.install-box {
|
||
display: grid;
|
||
gap: 0.4rem;
|
||
padding: 0.7rem 0.8rem;
|
||
border-radius: 8px;
|
||
border: 1px dashed var(--border);
|
||
background: var(--bg-elevated);
|
||
}
|
||
.install-url {
|
||
display: block;
|
||
font-family: var(--font-mono);
|
||
font-size: 0.75rem;
|
||
word-break: break-all;
|
||
color: var(--text-bright);
|
||
}
|
||
.claim-code-row {
|
||
margin-top: 0.25rem;
|
||
padding-top: 0.65rem;
|
||
border-top: 1px solid var(--border-dim);
|
||
}
|
||
.claim-code-form {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 0.5rem;
|
||
align-items: center;
|
||
}
|
||
.claim-code-form input {
|
||
flex: 1 1 10rem;
|
||
min-width: 8rem;
|
||
font-family: var(--font-mono);
|
||
letter-spacing: 0.06em;
|
||
text-transform: uppercase;
|
||
}
|
||
.sr-only {
|
||
position: absolute;
|
||
width: 1px;
|
||
height: 1px;
|
||
padding: 0;
|
||
margin: -1px;
|
||
overflow: hidden;
|
||
clip: rect(0, 0, 0, 0);
|
||
border: 0;
|
||
}
|
||
@media (max-width: 980px) {
|
||
.blocks {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
.block:first-child {
|
||
grid-column: auto;
|
||
}
|
||
}
|
||
@media (max-width: 820px) {
|
||
.workspace {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
.sidebar {
|
||
position: static;
|
||
max-height: none;
|
||
}
|
||
.detail-pane {
|
||
display: none;
|
||
}
|
||
.workspace.show-detail .sidebar {
|
||
display: none;
|
||
}
|
||
.workspace.show-detail .detail-pane {
|
||
display: block;
|
||
}
|
||
.back-btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
font-size: 0.85rem;
|
||
padding: 0.35rem 0.6rem;
|
||
}
|
||
}
|
||
@media (max-width: 560px) {
|
||
.page-header {
|
||
flex-direction: column;
|
||
align-items: stretch;
|
||
}
|
||
.page-header > button {
|
||
width: 100%;
|
||
}
|
||
}
|
||
`}</style>
|
||
</>
|
||
);
|
||
}
|