feat: add launcher frontend MVP
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user