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
File diff suppressed because it is too large Load Diff
+46
View File
@@ -0,0 +1,46 @@
import { Activity, Bot, Boxes, ChartNoAxesColumnIncreasing, KeyRound, Network, Sparkles } from "lucide-react";
import type { LauncherServiceView } from "../../entities/service/types";
import { cn } from "../../shared/lib/cn";
import { ServiceStatusBadge } from "../../shared/ui/StatusBadge";
export function ServiceRail({
services,
selectedServiceId,
onSelect,
}: {
services: LauncherServiceView[];
selectedServiceId?: string;
onSelect: (serviceId: string) => void;
}) {
return (
<div className="service-rail" aria-label="Доступные сервисы">
{services.map((service) => (
<button
key={service.id}
className={cn("service-tile", selectedServiceId === service.id && "service-tile--active")}
onClick={() => onSelect(service.id)}
type="button"
>
<span className="service-tile__media" style={{ "--tile-accent": service.accentColor ?? "#B5FF5A" } as React.CSSProperties}>
<ServiceIcon slug={service.slug} />
</span>
<span className="service-tile__content">
<strong>{service.title}</strong>
<small>{service.subtitle}</small>
</span>
<ServiceStatusBadge status={service.status} />
</button>
))}
</div>
);
}
function ServiceIcon({ slug }: { slug: string }) {
if (slug.includes("task")) return <ChartNoAxesColumnIncreasing size={18} />;
if (slug.includes("1c")) return <Boxes size={18} />;
if (slug.includes("tender")) return <KeyRound size={18} />;
if (slug.includes("twin")) return <Activity size={18} />;
if (slug.includes("digital-modules")) return <Sparkles size={18} />;
if (slug.includes("internal")) return <Network size={18} />;
return <Bot size={18} />;
}
+251
View File
@@ -0,0 +1,251 @@
import type { ReactNode } from "react";
import {
Activity,
Bot,
Boxes,
ChartNoAxesColumnIncreasing,
ChevronLeft,
ChevronRight,
ExternalLink,
KeyRound,
LockKeyhole,
Network,
Wrench,
} from "lucide-react";
import type { LauncherServiceView } from "../../entities/service/types";
import { Button } from "../../shared/ui/Button";
import { ServiceStatusBadge, StatusBadge } from "../../shared/ui/StatusBadge";
const DEFAULT_STAGE_MEDIA = "/storage/default.gif";
export function ServiceStage({
service,
hasServices,
onLaunch,
}: {
service?: LauncherServiceView;
hasServices: boolean;
onLaunch: (service: LauncherServiceView) => void;
}) {
if (!hasServices) {
return (
<section className="service-stage service-stage--empty">
<div className="empty-stage-card">
<Network size={28} />
<h1>Нет доступных сервисов</h1>
<p>Для активного клиента не найдено доступных приложений. Проверьте гранты или статус клиента.</p>
</div>
</section>
);
}
const style = {
"--service-accent": service?.accentColor ?? "#C3FF66",
} as React.CSSProperties;
const disabledReason = service
? service.status === "maintenance"
? "Сервис временно недоступен"
: service.userAccess === "denied"
? "Доступ не выдан"
: service.effectiveAccess.openEnabled
? null
: "Открытие заблокировано"
: null;
return (
<section className="service-stage" style={style}>
<div className="stage-video-shell">
<div className="stage-video-stream" aria-hidden="true">
{service?.media.ambientVideo ? (
<StageMedia className="stage-video-gif" src={service.media.ambientVideo} kind={service.media.ambientKind} />
) : (
<img className="stage-video-gif" src={DEFAULT_STAGE_MEDIA} alt="" />
)}
</div>
<div className="stage-video-topline">
<button className="stage-round-button" type="button" aria-label="Назад">
<ChevronLeft size={17} />
</button>
<span>{service?.title ?? "Витрина NODE.DC"}</span>
</div>
<div className="stage-side-controls" aria-hidden="true">
<span />
<span />
<span />
<span />
</div>
{service ? (
<div className="stage-service-overlay">
<article className="stage-image-card">
<div className="stage-image-card__visual">
{service.media.coverImage || service.media.thumbnail ? (
<StageMedia
className="stage-image-card__image"
src={service.media.coverImage ?? service.media.thumbnail ?? ""}
kind={service.media.coverKind}
/>
) : (
<>
<div className="stage-image-card__figure" />
<div className="stage-image-card__glow" />
</>
)}
</div>
</article>
<aside className="stage-description-card">
<div className="stage-card-label">
<ServiceIcon slug={service.slug} />
<span>Description</span>
</div>
<div className="stage-description-card__copy">
<h1>{service.title}</h1>
<RichDescription text={service.fullDescription ?? service.description} />
</div>
<div className="stage-description-card__chips">
<ServiceStatusBadge status={service.status} />
<StatusBadge
label={service.userAccess === "allowed" ? `Доступ: ${service.appRole ?? "member"}` : "Нет доступа"}
tone={service.userAccess === "allowed" ? "green" : "red"}
/>
</div>
<div className="stage-description-card__reason">
<span>Почему видно</span>
<strong>{service.effectiveAccess.reason}</strong>
</div>
<div className="stage-description-card__actions">
<Button
type="button"
variant="primary"
icon={
service.status === "maintenance" ? (
<Wrench size={16} />
) : service.userAccess === "denied" ? (
<LockKeyhole size={16} />
) : (
<ExternalLink size={16} />
)
}
onClick={() => onLaunch(service)}
disabled={!service.effectiveAccess.openEnabled || !service.openUrl}
>
{disabledReason ?? "Открыть"}
</Button>
<Button
type="button"
variant="secondary"
icon={<ChevronRight size={16} />}
onClick={() => onLaunch(service)}
disabled={!service.effectiveAccess.openEnabled || !service.openUrl}
>
Перейти
</Button>
</div>
</aside>
</div>
) : null}
<div className="stage-video-controls" aria-hidden="true">
<button type="button" tabIndex={-1}>
<ChevronLeft size={15} />
</button>
<button type="button" tabIndex={-1}>
<ChevronRight size={15} />
</button>
</div>
</div>
</section>
);
}
function ServiceIcon({ slug }: { slug: string }) {
if (slug.includes("task")) return <ChartNoAxesColumnIncreasing size={18} />;
if (slug.includes("1c")) return <Boxes size={18} />;
if (slug.includes("tender")) return <KeyRound size={18} />;
if (slug.includes("twin")) return <Activity size={18} />;
return <Bot size={18} />;
}
function StageMedia({ src, kind, className }: { src: string; kind?: LauncherServiceView["media"]["coverKind"]; className: string }) {
if (kind === "video" || isVideoSource(src)) {
return <video className={className} src={src} autoPlay loop muted playsInline />;
}
return <img className={className} src={src} alt="" />;
}
function RichDescription({ text }: { text: string }) {
const blocks = text
.replace(/\r\n/g, "\n")
.split(/\n{2,}/)
.map((block) => block.trim())
.filter(Boolean);
if (!blocks.length) return null;
return (
<div className="stage-rich-description">
{blocks.map((block, index) => {
const lines = block.split("\n").map((line) => line.trim()).filter(Boolean);
const isList = lines.length > 0 && lines.every((line) => /^[-*]\s+/.test(line));
if (isList) {
return (
<ul key={`list-${index}`}>
{lines.map((line, lineIndex) => (
<li key={lineIndex}>{renderInlineRichText(line.replace(/^[-*]\s+/, ""))}</li>
))}
</ul>
);
}
return <p key={`p-${index}`}>{renderInlineRichText(lines.join("\n"))}</p>;
})}
</div>
);
}
function renderInlineRichText(text: string): ReactNode[] {
const parts: ReactNode[] = [];
const pattern = /(\*\*[^*]+\*\*|__[^_]+__|`[^`]+`|\*[^*]+\*|_[^_]+_|\n)/g;
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = pattern.exec(text))) {
if (match.index > lastIndex) {
parts.push(text.slice(lastIndex, match.index));
}
const token = match[0];
const key = `${match.index}-${token}`;
if (token === "\n") {
parts.push(<br key={key} />);
} else if (token.startsWith("**") || token.startsWith("__")) {
parts.push(<strong key={key}>{token.slice(2, -2)}</strong>);
} else if (token.startsWith("`")) {
parts.push(<code key={key}>{token.slice(1, -1)}</code>);
} else {
parts.push(<em key={key}>{token.slice(1, -1)}</em>);
}
lastIndex = pattern.lastIndex;
}
if (lastIndex < text.length) {
parts.push(text.slice(lastIndex));
}
return parts;
}
function isVideoSource(src: string) {
return /\.(mp4|webm|mov|m4v|avi|mkv)(\?.*)?$/i.test(src);
}
+99
View File
@@ -0,0 +1,99 @@
import { Inbox } from "lucide-react";
import type { Client } from "../../entities/client/types";
import type { MeResponse, ProfileOption } from "../../shared/api/mockApi";
import { initials } from "../../shared/lib/format";
export function TopBar({
me,
clients,
profileOptions,
activeProfileId,
activeClientId,
adminOpen,
onProfileChange,
onClientChange,
onOpenAdmin,
onOpenShowcase,
}: {
me: MeResponse;
clients: Client[];
profileOptions: ProfileOption[];
activeProfileId: string;
activeClientId: string;
adminOpen: boolean;
onProfileChange: (userId: string) => void;
onClientChange: (clientId: string) => void;
onOpenAdmin: () => void;
onOpenShowcase: () => void;
}) {
const availableClientIds = new Set(me.memberships.map((membership) => membership.clientId));
const availableClients = clients.filter((client) => availableClientIds.has(client.id));
const activeClient = availableClients.find((client) => client.id === activeClientId);
const activeProfile = profileOptions.find((profile) => profile.userId === activeProfileId);
return (
<header className="nodedc-expanded-toolbar-shell">
<div className="nodedc-expanded-toolbar">
<div className="nodedc-expanded-toolbar-top">
<div className="nodedc-expanded-toolbar-left">
<a href="/" aria-label="NODE.DC">
<img src="/nodedc-logo.svg" alt="NODE DC" className="nodedc-expanded-brand-logo" />
</a>
</div>
<div className="nodedc-expanded-toolbar-center">
<label className="nodedc-expanded-workspace-button" title={activeClient?.name ?? "Клиент"}>
<img src="/nodedc-mark.svg" alt="" className="nodedc-expanded-workspace-mark" />
<select value={activeClientId} onChange={(event) => onClientChange(event.target.value)} aria-label="Выбрать клиента">
{availableClients.map((client) => (
<option key={client.id} value={client.id}>
{client.name}
</option>
))}
</select>
</label>
<nav className="nodedc-expanded-nav-group" aria-label="Навигация лаунчера">
<button className="nodedc-expanded-nav-button" type="button" data-active={!adminOpen} onClick={onOpenShowcase}>
<span>Витрина</span>
</button>
<label className="nodedc-expanded-nav-button nodedc-expanded-select-button" data-active="false">
<span>{activeProfile?.label ?? me.user.name}</span>
<select value={activeProfileId} onChange={(event) => onProfileChange(event.target.value)} aria-label="Выбрать профиль">
{profileOptions.map((profile) => (
<option key={profile.userId} value={profile.userId}>
{profile.label}
</option>
))}
</select>
</label>
{me.permissions.canOpenAdmin ? (
<button className="nodedc-expanded-nav-button" type="button" data-active={adminOpen} onClick={onOpenAdmin}>
<span>Администрирование</span>
</button>
) : null}
</nav>
</div>
<div className="nodedc-expanded-toolbar-right">
<div className="nodedc-expanded-user-group" title={`${me.user.name} · ${me.user.email}`}>
<button className="nodedc-expanded-nav-button" type="button" data-active="false">
<span>Профиль</span>
</button>
<button className="nodedc-toolbar-icon-button nodedc-expanded-notification-button" type="button" data-active="false" aria-label="Уведомления">
<span className="nodedc-toolbar-icon-active-dot">
<Inbox size={20} strokeWidth={1.7} />
</span>
</button>
<button className="nodedc-expanded-user-avatar-button" type="button" aria-label="Профиль пользователя">
<span className="nodedc-expanded-user-avatar">{initials(me.user.name)}</span>
</button>
</div>
</div>
</div>
</div>
</header>
);
}