This commit is contained in:
Daniil
2026-02-28 17:41:14 +03:00
parent 71b974903a
commit 305e72725c
18 changed files with 416 additions and 53 deletions
+9
View File
@@ -0,0 +1,9 @@
import type { ComponentType } from "react"
export interface IActionCardProps {
icon: ComponentType<{ size?: number; strokeWidth?: number }>
label: string
onClick: () => void
accent?: boolean
className?: string
}
@@ -0,0 +1,47 @@
.card {
@include mixins.reset-button;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
width: 160px;
height: 160px;
background: variables.$bg-default;
border: 1px solid variables.$border-default;
border-radius: variables.$radius-lg;
box-shadow: var(--shadow-sm);
color: variables.$text-secondary;
cursor: pointer;
transition:
transform 0.15s ease,
box-shadow 0.15s ease,
border-color 0.15s ease,
color 0.15s ease;
&:hover {
transform: translateY(-3px);
box-shadow: var(--shadow-md);
color: variables.$text-primary;
}
&.accent {
background: variables.$purple-50;
border-color: variables.$purple-100;
color: variables.$purple-400;
&:hover {
background: variables.$purple-100;
border-color: variables.$purple-400;
}
}
}
.label {
@include typography.font-body-s;
}
@@ -0,0 +1,28 @@
import type { IActionCardProps } from "./ActionCard.d"
import type { JSX } from "react"
import { FunctionComponent } from "react"
import cs from "classnames"
import styles from "./ActionCard.module.scss"
export const ActionCard: FunctionComponent<IActionCardProps> = ({
icon: Icon,
label,
onClick,
accent = false,
className,
}): JSX.Element => {
return (
<button
type="button"
className={cs(styles.card, accent && styles.accent, className)}
onClick={onClick}
data-testid="ActionCard"
>
<Icon size={32} strokeWidth={1.5} />
<span className={styles.label}>{label}</span>
</button>
)
}
@@ -0,0 +1 @@
export * from "./ActionCard"
+1
View File
@@ -0,0 +1 @@
export { ActionCard } from "./ActionCard"
+3
View File
@@ -0,0 +1,3 @@
export interface IHomePageProps {
className?: string
}
+38 -26
View File
@@ -1,36 +1,48 @@
.homepage {
.root {
padding: 28px 24px 40px;
display: flex;
align-items: center;
flex-direction: column;
justify-content: center;
gap: 1rem;
height: 100vh;
color: #c7d0cc;
background: #000;
font-family: Roboto, sans-serif;
font-size: 2vw;
gap: 32px;
}
.path {
font-style: italic;
.welcome {
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
text-align: center;
min-height: 160px;
}
.title {
font-size: 4vw;
.greeting {
font-weight: 700;
font-size: 52px;
line-height: 1.1;
letter-spacing: -1px;
color: variables.$text-primary;
margin: 0;
}
.hint {
padding: 0.5rem;
pointer-events: none;
border: rgb(199 208 204 / 5%) 1px solid;
border-radius: 15px;
font-size: 1vw;
.subtitle {
@include typography.font-body-mr;
font-size: 18px;
color: variables.$text-secondary;
margin: 0;
}
.actionsSection {
display: flex;
flex-direction: column;
gap: 16px;
}
.actionsTitle {
@include typography.font-header-l;
color: variables.$text-primary;
margin: 0;
}
.actions {
display: flex;
gap: 16px;
}
+92 -10
View File
@@ -1,21 +1,103 @@
"use client"
import { useBreadcrumbs } from "@shared/context/BreadcrumbsContext"
import { Button } from "@shared/ui"
import type { JSX } from "react"
import { FunctionComponent, useMemo, useState } from "react"
import { FolderKanban, PlusIcon } from "lucide-react"
import { useRouter } from "next/navigation"
import { ActionCard } from "@entities/dashboard"
import { CreateProjectModal } from "@features/project"
import api from "@shared/api"
import { useBreadcrumbs } from "@shared/context/BreadcrumbsContext"
import { useAppSelector } from "@shared/hooks/useAppSelector"
import { StaticLoader } from "@shared/ui/Loader"
import { RecentProjects } from "@widgets/Dashboard/RecentProjects"
import { StatsGrid } from "@widgets/Dashboard/StatsGrid"
import { IHomePageProps } from "./HomePage.d"
import cls from "./HomePage.module.scss"
const HomePage = () => {
const RECENT_PROJECTS_LIMIT = 3
const WELCOME_SUBTITLES = [
"Управляйте своими проектами и отслеживайте их статус",
"Что будем делать сегодня?",
"Ваши проекты ждут вас",
"Создайте новый проект или продолжите работу над существующим",
"Всё под контролем — просматривайте и управляйте проектами",
]
export const HomePage: FunctionComponent<IHomePageProps> = (): JSX.Element => {
useBreadcrumbs([{ label: "Главная" }])
const router = useRouter()
const user = useAppSelector((state) => state.user.user)
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)
const { data: projects, isLoading, refetch } = api.useQuery("get", "/api/projects/")
const subtitle = useMemo(
() => WELCOME_SUBTITLES[Math.floor(Math.random() * WELCOME_SUBTITLES.length)],
[],
)
const stats = {
total: projects?.length ?? 0,
processing: projects?.filter((p) => p.status === "PROCESSING").length ?? 0,
done: projects?.filter((p) => p.status === "DONE").length ?? 0,
failed: projects?.filter((p) => p.status === "FAILED").length ?? 0,
}
const recentProjects = [...(projects ?? [])]
.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime())
.slice(0, RECENT_PROJECTS_LIMIT)
const userName = user?.first_name || user?.username || "пользователь"
return (
<div className={cls.homepage}>
<p className={cls.title}>Coffee Project Starter</p>
<pre className={cls.hint}>
Редактируйте <span className={cls.path}>src/pages/HomePage</span>{" "}
чтобы начать разработку.
</pre>
<Button variant="primary">Начать</Button>
<div className={cls.root}>
{isLoading && <StaticLoader fullscreen />}
<div className={cls.welcome}>
<h1 className={cls.greeting}>Добро пожаловать, {userName}</h1>
<p className={cls.subtitle}>{subtitle}</p>
</div>
<StatsGrid {...stats} />
<RecentProjects
projects={recentProjects}
isLoading={isLoading}
onProjectClick={(id) => router.push(`/projects/${id}`)}
onCreateClick={() => setIsCreateModalOpen(true)}
onViewAllClick={() => router.push("/projects")}
/>
<div className={cls.actionsSection}>
<h2 className={cls.actionsTitle}>Быстрые действия</h2>
<div className={cls.actions}>
<ActionCard
icon={PlusIcon}
label="Создать проект"
accent
onClick={() => setIsCreateModalOpen(true)}
/>
<ActionCard
icon={FolderKanban}
label="Все проекты"
onClick={() => router.push("/projects")}
/>
</div>
</div>
<CreateProjectModal
open={isCreateModalOpen}
onOpenChange={setIsCreateModalOpen}
onCreated={async () => {
await refetch()
}}
/>
</div>
)
}
@@ -0,0 +1,11 @@
import type { components } from "@shared/api/__generated__/openapi.types"
type ProjectRead = components["schemas"]["ProjectRead"]
export interface IRecentProjectsProps {
projects: ProjectRead[]
isLoading: boolean
onProjectClick: (id: string) => void
onCreateClick: () => void
onViewAllClick: () => void
}
@@ -0,0 +1,38 @@
.section {
display: flex;
flex-direction: column;
gap: 16px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
}
.title {
@include typography.font-header-l;
color: variables.$text-primary;
margin: 0;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 20px;
}
.empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
padding: 48px 0;
color: variables.$text-tertiary;
}
.emptyText {
@include typography.font-body-mr;
color: variables.$text-secondary;
margin: 0;
}
@@ -0,0 +1,56 @@
import type { IRecentProjectsProps } from "./RecentProjects.d"
import type { JSX } from "react"
import { FunctionComponent } from "react"
import { FolderOpenIcon, PlusIcon } from "lucide-react"
import { ProjectCard } from "@entities/ProjectCard"
import { Button } from "@shared/ui"
import styles from "./RecentProjects.module.scss"
export const RecentProjects: FunctionComponent<IRecentProjectsProps> = ({
projects,
isLoading,
onProjectClick,
onCreateClick,
onViewAllClick,
}): JSX.Element => {
const isEmpty = !isLoading && projects.length === 0
return (
<section className={styles.section} data-testid="RecentProjects">
<div className={styles.header}>
<h2 className={styles.title}>Последние проекты</h2>
{!isEmpty && (
<Button variant="ghost" size="sm" onClick={onViewAllClick}>
Все проекты
</Button>
)}
</div>
{isEmpty ? (
<div className={styles.empty}>
<FolderOpenIcon size={40} strokeWidth={1.5} />
<p className={styles.emptyText}>У вас пока нет проектов</p>
<Button variant="primary" onClick={onCreateClick}>
<PlusIcon /> Создать первый проект
</Button>
</div>
) : (
<div className={styles.grid}>
{projects.map((project) => (
<ProjectCard
key={project.id}
project={project}
progress={project.status === "PROCESSING" ? 45 : 0}
currentAction={project.status === "PROCESSING" ? "Обработка" : undefined}
onClick={() => onProjectClick(project.id)}
/>
))}
</div>
)}
</section>
)
}
@@ -0,0 +1 @@
export * from "./RecentProjects"
+6
View File
@@ -0,0 +1,6 @@
export interface IStatsGridProps {
total: number
processing: number
done: number
failed: number
}
@@ -0,0 +1,27 @@
.grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
}
.card {
display: flex;
flex-direction: column;
gap: 4px;
padding: 20px 24px;
background: variables.$bg-default;
border: 1px solid variables.$border-default;
border-radius: variables.$radius-md;
box-shadow: var(--shadow-sm);
}
.value {
@include typography.font-display;
color: variables.$text-primary;
line-height: 1;
}
.label {
@include typography.font-body-s;
color: variables.$text-secondary;
}
@@ -0,0 +1,37 @@
import type { IStatsGridProps } from "./StatsGrid.d"
import type { JSX } from "react"
import { FunctionComponent } from "react"
import styles from "./StatsGrid.module.scss"
interface IStatCardProps {
label: string
value: number
color?: string
}
const StatCard = ({ label, value, color }: IStatCardProps) => (
<div className={styles.card}>
<span className={styles.value} style={color ? { color } : undefined}>
{value}
</span>
<span className={styles.label}>{label}</span>
</div>
)
export const StatsGrid: FunctionComponent<IStatsGridProps> = ({
total,
processing,
done,
failed,
}): JSX.Element => {
return (
<div className={styles.grid} data-testid="StatsGrid">
<StatCard label="Всего проектов" value={total} />
<StatCard label="В процессе" value={processing} color="var(--color-warning)" />
<StatCard label="Готово" value={done} color="var(--color-success)" />
<StatCard label="Ошибка" value={failed} color="var(--color-danger)" />
</div>
)
}
+1
View File
@@ -0,0 +1 @@
export * from "./StatsGrid"
+6 -1
View File
@@ -2,7 +2,7 @@
import type { JSX } from "react"
import { FolderKanban, Menu as MenuIcon } from "lucide-react"
import { FolderKanban, Home, Menu as MenuIcon } from "lucide-react"
import dynamic from "next/dynamic"
import Link from "next/link"
import { FunctionComponent, useState } from "react"
@@ -63,6 +63,11 @@ export const Header: FunctionComponent<IHeaderProps> = (): JSX.Element => {
open={isDrawerOpen}
onClose={() => setIsDrawerOpen(false)}
buttons={[
{
label: "Главная",
icon: Home,
path: "/",
},
{
label: "Проекты",
icon: FolderKanban,
+14 -16
View File
@@ -19,6 +19,8 @@ import {
} from "lucide-react"
import { FunctionComponent, useCallback, useEffect, useRef, useState } from "react"
import { Tooltip } from "@radix-ui/themes"
import { DeleteFileModal } from "@features/project"
import {
useDeleteArtifact,
@@ -272,12 +274,11 @@ export const FileTree: FunctionComponent<IFileTreeProps> = ({
}
>
<Icon size={16} className={styles.fileIcon} />
<span
className={styles.fileName}
title={file.displayName}
>
<Tooltip delayDuration={1500} content={file.displayName}>
<span className={styles.fileName}>
{file.displayName}
</span>
</Tooltip>
</button>
<button
className={styles.removeButton}
@@ -371,12 +372,11 @@ export const FileTree: FunctionComponent<IFileTreeProps> = ({
}
>
<Icon size={16} className={styles.fileIcon} />
<span
className={styles.fileName}
title={file.original_filename}
>
<Tooltip delayDuration={1500} content={file.original_filename}>
<span className={styles.fileName}>
{file.original_filename}
</span>
</Tooltip>
</button>
{!used && (
<button
@@ -466,12 +466,11 @@ export const FileTree: FunctionComponent<IFileTreeProps> = ({
}
>
<Icon size={16} className={styles.fileIcon} />
<span
className={styles.fileName}
title={displayName}
>
<Tooltip delayDuration={1500} content={displayName}>
<span className={styles.fileName}>
{displayName}
</span>
</Tooltip>
</button>
{!used && (
<button
@@ -551,12 +550,11 @@ export const FileTree: FunctionComponent<IFileTreeProps> = ({
}
>
<Icon size={16} className={styles.fileIcon} />
<span
className={styles.fileName}
title={displayName}
>
<Tooltip delayDuration={1500} content={displayName}>
<span className={styles.fileName}>
{displayName}
</span>
</Tooltip>
</button>
{!used && (
<button