feat: add launcher frontend MVP
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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} />;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user