Add Django backend, Next.js frontend, and deploy templates

This commit is contained in:
ogrechko committed 2026-08-27 15:38:31 +00:00
1 parent fc0c991335
commit e9cd22d36f
93 files changed
+7840 -1

No files matched your search

+117
View File
@@ -0,0 +1,117 @@
"use client";
import { useEffect, useState } from "react";
import { Download, Eye, FileText } from "lucide-react";
type DocumentItem = {
id: number;
title: string;
file?: string;
mode: "preview" | "download" | "disabled";
};
type Props = {
items: DocumentItem[];
};
function getPreviewUrl(origin: string, file: string) {
const absoluteUrl = `${origin}/documents/${file}`;
const extension = file.split(".").pop()?.toLowerCase();
if (extension === "pdf") {
return `${absoluteUrl}#toolbar=0&navpanes=0&scrollbar=1`;
}
return `https://docs.google.com/gview?embedded=1&url=${encodeURIComponent(absoluteUrl)}`;
}
export function DocumentsViewer({ items }: Props) {
const firstPreviewable = items.find((item) => item.mode === "preview" && item.file) ?? null;
const [selectedId, setSelectedId] = useState<number>(firstPreviewable?.id ?? 0);
const [origin, setOrigin] = useState("");
useEffect(() => {
setOrigin(window.location.origin);
}, []);
const selectedItem =
items.find((item) => item.id === selectedId && item.mode === "preview" && item.file) ??
firstPreviewable;
return (
<div className="documents-layout">
<aside className="documents-sidebar">
<div className="documents-sidebar__panel">
{items.map((item) => {
if (item.mode === "download" && item.file) {
return (
<a
key={item.id}
href={`/documents/${item.file}`}
download
className="documents-link documents-link--download"
>
<Download size={16} />
<span>{item.title}</span>
</a>
);
}
if (item.mode === "disabled") {
return (
<div key={item.id} className="documents-link documents-link--disabled">
<FileText size={16} />
<span>{item.title}</span>
</div>
);
}
return (
<button
key={item.id}
type="button"
onClick={() => setSelectedId(item.id)}
className={`documents-link documents-link--button${
selectedItem?.id === item.id ? " documents-link--active" : ""
}`}
>
<Eye size={16} />
<span>{item.title}</span>
</button>
);
})}
</div>
</aside>
<div className="documents-content">
<article className="page-panel documents-preview">
<div className="documents-preview__header">
<div>
<span className="eyebrow">Просмотр документа</span>
<h2>{selectedItem?.title ?? "Документ не выбран"}</h2>
</div>
<div className="documents-preview__note">
<FileText size={18} />
<span>Для просмотра используйте список слева</span>
</div>
</div>
{selectedItem?.file && origin ? (
<div className="documents-preview__frame">
<iframe
key={`${origin}-${selectedItem.file}`}
src={getPreviewUrl(origin, selectedItem.file)}
title={selectedItem.title}
className="documents-preview__iframe"
/>
</div>
) : (
<div className="documents-preview__empty">
Документ загрузится после открытия страницы в браузере.
</div>
)}
</article>
</div>
</div>
);
}
+17
View File
@@ -0,0 +1,17 @@
import { FileText, Microscope, ShieldPlus, Stethoscope } from "lucide-react";
export function resolveIcon(name: string) {
const props = { size: 22, strokeWidth: 2 };
switch (name) {
case "microscope":
return <Microscope {...props} />;
case "stethoscope":
return <Stethoscope {...props} />;
case "file-text":
return <FileText {...props} />;
default:
return <ShieldPlus {...props} />;
}
}
+93
View File
@@ -0,0 +1,93 @@
import Link from "next/link";
import { ExternalLink, MapPinned, Phone } from "lucide-react";
type Props = {
siteName: string;
phone: string;
email: string;
address: string;
workingHours: string;
};
const footerLinks = [
{
title: "Госуслуги",
href: "https://www.gosuslugi.ru/",
icon: "https://www.google.com/s2/favicons?domain=www.gosuslugi.ru&sz=64",
},
{
title: "НМО Минздрава РФ",
href: "https://edu.rosminzdrav.ru/",
icon: "https://www.google.com/s2/favicons?domain=edu.rosminzdrav.ru&sz=64",
},
{
title: "Правительство СК",
href: "https://www.stavregion.ru/",
icon: "https://www.google.com/s2/favicons?domain=www.stavregion.ru&sz=64",
},
{
title: "ТФОМС",
href: "https://tfomssk.ru/",
icon: "https://www.google.com/s2/favicons?domain=spboms.ru&sz=64",
},
];
const yandexMapsHref =
"https://yandex.ru/maps/?text=%D0%A1%D1%82%D0%B0%D0%B2%D1%80%D0%BE%D0%BF%D0%BE%D0%BB%D1%8C%2C%20%D1%83%D0%BB%D0%B8%D1%86%D0%B0%20%D0%91%D0%B0%D0%BB%D0%B0%D0%BA%D0%B8%D1%80%D0%B5%D0%B2%D0%B0%2C%205%D0%BA7";
export function SiteFooter({ siteName, phone, email, address, workingHours }: Props) {
return (
<footer className="site-footer">
<div className="container">
<div className="footer-panel">
<div className="footer-tiles">
{footerLinks.map((item) => (
<Link
key={item.title}
href={item.href}
className="footer-tile"
target="_blank"
rel="noreferrer noopener"
>
<span className="footer-tile__main">
<span className="footer-tile__icon">
<img src={item.icon} alt="" width="20" height="20" loading="lazy" />
</span>
<span>{item.title}</span>
</span>
<ExternalLink size={14} />
</Link>
))}
</div>
<div className="footer-bottom">
<Link
href={yandexMapsHref}
className="footer-map"
target="_blank"
rel="noreferrer noopener"
>
<div className="footer-map__badge">
<MapPinned size={16} />
<span>Как нас найти</span>
</div>
<div className="footer-map__title">{address}</div>
<div className="footer-map__meta">{workingHours}</div>
</Link>
<div className="footer-contact">
<strong>{siteName}</strong>
<div className="footer-meta">{address}</div>
<div className="footer-meta">{workingHours}</div>
<a className="footer-contact__phone" href={`tel:${phone.replace(/[^\d+]/g, "")}`}>
<Phone size={16} />
<span>{phone}</span>
</a>
<div className="footer-meta">{email}</div>
</div>
</div>
</div>
</div>
</footer>
);
}
+63
View File
@@ -0,0 +1,63 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
type Props = {
siteName: string;
tagline: string;
phone: string;
};
const navItems = [
{ label: "Главная", href: "/" },
{ label: "Услуги", href: "/services" },
{ label: "Специалисты", href: "/specialists" },
{ label: "Документы", href: "/documents" },
{ label: "Отзывы", href: "/reviews" },
];
export function SiteHeader({ siteName, tagline, phone }: Props) {
const pathname = usePathname();
const compactName = siteName
.replace("Консультационное клиническое ", "")
.replace("консультационное клиническое ", "");
return (
<header className="site-header">
<div className="container site-header__inner">
<Link href="/" className="brand">
<span className="brand__eyebrow">Ставрополь</span>
<span className="brand__name">{compactName || "Патологоанатомическое бюро"}</span>
<span className="brand__tag">{tagline}</span>
</Link>
<nav className="nav" aria-label="Основная навигация">
{navItems.map((item) => {
const isActive =
item.href === "/"
? pathname === "/"
: pathname === item.href || pathname.startsWith(`${item.href}/`);
return (
<Link
key={item.href}
href={item.href}
className={`nav__link${isActive ? " nav__link--active" : ""}`}
>
{item.label}
</Link>
);
})}
</nav>
<div className="header-actions">
<a className="phone-pill" href={`tel:${phone.replace(/[^\d+]/g, "")}`}>
{phone}
</a>
</div>
</div>
</header>
);
}