feat: add launcher frontend MVP

This commit is contained in:
DCCONSTRUCTIONS
2026-05-01 18:39:59 +03:00
parent 63d21a7a57
commit e8c6e76885
46 changed files with 9497 additions and 0 deletions
+272
View File
@@ -0,0 +1,272 @@
import { useEffect, useMemo, useState } from "react";
import type { ServiceAccessException, ServiceGrant } from "../entities/access/types";
import type { Invite } from "../entities/invite/types";
import type { LauncherServiceView, Service } from "../entities/service/types";
import type { SyncStatus } from "../entities/sync/types";
import {
buildLauncherServices,
buildMe,
initialLauncherData,
profileOptions,
type LauncherData,
} from "../shared/api/mockApi";
import { loadPersistedLauncherData, persistLauncherData } from "../shared/api/storageApi";
import { AdminOverlay } from "../widgets/admin-overlay/AdminOverlay";
import { ServiceRail } from "../widgets/service-rail/ServiceRail";
import { ServiceStage } from "../widgets/service-stage/ServiceStage";
import { TopBar } from "../widgets/top-bar/TopBar";
export function LauncherApp() {
const [data, setData] = useState<LauncherData>(initialLauncherData);
const [activeProfileId, setActiveProfileId] = useState(profileOptions[0].userId);
const [activeClientId, setActiveClientId] = useState(profileOptions[0].defaultClientId);
const [selectedServiceId, setSelectedServiceId] = useState<string | undefined>();
const [adminOpen, setAdminOpen] = useState(false);
const [storageHydrated, setStorageHydrated] = useState(false);
const me = useMemo(() => buildMe(data, activeProfileId, activeClientId), [data, activeProfileId, activeClientId]);
const resolvedClientId = me.activeClientId;
const launcherServices = useMemo(
() => buildLauncherServices(data, activeProfileId, resolvedClientId),
[data, activeProfileId, resolvedClientId]
);
useEffect(() => {
if (!launcherServices.length) {
setSelectedServiceId(undefined);
return;
}
if (selectedServiceId && !launcherServices.some((service) => service.id === selectedServiceId)) {
setSelectedServiceId(undefined);
}
}, [launcherServices, selectedServiceId]);
const selectedService = launcherServices.find((service) => service.id === selectedServiceId);
useEffect(() => {
let isMounted = true;
loadPersistedLauncherData()
.then((persistedData) => {
if (isMounted && persistedData) {
setData(persistedData);
}
})
.finally(() => {
if (isMounted) {
setStorageHydrated(true);
}
});
return () => {
isMounted = false;
};
}, []);
useEffect(() => {
if (!storageHydrated) return;
const saveTimer = window.setTimeout(() => {
persistLauncherData(data).catch((error: unknown) => {
console.warn(error instanceof Error ? error.message : "Не удалось сохранить состояние витрины");
});
}, 350);
return () => window.clearTimeout(saveTimer);
}, [data, storageHydrated]);
function handleProfileChange(userId: string) {
const profile = profileOptions.find((option) => option.userId === userId);
setActiveProfileId(userId);
setActiveClientId(profile?.defaultClientId ?? activeClientId);
setAdminOpen(false);
}
function handleLaunch(service: LauncherServiceView) {
if (!service.openUrl || !service.effectiveAccess.openEnabled) return;
window.open(service.openUrl, "_blank", "noopener,noreferrer");
}
function handleServiceSelect(serviceId: string) {
setSelectedServiceId((current) => (current === serviceId ? undefined : serviceId));
}
function handleCreateGrant(grant: Omit<ServiceGrant, "id" | "status" | "createdAt" | "updatedAt">) {
setData((current) => ({
...current,
grants: [
...current.grants,
{
...grant,
id: `grant_mock_${Date.now()}`,
status: "active",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
],
}));
}
function handleCreateDenyException(exception: Omit<ServiceAccessException, "id" | "type" | "createdAt" | "updatedAt">) {
setData((current) => ({
...current,
exceptions: [
...current.exceptions.filter(
(item) => !(item.serviceId === exception.serviceId && item.userId === exception.userId && item.type === "deny")
),
{
...exception,
id: `exception_mock_${Date.now()}`,
type: "deny",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
],
}));
}
function handleRemoveException(exceptionId: string) {
setData((current) => ({
...current,
exceptions: current.exceptions.filter((exception) => exception.id !== exceptionId),
}));
}
function handleCreateInvite(invite: Pick<Invite, "clientId" | "email" | "role">) {
setData((current) => ({
...current,
invites: [
{
...invite,
id: `invite_mock_${Date.now()}`,
invitedByUserId: me.user.id,
token: `mock-${Date.now()}`,
expiresAt: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000).toISOString(),
status: "created",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
...current.invites,
],
}));
}
function handleRetrySync(syncId: string) {
setData((current) => ({
...current,
syncStatuses: current.syncStatuses.map((sync): SyncStatus =>
sync.id === syncId
? {
...sync,
state: "pending",
error: null,
updatedAt: new Date().toISOString(),
}
: sync
),
}));
}
function handleUpdateService(serviceId: string, patch: Partial<Service>) {
setData((current) => ({
...current,
services: current.services.map((service) =>
service.id === serviceId
? {
...service,
...patch,
updatedAt: new Date().toISOString(),
}
: service
),
}));
}
function handleCreateService() {
const createdAt = new Date().toISOString();
setData((current) => {
const nextOrder = Math.max(0, ...current.services.map((service) => service.order)) + 10;
const id = `service_mock_${Date.now()}`;
return {
...current,
services: [
...current.services,
{
id,
slug: `new-service-${current.services.length + 1}`,
title: "New Service",
subtitle: "Новый сервис",
description: "Описание сервиса для витрины.",
fullDescription: "Заполните описание, медиа и ссылку запуска в редакторе контента.",
url: "https://service.handhdc.ru",
launchUrl: "https://service.handhdc.ru/sso/launch",
accentColor: "#F7F8F4",
fallbackGradient: "linear-gradient(135deg, rgba(247, 248, 244, 0.72), rgba(36, 37, 42, 0.9) 52%, #090B0F 88%)",
coverMediaSource: "url",
coverMediaKind: "image",
ambientMediaSource: "url",
ambientMediaKind: "gif",
status: "hidden",
order: nextOrder,
authentikApplicationSlug: `new-service-${current.services.length + 1}`,
authentikGroupName: `service-new-${current.services.length + 1}`,
createdAt,
updatedAt: createdAt,
},
],
};
});
}
function handleDeleteService(serviceId: string) {
setData((current) => ({
...current,
services: current.services.filter((service) => service.id !== serviceId),
grants: current.grants.filter((grant) => grant.serviceId !== serviceId),
exceptions: current.exceptions.filter((exception) => exception.serviceId !== serviceId),
}));
setSelectedServiceId((current) => (current === serviceId ? undefined : current));
}
return (
<div className="launcher-app">
<TopBar
me={me}
clients={data.clients}
profileOptions={profileOptions}
activeProfileId={activeProfileId}
activeClientId={resolvedClientId}
adminOpen={adminOpen}
onProfileChange={handleProfileChange}
onClientChange={setActiveClientId}
onOpenAdmin={() => setAdminOpen(true)}
onOpenShowcase={() => setAdminOpen(false)}
/>
<main className="launcher-main">
<ServiceStage service={selectedService} hasServices={launcherServices.length > 0} onLaunch={handleLaunch} />
{adminOpen && me.permissions.canOpenAdmin ? (
<AdminOverlay
data={data}
me={me}
activeClientId={resolvedClientId}
onClose={() => setAdminOpen(false)}
onCreateGrant={handleCreateGrant}
onCreateDenyException={handleCreateDenyException}
onRemoveException={handleRemoveException}
onCreateInvite={handleCreateInvite}
onRetrySync={handleRetrySync}
onUpdateService={handleUpdateService}
onCreateService={handleCreateService}
onDeleteService={handleDeleteService}
/>
) : null}
<ServiceRail services={launcherServices} selectedServiceId={selectedServiceId} onSelect={handleServiceSelect} />
</main>
</div>
);
}