move to biome (#297)

* switch to biome

* writer format

* migrate format

* packages format

* hudozka format

* writer fix

* biome config updated

* migrate fix

* packages fix

* hudozka fix

* claude updated
This commit is contained in:
tmshv authored and GitHub committed 2026-04-25 03:25:15 +03:00
1 parent 98dcaaa237
commit 467a218d00
112 files changed
+1472 -5914

No files matched your search

+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
run: npm ci
- name: Lint
run: npm run lint
run: npx biome ci
test:
name: Test
+3 -3
View File
@@ -12,8 +12,8 @@ From repo root (delegates to `apps/hudozka`):
- `npm run dev` — dev server with Turbo (`next dev --turbo`)
- `npm run build` — production build
- `npm run lint` — ESLint across the whole monorepo
- `npm run format` — auto-fix lint issues
- `npm run lint` — Biome lint + format check across the whole monorepo (`biome check`)
- `npm run format` — auto-fix lint and format issues (`biome check --write`)
- `npm test` — run unit tests (Vitest) across every workspace
Per-app:
@@ -35,7 +35,7 @@ Feature branches are named `issue-XXX` where `XXX` is the related GitHub issue n
## Code Style
ESLint 9 flat config (`eslint.config.js`) with native `eslint-config-next` 16.x support. Extends `next/core-web-vitals`. Enforces: no semicolons, double quotes, 4-space indentation, always-multiline trailing commas, `eol-last`.
Biome (`biome.json`) handles formatting and linting. Style: no semicolons, double quotes, 4-space indentation, always-multiline trailing commas, LF line endings, 120-column line width. Lint scope is limited to `**/src/**/*.{ts,tsx,js}` via `files.includes`. Console calls are restricted to `console.error`/`console.warn` (full `console.*` is allowed only inside `apps/migrate-to-pocketbase`). `noDangerouslySetInnerHtml` is disabled because the site renders trusted CMS HTML. Inline `biome-ignore` / `eslint-disable` comments are not used — fix the underlying issue instead.
**React components:** Use regular function declarations (not arrow functions) and `type` for props. Do not use `React.FC`.
+20 -16
View File
@@ -1,7 +1,7 @@
import { useState, useEffect, useCallback } from "react"
import { pb } from "./pb"
import { Login } from "./components/Login"
import { useCallback, useEffect, useState } from "react"
import { Editor } from "./components/Editor"
import { Login } from "./components/Login"
import { pb } from "./pb"
import type { PbPage } from "./types"
import "./App.css"
@@ -22,9 +22,7 @@ export function App() {
setLoading(true)
setError("")
try {
const record = await pb.collection("pages").getFirstListItem<PbPage>(
`slug="${slug}"`,
)
const record = await pb.collection("pages").getFirstListItem<PbPage>(`slug="${slug}"`)
setPage(record)
} catch {
setError(`Page "${slug}" not found`)
@@ -87,8 +85,12 @@ export function App() {
if (!slug) {
return (
<div className="app-message">
<p>Add <code>?page=slug</code> to the URL to edit a page.</p>
<button onClick={handleLogout}>Logout</button>
<p>
Add <code>?page=slug</code> to the URL to edit a page.
</p>
<button type="button" onClick={handleLogout}>
Logout
</button>
</div>
)
}
@@ -101,7 +103,9 @@ export function App() {
return (
<div className="app-message">
<p className="app-error">{error}</p>
<button onClick={handleLogout}>Logout</button>
<button type="button" onClick={handleLogout}>
Logout
</button>
</div>
)
}
@@ -116,15 +120,15 @@ export function App() {
<span className="app-title">{page.title}</span>
<span className="app-slug">/{page.slug}</span>
<label className="app-published-toggle">
<input
type="checkbox"
checked={page.published}
onChange={handleTogglePublished}
/>
<input type="checkbox" checked={page.published} onChange={handleTogglePublished} />
Published
</label>
<button onClick={handleDuplicate}>Duplicate</button>
<button onClick={handleLogout}>Logout</button>
<button type="button" onClick={handleDuplicate}>
Duplicate
</button>
<button type="button" onClick={handleLogout}>
Logout
</button>
</header>
<main className="app-main">
<Editor page={page} />
@@ -1,6 +1,6 @@
import { useEffect, useCallback } from "react"
import type { Editor } from "@tiptap/react"
import { Fragment } from "@tiptap/pm/model"
import type { Editor } from "@tiptap/react"
import { useCallback, useEffect } from "react"
import "./BlockActions.css"
export type BlockActionsProps = {
@@ -68,10 +68,14 @@ export function BlockActions({ editor, getPos, showSplit }: BlockActionsProps) {
if (typeof pos !== "number") return
const node = editor.state.doc.nodeAt(pos)
if (!node) return
editor.chain().focus().deleteRange({
from: pos,
to: pos + node.nodeSize,
}).run()
editor
.chain()
.focus()
.deleteRange({
from: pos,
to: pos + node.nodeSize,
})
.run()
}
const handleSplit = (e: React.MouseEvent) => {
@@ -120,6 +124,7 @@ export function BlockActions({ editor, getPos, showSplit }: BlockActionsProps) {
return (
<div className="block-actions" contentEditable={false}>
<button
type="button"
className="block-action-btn block-action-delete"
title="Delete block"
onClick={handleDelete}
@@ -128,6 +133,7 @@ export function BlockActions({ editor, getPos, showSplit }: BlockActionsProps) {
</button>
{showSplit && (
<button
type="button"
className="block-action-btn block-action-split"
title="Split block"
onClick={handleSplit}
@@ -135,12 +141,7 @@ export function BlockActions({ editor, getPos, showSplit }: BlockActionsProps) {
&#x2702;
</button>
)}
<div
className="block-action-btn block-drag-handle"
data-drag-handle
draggable
title="Drag to reorder"
>
<div className="block-action-btn block-drag-handle" data-drag-handle draggable title="Drag to reorder">
&#x2807;
</div>
</div>
@@ -1,5 +1,5 @@
import { useState } from "react"
import type { Editor } from "@tiptap/react"
import { useState } from "react"
import { BlockMenu } from "./BlockMenu"
import "./BlockInsert.css"
@@ -30,20 +30,12 @@ export function BlockInsert({ editor, getPos }: BlockInsertProps) {
return (
<div className="block-insert" contentEditable={false}>
<button
className="block-insert-btn"
title="Add block"
onClick={handleToggle}
>
<button type="button" className="block-insert-btn" title="Add block" onClick={handleToggle}>
+
</button>
{menuOpen && (
<div className="block-insert-menu">
<BlockMenu
editor={editor}
position={insertPos}
onClose={handleClose}
/>
<BlockMenu editor={editor} position={insertPos} onClose={handleClose} />
</div>
)}
</div>
@@ -25,21 +25,29 @@ export type BlockMenuProps = {
export function BlockMenu({ editor, position, onClose, filter }: BlockMenuProps) {
const filtered = filter
? BLOCK_TYPES.filter((b) => b.label.toLowerCase().includes(filter.toLowerCase()))
? BLOCK_TYPES.filter(b => b.label.toLowerCase().includes(filter.toLowerCase()))
: BLOCK_TYPES
function handleInsert(block: BlockType) {
const id = generateBlockId()
if (block.type === "textBlock") {
editor.chain().focus().insertContentAt(position, {
type: "textBlock",
content: [{ type: "paragraph" }],
}).run()
editor
.chain()
.focus()
.insertContentAt(position, {
type: "textBlock",
content: [{ type: "paragraph" }],
})
.run()
} else {
editor.chain().focus().insertContentAt(position, {
type: block.type,
attrs: { ...block.attrs, id },
}).run()
editor
.chain()
.focus()
.insertContentAt(position, {
type: block.type,
attrs: { ...block.attrs, id },
})
.run()
}
onClose()
}
@@ -48,12 +56,8 @@ export function BlockMenu({ editor, position, onClose, filter }: BlockMenuProps)
return (
<div className="block-menu">
{filtered.map((block) => (
<button
key={block.type}
className="block-menu-item"
onClick={() => handleInsert(block)}
>
{filtered.map(block => (
<button type="button" key={block.type} className="block-menu-item" onClick={() => handleInsert(block)}>
{block.label}
</button>
))}
@@ -61,5 +65,5 @@ export function BlockMenu({ editor, position, onClose, filter }: BlockMenuProps)
)
}
export { BLOCK_TYPES }
export type { BlockType }
export { BLOCK_TYPES }
@@ -8,7 +8,7 @@ export function DraftBanner({ onDiscard }: DraftBannerProps) {
return (
<div className="draft-banner">
<span>You have unpublished changes</span>
<button onClick={onDiscard} className="draft-banner-discard">
<button type="button" onClick={onDiscard} className="draft-banner-discard">
Discard
</button>
</div>
+13 -27
View File
@@ -1,24 +1,17 @@
import { useEditor, EditorContent } from "@tiptap/react"
import StarterKit from "@tiptap/starter-kit"
import { Markdown } from "@tiptap/markdown"
import Link from "@tiptap/extension-link"
import { useState, useCallback, useRef, useEffect } from "react"
import { pb } from "../pb"
import { docToTiptap, tiptapToDoc } from "../lib/serialize"
import { Toolbar } from "./Toolbar"
import { DraftBanner } from "./DraftBanner"
import {
WriterDocument,
TextBlock,
ImageBlock,
DocumentBlock,
EmbedBlock,
CardGridBlock,
} from "../nodes"
import { Markdown } from "@tiptap/markdown"
import { EditorContent, useEditor } from "@tiptap/react"
import StarterKit from "@tiptap/starter-kit"
import { useCallback, useRef, useState } from "react"
import { SlashCommands } from "../extensions/SlashCommands"
import { docToTiptap, tiptapToDoc } from "../lib/serialize"
import { CardGridBlock, DocumentBlock, EmbedBlock, ImageBlock, TextBlock, WriterDocument } from "../nodes"
import { pb } from "../pb"
import { DraftBanner } from "./DraftBanner"
import { Toolbar } from "./Toolbar"
import "../nodes/nodes.css"
import "./Editor.css"
import type { PbPage, DocV1 } from "../types"
import type { DocV1, PbPage } from "../types"
function serializeDoc(doc: DocV1): string {
return JSON.stringify(doc)
@@ -37,15 +30,10 @@ export function Editor({ page }: EditorProps) {
const [hasDraft, setHasDraft] = useState(page.draft !== null)
const [publishedDoc, setPublishedDoc] = useState(page.doc)
const snapshotRef = useRef("")
const initialDoc = page.draft ?? page.doc
const tiptapDoc = docToTiptap(initialDoc)
// Set initial snapshot
useEffect(() => {
snapshotRef.current = serializeDoc(initialDoc)
}, []) // eslint-disable-line react-hooks/exhaustive-deps
const snapshotRef = useRef(serializeDoc(initialDoc))
const editor = useEditor({
extensions: [
@@ -157,14 +145,12 @@ export function Editor({ page }: EditorProps) {
markdownMode={markdownMode}
onToggleMarkdown={handleToggleMarkdown}
/>
{hasDraft && (
<DraftBanner onDiscard={handleDiscard} />
)}
{hasDraft && <DraftBanner onDiscard={handleDiscard} />}
{markdownMode ? (
<textarea
className="editor-markdown"
value={markdownText}
onChange={(e) => setMarkdownText(e.target.value)}
onChange={e => setMarkdownText(e.target.value)}
/>
) : (
<EditorContent editor={editor} className="editor-content" />
@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from "react"
import { useEffect, useRef, useState } from "react"
import { pb } from "../pb"
import type { PbFile } from "../types"
import "./FilePicker.css"
@@ -18,23 +18,25 @@ export function FilePicker({ onSelect, onClose }: FilePickerProps) {
const fileRef = useRef<HTMLInputElement>(null)
useEffect(() => {
loadFiles(page)
}, [page])
async function loadFiles(p: number) {
let cancelled = false
setLoading(true)
try {
const result = await pb.collection("files").getList<PbFile>(p, 20, {
sort: "-created",
pb.collection("files")
.getList<PbFile>(page, 20, { sort: "-created" })
.then(result => {
if (cancelled) return
setFiles(result.items)
setTotalPages(result.totalPages)
})
setFiles(result.items)
setTotalPages(result.totalPages)
} catch (err) {
console.error("Failed to load files:", err)
} finally {
setLoading(false)
.catch(err => {
if (!cancelled) console.error("Failed to load files:", err)
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}
}, [page])
async function handleUpload() {
const file = fileRef.current?.files?.[0]
@@ -55,22 +57,30 @@ export function FilePicker({ onSelect, onClose }: FilePickerProps) {
}
return (
<div className="picker-overlay" onClick={onClose}>
<div className="picker" onClick={(e) => e.stopPropagation()}>
<div
className="picker-overlay"
onClick={onClose}
onKeyDown={e => {
if (e.key === "Escape") onClose()
}}
>
<div
className="picker"
onClick={e => e.stopPropagation()}
onKeyDown={e => {
e.stopPropagation()
}}
>
<div className="picker-header">
<button
className={tab === "browse" ? "active" : ""}
onClick={() => setTab("browse")}
>
<button type="button" className={tab === "browse" ? "active" : ""} onClick={() => setTab("browse")}>
Browse
</button>
<button
className={tab === "upload" ? "active" : ""}
onClick={() => setTab("upload")}
>
<button type="button" className={tab === "upload" ? "active" : ""} onClick={() => setTab("upload")}>
Upload
</button>
<button className="picker-close" onClick={onClose}>×</button>
<button type="button" className="picker-close" onClick={onClose}>
×
</button>
</div>
{tab === "browse" && (
@@ -79,8 +89,9 @@ export function FilePicker({ onSelect, onClose }: FilePickerProps) {
<div className="picker-loading">Loading...</div>
) : (
<div className="file-picker-list">
{files.map((f) => (
{files.map(f => (
<button
type="button"
key={f.id}
className="file-picker-item"
onClick={() => onSelect(f.id)}
@@ -92,17 +103,13 @@ export function FilePicker({ onSelect, onClose }: FilePickerProps) {
</div>
)}
<div className="picker-pagination">
<button
disabled={page <= 1}
onClick={() => setPage((p) => p - 1)}
>
<button type="button" disabled={page <= 1} onClick={() => setPage(p => p - 1)}>
Prev
</button>
<span>{page} / {totalPages}</span>
<button
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
>
<span>
{page} / {totalPages}
</span>
<button type="button" disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}>
Next
</button>
</div>
@@ -112,7 +119,7 @@ export function FilePicker({ onSelect, onClose }: FilePickerProps) {
{tab === "upload" && (
<div className="picker-upload">
<input type="file" ref={fileRef} />
<button onClick={handleUpload} disabled={uploading}>
<button type="button" onClick={handleUpload} disabled={uploading}>
{uploading ? "Uploading..." : "Upload"}
</button>
</div>
@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from "react"
import { useEffect, useRef, useState } from "react"
import { pb } from "../pb"
import type { PbImage } from "../types"
import "./ImagePicker.css"
@@ -18,23 +18,25 @@ export function ImagePicker({ onSelect, onClose }: ImagePickerProps) {
const fileRef = useRef<HTMLInputElement>(null)
useEffect(() => {
loadImages(page)
}, [page])
async function loadImages(p: number) {
let cancelled = false
setLoading(true)
try {
const result = await pb.collection("images").getList<PbImage>(p, 20, {
sort: "-created",
pb.collection("images")
.getList<PbImage>(page, 20, { sort: "-created" })
.then(result => {
if (cancelled) return
setImages(result.items)
setTotalPages(result.totalPages)
})
setImages(result.items)
setTotalPages(result.totalPages)
} catch (err) {
console.error("Failed to load images:", err)
} finally {
setLoading(false)
.catch(err => {
if (!cancelled) console.error("Failed to load images:", err)
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}
}, [page])
async function handleUpload() {
const file = fileRef.current?.files?.[0]
@@ -64,22 +66,30 @@ export function ImagePicker({ onSelect, onClose }: ImagePickerProps) {
}
return (
<div className="picker-overlay" onClick={onClose}>
<div className="picker" onClick={(e) => e.stopPropagation()}>
<div
className="picker-overlay"
onClick={onClose}
onKeyDown={e => {
if (e.key === "Escape") onClose()
}}
>
<div
className="picker"
onClick={e => e.stopPropagation()}
onKeyDown={e => {
e.stopPropagation()
}}
>
<div className="picker-header">
<button
className={tab === "browse" ? "active" : ""}
onClick={() => setTab("browse")}
>
<button type="button" className={tab === "browse" ? "active" : ""} onClick={() => setTab("browse")}>
Browse
</button>
<button
className={tab === "upload" ? "active" : ""}
onClick={() => setTab("upload")}
>
<button type="button" className={tab === "upload" ? "active" : ""} onClick={() => setTab("upload")}>
Upload
</button>
<button className="picker-close" onClick={onClose}>×</button>
<button type="button" className="picker-close" onClick={onClose}>
×
</button>
</div>
{tab === "browse" && (
@@ -88,8 +98,9 @@ export function ImagePicker({ onSelect, onClose }: ImagePickerProps) {
<div className="picker-loading">Loading...</div>
) : (
<div className="picker-grid">
{images.map((img) => (
{images.map(img => (
<button
type="button"
key={img.id}
className="picker-thumb"
onClick={() => onSelect(img.id)}
@@ -100,17 +111,13 @@ export function ImagePicker({ onSelect, onClose }: ImagePickerProps) {
</div>
)}
<div className="picker-pagination">
<button
disabled={page <= 1}
onClick={() => setPage((p) => p - 1)}
>
<button type="button" disabled={page <= 1} onClick={() => setPage(p => p - 1)}>
Prev
</button>
<span>{page} / {totalPages}</span>
<button
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
>
<span>
{page} / {totalPages}
</span>
<button type="button" disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}>
Next
</button>
</div>
@@ -120,7 +127,7 @@ export function ImagePicker({ onSelect, onClose }: ImagePickerProps) {
{tab === "upload" && (
<div className="picker-upload">
<input type="file" accept="image/*" ref={fileRef} />
<button onClick={handleUpload} disabled={uploading}>
<button type="button" onClick={handleUpload} disabled={uploading}>
{uploading ? "Uploading..." : "Upload"}
</button>
</div>
+3 -3
View File
@@ -1,5 +1,5 @@
import { useState } from "react"
import type { FormEvent } from "react"
import { useState } from "react"
import { pb } from "../pb"
import "./Login.css"
@@ -36,14 +36,14 @@ export function Login({ onLogin }: LoginProps) {
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
onChange={e => setEmail(e.target.value)}
required
/>
<input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
onChange={e => setPassword(e.target.value)}
required
/>
<button type="submit" disabled={loading}>
+15 -5
View File
@@ -33,6 +33,7 @@ export function Toolbar({
<div className="toolbar">
<div className="toolbar-group">
<button
type="button"
onClick={() => editor.chain().focus().toggleBold().run()}
className={editor.isActive("bold") ? "active" : ""}
title="Bold"
@@ -40,6 +41,7 @@ export function Toolbar({
B
</button>
<button
type="button"
onClick={() => editor.chain().focus().toggleItalic().run()}
className={editor.isActive("italic") ? "active" : ""}
title="Italic"
@@ -47,6 +49,7 @@ export function Toolbar({
I
</button>
<button
type="button"
onClick={() => editor.chain().focus().toggleStrike().run()}
className={editor.isActive("strike") ? "active" : ""}
title="Strikethrough"
@@ -54,6 +57,7 @@ export function Toolbar({
S
</button>
<button
type="button"
onClick={() => editor.chain().focus().toggleCode().run()}
className={editor.isActive("code") ? "active" : ""}
title="Inline code"
@@ -64,18 +68,21 @@ export function Toolbar({
<div className="toolbar-group">
<button
type="button"
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
className={editor.isActive("heading", { level: 1 }) ? "active" : ""}
>
H1
</button>
<button
type="button"
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
className={editor.isActive("heading", { level: 2 }) ? "active" : ""}
>
H2
</button>
<button
type="button"
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
className={editor.isActive("heading", { level: 3 }) ? "active" : ""}
>
@@ -85,6 +92,7 @@ export function Toolbar({
<div className="toolbar-group">
<button
type="button"
onClick={() => editor.chain().focus().toggleBulletList().run()}
className={editor.isActive("bulletList") ? "active" : ""}
title="Bullet list"
@@ -92,6 +100,7 @@ export function Toolbar({
List
</button>
<button
type="button"
onClick={() => editor.chain().focus().toggleOrderedList().run()}
className={editor.isActive("orderedList") ? "active" : ""}
title="Numbered list"
@@ -99,6 +108,7 @@ export function Toolbar({
1.
</button>
<button
type="button"
onClick={() => editor.chain().focus().toggleBlockquote().run()}
className={editor.isActive("blockquote") ? "active" : ""}
title="Blockquote"
@@ -106,6 +116,7 @@ export function Toolbar({
&ldquo;
</button>
<button
type="button"
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
className={editor.isActive("codeBlock") ? "active" : ""}
title="Code block"
@@ -113,6 +124,7 @@ export function Toolbar({
Code
</button>
<button
type="button"
onClick={() => editor.chain().focus().setHorizontalRule().run()}
title="Horizontal rule"
>
@@ -122,6 +134,7 @@ export function Toolbar({
<div className="toolbar-group">
<button
type="button"
onClick={onToggleMarkdown}
className={markdownMode ? "active" : ""}
title="Toggle markdown view"
@@ -131,14 +144,11 @@ export function Toolbar({
</div>
<div className="toolbar-group toolbar-actions">
<button
onClick={onSave}
disabled={!canSave || saving}
className="toolbar-save-btn"
>
<button type="button" onClick={onSave} disabled={!canSave || saving} className="toolbar-save-btn">
{saving ? "Saving..." : "Save"}
</button>
<button
type="button"
onClick={onPublish}
disabled={!canPublish || publishing}
className="toolbar-publish-btn"
@@ -1,12 +1,12 @@
import { Extension } from "@tiptap/core"
import { PluginKey } from "@tiptap/pm/state"
import type { Node as PmNode, ResolvedPos } from "@tiptap/pm/model"
import { Fragment } from "@tiptap/pm/model"
import type { ResolvedPos, Node as PmNode } from "@tiptap/pm/model"
import { PluginKey } from "@tiptap/pm/state"
import type { Editor } from "@tiptap/react"
import type { SuggestionKeyDownProps, SuggestionOptions, SuggestionProps } from "@tiptap/suggestion"
import Suggestion from "@tiptap/suggestion"
import type { SuggestionOptions, SuggestionProps, SuggestionKeyDownProps } from "@tiptap/suggestion"
import { createRoot } from "react-dom/client"
import { generateBlockId } from "../lib/id"
import type { Editor } from "@tiptap/react"
import "./SlashCommands.css"
type SlashCommandItem = {
@@ -34,13 +34,22 @@ function findTextBlockDepth($pos: ResolvedPos): number | null {
return null
}
function SlashMenu({ items, command, selectedIndex }: { items: SlashCommandItem[]; command: (item: SlashCommandItem) => void; selectedIndex: number }) {
function SlashMenu({
items,
command,
selectedIndex,
}: {
items: SlashCommandItem[]
command: (item: SlashCommandItem) => void
selectedIndex: number
}) {
if (items.length === 0) return null
return (
<div className="slash-menu">
{items.map((item, index) => (
<button
type="button"
key={item.type}
className={`slash-menu-item${index === selectedIndex ? " slash-menu-item--selected" : ""}`}
onClick={() => command(item)}
@@ -62,9 +71,7 @@ export const SlashCommands = Extension.create({
pluginKey: slashCommandsPluginKey,
startOfLine: true,
items: ({ query }: { query: string }) => {
return ITEMS.filter((item) =>
item.label.toLowerCase().includes(query.toLowerCase())
)
return ITEMS.filter(item => item.label.toLowerCase().includes(query.toLowerCase()))
},
render: () => {
let container: HTMLDivElement | null = null
@@ -74,13 +81,9 @@ export const SlashCommands = Extension.create({
let currentCommand: ((item: SlashCommandItem) => void) | null = null
function renderMenu() {
if (!root) return
if (!root || !currentCommand) return
root.render(
<SlashMenu
items={currentItems}
command={currentCommand!}
selectedIndex={selectedIndex}
/>
<SlashMenu items={currentItems} command={currentCommand} selectedIndex={selectedIndex} />,
)
}
@@ -153,7 +156,15 @@ export const SlashCommands = Extension.create({
},
}
},
command: ({ editor, range, props }: { editor: Editor; range: { from: number; to: number }; props: SlashCommandItem }) => {
command: ({
editor,
range,
props,
}: {
editor: Editor
range: { from: number; to: number }
props: SlashCommandItem
}) => {
// Delete the slash command text
editor.chain().focus().deleteRange(range).run()
@@ -193,7 +204,7 @@ export const SlashCommands = Extension.create({
if (curChild.content.size === 0 && i > 0) {
splitAfter = i - 1
} else if (curChild.content.size === 0 && i === 0) {
splitAfter = -1 // cursor in empty first child
splitAfter = -1 // cursor in empty first child
} else {
splitAfter = i
}
@@ -213,16 +224,14 @@ export const SlashCommands = Extension.create({
children.push(textBlockNode.child(i))
}
// Skip if all children are empty paragraphs
const hasContent = children.some((c) => c.content.size > 0)
const hasContent = children.some(c => c.content.size > 0)
if (hasContent) {
fragments.push(textBlockType.create(null, children))
}
}
// Insert the custom block
fragments.push(schema.nodes[props.type].create(
{ ...props.attrs, id },
))
fragments.push(schema.nodes[props.type].create({ ...props.attrs, id }))
// Build second textBlock (children after splitAfter) — skip empty
const remaining = []
@@ -231,7 +240,7 @@ export const SlashCommands = Extension.create({
remaining.push(textBlockNode.child(i))
}
// Filter out empty paragraphs at the edges
const nonEmpty = remaining.filter((c) => c.content.size > 0)
const nonEmpty = remaining.filter(c => c.content.size > 0)
if (nonEmpty.length > 0) {
fragments.push(textBlockType.create(null, remaining))
}
+2 -5
View File
@@ -1,16 +1,13 @@
import Link from "@tiptap/extension-link"
import { MarkdownManager } from "@tiptap/markdown"
import StarterKit from "@tiptap/starter-kit"
import Link from "@tiptap/extension-link"
let manager: MarkdownManager | null = null
export function getMarkdownManager(): MarkdownManager {
if (!manager) {
manager = new MarkdownManager({
extensions: [
StarterKit,
Link,
],
extensions: [StarterKit, Link],
})
}
return manager
+67 -67
View File
@@ -2,11 +2,11 @@ import type { JSONContent } from "@tiptap/core"
import type {
DocV1,
DocV1Block,
DocV1BlockText,
DocV1BlockImage,
DocV1BlockCardGrid,
DocV1BlockDocument,
DocV1BlockEmbed,
DocV1BlockCardGrid,
DocV1BlockImage,
DocV1BlockText,
} from "../types"
import { generateBlockId } from "./id"
import { getMarkdownManager } from "./markdown"
@@ -70,21 +70,21 @@ export function docToTiptap(doc: DocV1): JSONContent {
for (const block of doc.blocks) {
switch (block.type) {
case "text":
content.push(textBlockToTiptap(block))
break
case "image":
content.push(imageBlockToTiptap(block))
break
case "document":
content.push(documentBlockToTiptap(block))
break
case "embed":
content.push(embedBlockToTiptap(block))
break
case "card-grid":
content.push(cardGridBlockToTiptap(block))
break
case "text":
content.push(textBlockToTiptap(block))
break
case "image":
content.push(imageBlockToTiptap(block))
break
case "document":
content.push(documentBlockToTiptap(block))
break
case "embed":
content.push(embedBlockToTiptap(block))
break
case "card-grid":
content.push(cardGridBlockToTiptap(block))
break
}
}
@@ -107,60 +107,60 @@ export function tiptapToDoc(content: JSONContent[]): DocV1 {
for (const node of content) {
switch (node.type) {
case "textBlock": {
const text = textNodesToMarkdown(node.content ?? [])
if (text.trim()) {
blocks.push({
type: "text",
id: generateBlockId(),
text,
})
case "textBlock": {
const text = textNodesToMarkdown(node.content ?? [])
if (text.trim()) {
blocks.push({
type: "text",
id: generateBlockId(),
text,
})
}
break
}
break
}
case "imageBlock": {
const attrs = node.attrs ?? {}
blocks.push({
type: "image",
id: attrs.id ?? generateBlockId(),
image: attrs.imageId ?? "",
wide: attrs.wide ?? false,
caption: attrs.caption ?? "",
})
break
}
case "imageBlock": {
const attrs = node.attrs ?? {}
blocks.push({
type: "image",
id: attrs.id ?? generateBlockId(),
image: attrs.imageId ?? "",
wide: attrs.wide ?? false,
caption: attrs.caption ?? "",
})
break
}
case "documentBlock": {
const attrs = node.attrs ?? {}
blocks.push({
type: "document",
id: attrs.id ?? generateBlockId(),
file: attrs.fileId ?? "",
title: attrs.title ?? "",
})
break
}
case "documentBlock": {
const attrs = node.attrs ?? {}
blocks.push({
type: "document",
id: attrs.id ?? generateBlockId(),
file: attrs.fileId ?? "",
title: attrs.title ?? "",
})
break
}
case "embedBlock": {
const attrs = node.attrs ?? {}
blocks.push({
type: "embed",
id: attrs.id ?? generateBlockId(),
src: attrs.src ?? "",
})
break
}
case "embedBlock": {
const attrs = node.attrs ?? {}
blocks.push({
type: "embed",
id: attrs.id ?? generateBlockId(),
src: attrs.src ?? "",
})
break
}
case "cardGridBlock": {
const attrs = node.attrs ?? {}
blocks.push({
type: "card-grid",
id: attrs.id ?? generateBlockId(),
items: JSON.parse(attrs.items ?? "[]"),
})
break
}
case "cardGridBlock": {
const attrs = node.attrs ?? {}
blocks.push({
type: "card-grid",
id: attrs.id ?? generateBlockId(),
items: JSON.parse(attrs.items ?? "[]"),
})
break
}
}
}
+4 -1
View File
@@ -3,7 +3,10 @@ import { createRoot } from "react-dom/client"
import { App } from "./App"
import "./index.css"
createRoot(document.getElementById("root")!).render(
const rootElement = document.getElementById("root")
if (!rootElement) throw new Error("Root element #root not found")
createRoot(rootElement).render(
<StrictMode>
<App />
</StrictMode>,
+12 -14
View File
@@ -1,11 +1,11 @@
import { Node, mergeAttributes } from "@tiptap/core"
import { ReactNodeViewRenderer, NodeViewWrapper } from "@tiptap/react"
import { mergeAttributes, Node } from "@tiptap/core"
import type { NodeViewProps } from "@tiptap/react"
import { useState, useEffect } from "react"
import { pb } from "../pb"
import type { PbPage } from "../types"
import { NodeViewWrapper, ReactNodeViewRenderer } from "@tiptap/react"
import { useEffect, useMemo, useState } from "react"
import { BlockActions } from "../components/BlockActions"
import { BlockInsert } from "../components/BlockInsert"
import { pb } from "../pb"
import type { PbPage } from "../types"
type ResolvedItem = {
page: string
@@ -13,7 +13,7 @@ type ResolvedItem = {
}
function CardGridBlockView({ node, editor, getPos }: NodeViewProps) {
const items = JSON.parse(node.attrs.items || "[]")
const items = useMemo<Array<{ page: string }>>(() => JSON.parse(node.attrs.items || "[]"), [node.attrs.items])
const [resolved, setResolved] = useState<ResolvedItem[]>([])
const [loading, setLoading] = useState(false)
@@ -25,33 +25,31 @@ function CardGridBlockView({ node, editor, getPos }: NodeViewProps) {
setLoading(true)
Promise.all(
items.map(async (item: { page: string }) => {
items.map(async item => {
try {
const page = await pb.collection("pages").getOne<PbPage>(item.page)
return { page: item.page, title: page.title }
} catch {
return { page: item.page, title: null }
}
})
).then((results) => {
}),
).then(results => {
setResolved(results)
setLoading(false)
})
}, [node.attrs.items])
}, [items])
return (
<>
<NodeViewWrapper className="node-view-block node-readonly-block">
<BlockActions editor={editor} getPos={getPos} />
<div className="node-view-content">
<div className="node-block-label">
Card Grid ({items.length})
</div>
<div className="node-block-label">Card Grid ({items.length})</div>
{loading ? (
<div className="node-block-info">Loading...</div>
) : (
<ul className="node-cardgrid-list">
{resolved.map((item) => (
{resolved.map(item => (
<li key={item.page} className="node-cardgrid-item">
{item.title ?? item.page}
</li>
+23 -26
View File
@@ -1,12 +1,12 @@
import { Node, mergeAttributes } from "@tiptap/core"
import { ReactNodeViewRenderer, NodeViewWrapper } from "@tiptap/react"
import { mergeAttributes, Node } from "@tiptap/core"
import type { NodeViewProps } from "@tiptap/react"
import { useState, useEffect } from "react"
import { pb } from "../pb"
import { FilePicker } from "../components/FilePicker"
import type { PbFile } from "../types"
import { NodeViewWrapper, ReactNodeViewRenderer } from "@tiptap/react"
import { useEffect, useState } from "react"
import { BlockActions } from "../components/BlockActions"
import { BlockInsert } from "../components/BlockInsert"
import { FilePicker } from "../components/FilePicker"
import { pb } from "../pb"
import type { PbFile } from "../types"
function DocumentBlockView({ node, updateAttributes, editor, getPos }: NodeViewProps) {
const { fileId, title } = node.attrs
@@ -15,11 +15,14 @@ function DocumentBlockView({ node, updateAttributes, editor, getPos }: NodeViewP
useEffect(() => {
if (!fileId) return
pb.collection("files").getOne<PbFile>(fileId).then((f) => {
setFilename(f.filename)
}).catch(() => {
setFilename(null)
})
pb.collection("files")
.getOne<PbFile>(fileId)
.then(f => {
setFilename(f.filename)
})
.catch(() => {
setFilename(null)
})
}, [fileId])
function handleSelect(newFileId: string) {
@@ -34,43 +37,37 @@ function DocumentBlockView({ node, updateAttributes, editor, getPos }: NodeViewP
<div className="node-view-content">
<div className="node-block-label">File</div>
{fileId ? (
<div
<button
type="button"
className="node-document-file"
onClick={() => setShowPicker(true)}
style={{ cursor: "pointer" }}
>
📎 {filename ?? fileId}
</div>
</button>
) : (
<div
<button
type="button"
className="node-document-placeholder"
onClick={() => setShowPicker(true)}
style={{ cursor: "pointer" }}
>
Click to select file
</div>
</button>
)}
<div className="node-document-controls">
<input
type="text"
placeholder="Title"
value={title || ""}
onChange={(e) => updateAttributes({ title: e.target.value })}
onChange={e => updateAttributes({ title: e.target.value })}
className="node-document-title"
/>
<button
className="node-image-pick-btn"
onClick={() => setShowPicker(true)}
>
<button type="button" className="node-image-pick-btn" onClick={() => setShowPicker(true)}>
Pick
</button>
</div>
{showPicker && (
<FilePicker
onSelect={handleSelect}
onClose={() => setShowPicker(false)}
/>
)}
{showPicker && <FilePicker onSelect={handleSelect} onClose={() => setShowPicker(false)} />}
</div>
</NodeViewWrapper>
<BlockInsert editor={editor} getPos={getPos} />
+2 -2
View File
@@ -1,6 +1,6 @@
import { Node, mergeAttributes } from "@tiptap/core"
import { ReactNodeViewRenderer, NodeViewWrapper } from "@tiptap/react"
import { mergeAttributes, Node } from "@tiptap/core"
import type { NodeViewProps } from "@tiptap/react"
import { NodeViewWrapper, ReactNodeViewRenderer } from "@tiptap/react"
import { BlockActions } from "../components/BlockActions"
import { BlockInsert } from "../components/BlockInsert"
+29 -32
View File
@@ -1,12 +1,12 @@
import { Node, mergeAttributes } from "@tiptap/core"
import { ReactNodeViewRenderer, NodeViewWrapper } from "@tiptap/react"
import { mergeAttributes, Node } from "@tiptap/core"
import type { NodeViewProps } from "@tiptap/react"
import { pb } from "../pb"
import type { PbImage } from "../types"
import { useState, useEffect } from "react"
import { ImagePicker } from "../components/ImagePicker"
import { NodeViewWrapper, ReactNodeViewRenderer } from "@tiptap/react"
import { useEffect, useState } from "react"
import { BlockActions } from "../components/BlockActions"
import { BlockInsert } from "../components/BlockInsert"
import { ImagePicker } from "../components/ImagePicker"
import { pb } from "../pb"
import type { PbImage } from "../types"
function ImageBlockView({ node, updateAttributes, editor, getPos }: NodeViewProps) {
const { imageId, wide, caption } = node.attrs
@@ -15,12 +15,15 @@ function ImageBlockView({ node, updateAttributes, editor, getPos }: NodeViewProp
useEffect(() => {
if (!imageId) return
pb.collection("images").getOne<PbImage>(imageId).then((img) => {
const url = pb.files.getURL(img, img.file, { thumb: "300x200" })
setThumbnail(url)
}).catch(() => {
setThumbnail(null)
})
pb.collection("images")
.getOne<PbImage>(imageId)
.then(img => {
const url = pb.files.getURL(img, img.file, { thumb: "300x200" })
setThumbnail(url)
})
.catch(() => {
setThumbnail(null)
})
}, [imageId])
function handleSelect(newImageId: string) {
@@ -35,28 +38,30 @@ function ImageBlockView({ node, updateAttributes, editor, getPos }: NodeViewProp
<div className="node-view-content">
<div className="node-block-label">Image</div>
{thumbnail ? (
<img
src={thumbnail}
alt={caption || "image"}
className="node-image-thumb"
<button
type="button"
className="node-image-thumb-button"
onClick={() => setShowPicker(true)}
style={{ cursor: "pointer" }}
/>
style={{ cursor: "pointer", padding: 0, border: 0, background: "none" }}
>
<img src={thumbnail} alt={caption || "image"} className="node-image-thumb" />
</button>
) : (
<div
<button
type="button"
className="node-image-placeholder"
onClick={() => setShowPicker(true)}
style={{ cursor: "pointer" }}
>
{imageId ? "Loading..." : "Click to select image"}
</div>
</button>
)}
<div className="node-image-controls">
<label>
<input
type="checkbox"
checked={wide}
onChange={(e) => updateAttributes({ wide: e.target.checked })}
onChange={e => updateAttributes({ wide: e.target.checked })}
/>
Wide
</label>
@@ -64,22 +69,14 @@ function ImageBlockView({ node, updateAttributes, editor, getPos }: NodeViewProp
type="text"
placeholder="Caption"
value={caption || ""}
onChange={(e) => updateAttributes({ caption: e.target.value })}
onChange={e => updateAttributes({ caption: e.target.value })}
className="node-image-caption"
/>
<button
className="node-image-pick-btn"
onClick={() => setShowPicker(true)}
>
<button type="button" className="node-image-pick-btn" onClick={() => setShowPicker(true)}>
Pick
</button>
</div>
{showPicker && (
<ImagePicker
onSelect={handleSelect}
onClose={() => setShowPicker(false)}
/>
)}
{showPicker && <ImagePicker onSelect={handleSelect} onClose={() => setShowPicker(false)} />}
</div>
</NodeViewWrapper>
<BlockInsert editor={editor} getPos={getPos} />
+2 -2
View File
@@ -1,6 +1,6 @@
import { Node, mergeAttributes } from "@tiptap/core"
import { ReactNodeViewRenderer, NodeViewWrapper, NodeViewContent } from "@tiptap/react"
import { mergeAttributes, Node } from "@tiptap/core"
import type { NodeViewProps } from "@tiptap/react"
import { NodeViewContent, NodeViewWrapper, ReactNodeViewRenderer } from "@tiptap/react"
import { BlockActions } from "../components/BlockActions"
import { BlockInsert } from "../components/BlockInsert"
+4 -4
View File
@@ -1,6 +1,6 @@
export { WriterDocument } from "./WriterDocument"
export { TextBlock } from "./TextBlock"
export { ImageBlock } from "./ImageBlock"
export { CardGridBlock } from "./CardGridBlock"
export { DocumentBlock } from "./DocumentBlock"
export { EmbedBlock } from "./EmbedBlock"
export { CardGridBlock } from "./CardGridBlock"
export { ImageBlock } from "./ImageBlock"
export { TextBlock } from "./TextBlock"
export { WriterDocument } from "./WriterDocument"
+1 -6
View File
@@ -77,12 +77,7 @@ export type DocV1BlockCardGrid = {
items: DocV1BlockCardGridItem[]
}
export type DocV1Block =
| DocV1BlockText
| DocV1BlockImage
| DocV1BlockDocument
| DocV1BlockEmbed
| DocV1BlockCardGrid
export type DocV1Block = DocV1BlockText | DocV1BlockImage | DocV1BlockDocument | DocV1BlockEmbed | DocV1BlockCardGrid
export type DocV1 = {
version: 1
+2 -2
View File
@@ -8,8 +8,8 @@
"start": "next start",
"dev": "next dev --turbo",
"build": "next build",
"lint": "eslint .",
"format": "eslint --fix .",
"lint": "biome check",
"format": "biome check --write",
"test": "vitest run"
},
"repository": {
+3 -3
View File
@@ -1,10 +1,10 @@
import { tail } from "@hudozka/utils"
import type { Metadata } from "next"
import { notFound } from "next/navigation"
import { App } from "@/components/App"
import { PageContent } from "@/components/PageContent"
import { MetaBuilder, buildMetadata } from "@/lib/meta"
import { buildMetadata, MetaBuilder } from "@/lib/meta"
import { getPageBySlug, getUrls } from "@/remote/api"
import { tail } from "@hudozka/utils"
export const revalidate = 30
export const dynamicParams = true
@@ -45,7 +45,7 @@ export default async function SlugPage({ params }: Props) {
}
const breadcrumbSize = page.breadcrumb?.length ?? 0
const breadcrumbs = breadcrumbSize < 2 ? [] : page.breadcrumb ?? []
const breadcrumbs = breadcrumbSize < 2 ? [] : (page.breadcrumb ?? [])
return (
<App
+1 -1
View File
@@ -10,7 +10,7 @@ export async function GET() {
title: "Шлиссельбургская ДХШ",
home_page_url: siteUrl,
feed_url: `${siteUrl}/feed.json`,
items: pages.map((page) => ({
items: pages.map(page => ({
id: `${siteUrl}${page.url}`,
url: `${siteUrl}${page.url}`,
title: page.title,
+3 -6
View File
@@ -5,8 +5,8 @@ import "src/style/marker/marker-table.css"
import "src/style/kazimir/video.css"
import type { Metadata, Viewport } from "next"
import Script from "next/script"
import { Roboto } from "next/font/google"
import Script from "next/script"
import { YMetrika } from "@/components/YMetrika"
const roboto = Roboto({
@@ -17,10 +17,7 @@ const roboto = Roboto({
export const metadata: Metadata = {
verification: {
google: [
"8BxOTw-Q02DS2KAdcGcnIMwiPZ58DVB18XpOfRibCcg",
"A-TEemUw4hHpULUDCd6xhxfHnyRbgKtss90JGKXnSpw",
],
google: ["8BxOTw-Q02DS2KAdcGcnIMwiPZ58DVB18XpOfRibCcg", "A-TEemUw4hHpULUDCd6xhxfHnyRbgKtss90JGKXnSpw"],
other: {
"yandex-verification": "643c7dac144b9dac",
"msvalidate.01": "E878301FE3E3F12D233A6B1156889601",
@@ -38,7 +35,7 @@ export const metadata: Metadata = {
},
other: {
"apple-mobile-web-app-capable": "yes",
"referrer": "unsafe-url",
referrer: "unsafe-url",
},
alternates: {
types: {
+2 -5
View File
@@ -1,17 +1,14 @@
import type { Metadata } from "next"
import { App } from "@/components/App"
import { HomeContent } from "@/components/HomeContent"
import { MetaBuilder, buildMetadata } from "@/lib/meta"
import { buildMetadata, MetaBuilder } from "@/lib/meta"
import { getHomeCards } from "@/remote/api"
export const revalidate = 30
export async function generateMetadata(): Promise<Metadata> {
const title = "Шлиссельбургская ДХШ"
const m = new MetaBuilder()
.setTitle(title)
.setData({ url: "/" })
.build()
const m = new MetaBuilder().setTitle(title).setData({ url: "/" }).build()
return buildMetadata(m)
}
+1 -1
View File
@@ -16,7 +16,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
},
]
const dynamicPages: MetadataRoute.Sitemap = urls.map((path) => ({
const dynamicPages: MetadataRoute.Sitemap = urls.map(path => ({
url: `${siteUrl}${path}`,
changeFrequency: "daily",
}))
@@ -1,22 +1,14 @@
import { theme, toggleTheme } from "@/store/theme"
import { Button } from "@hudozka/ui"
import { HiOutlineEye, HiOutlineEyeOff } from "react-icons/hi"
import { useSnapshot } from "valtio"
import { theme, toggleTheme } from "@/store/theme"
export function AccessibilityButton() {
const t = useSnapshot(theme)
return (
<Button
theme="icon"
size="default"
onClick={toggleTheme}
>
{t.theme === "default" ? (
<HiOutlineEyeOff size={24} />
) : (
<HiOutlineEye size={24} />
)}
<Button theme="icon" size="default" onClick={toggleTheme}>
{t.theme === "default" ? <HiOutlineEyeOff size={24} /> : <HiOutlineEye size={24} />}
</Button>
)
}
@@ -1,78 +1,159 @@
import play from "@/play"
import { state as playState, setVoice } from "@/store/play"
import { decreaseFontSize, hideImages, increaseFontSize, reset, setBlackOnWhite, setBlueOnBlue, setBrownOnYellow, setDefaultTheme, setGreenOnBrown, setLetterSpacingOne, setLetterSpacingTwo, setLetterSpacingFour, setLineHeightOneAndHalf, setLineHeightTwo, setLineHeightTwoAndHalf, setSerifFont, setSansSerifFont, setWhiteOnBlack, showImages } from "@/store/theme"
import { HiCog, HiOutlineEyeOff, HiPlay } from "react-icons/hi"
import { useSnapshot } from "valtio"
import play from "@/play"
import { state as playState, setVoice } from "@/store/play"
import {
decreaseFontSize,
hideImages,
increaseFontSize,
reset,
setBlackOnWhite,
setBlueOnBlue,
setBrownOnYellow,
setDefaultTheme,
setGreenOnBrown,
setLetterSpacingFour,
setLetterSpacingOne,
setLetterSpacingTwo,
setLineHeightOneAndHalf,
setLineHeightTwo,
setLineHeightTwoAndHalf,
setSansSerifFont,
setSerifFont,
setWhiteOnBlack,
showImages,
} from "@/store/theme"
import s from "./panel.module.css"
import VoiceSelect from "./voice"
function AccessibilityPanel() {
const p = useSnapshot(playState)
return ( <div className={s.viewsettings}>
<div className={s.row}>
<div className={s.block}>
<p>Размер шрифта:</p>
<a onClick={decreaseFontSize} title="уменьшить размер шрифта">A-</a>
<a onClick={increaseFontSize} title="увеличить размер шрифта"><strong>A+</strong></a>
return (
<div className={s.viewsettings}>
<div className={s.row}>
<div className={s.block}>
<p>Размер шрифта:</p>
<button type="button" onClick={decreaseFontSize} title="уменьшить размер шрифта">
A-
</button>
<button type="button" onClick={increaseFontSize} title="увеличить размер шрифта">
<strong>A+</strong>
</button>
</div>
<div className={s.block}>
<p>Изображения:</p>
<button type="button" onClick={showImages}>
Включить
</button>
<button type="button" onClick={hideImages}>
Выключить
</button>
</div>
<div className={s.block}>
<p>Цвет сайта:</p>
<button
type="button"
onClick={setBlackOnWhite}
title="черные буквы на белом фоне"
style={{ color: "black", backgroundColor: "white" }}
>
Ц
</button>
<button
type="button"
onClick={setWhiteOnBlack}
title="белые буквы на черном фоне"
style={{ color: "white", backgroundColor: "black" }}
>
Ц
</button>
<button
type="button"
onClick={setBlueOnBlue}
title="синии буквы на голубом фоне"
style={{ color: "oklch(32% 24% 253deg)", backgroundColor: "oklch(84% 21% 245deg)" }}
>
Ц
</button>
<button
type="button"
onClick={setBrownOnYellow}
title="коричневые буквы на желтом фоне"
style={{ color: "oklch(41% 3% 92deg)", backgroundColor: "oklch(96% 9% 101deg)" }}
>
Ц
</button>
<button
type="button"
onClick={setGreenOnBrown}
title="зеленые буквы на коричневом фоне"
style={{ color: "oklch(85% 47% 128deg)", backgroundColor: "oklch(29% 10% 61deg)" }}
>
Ц
</button>
</div>
<div className={s.block}>
<p>Шрифт:</p>
<button type="button" onClick={setSerifFont}>
С засечками
</button>
<button type="button" onClick={setSansSerifFont}>
Без засечек
</button>
</div>
<div className={s.block}>
<p>Высота строк:</p>
<button type="button" onClick={setLineHeightOneAndHalf}>
1.5
</button>
<button type="button" onClick={setLineHeightTwo}>
2.0
</button>
<button type="button" onClick={setLineHeightTwoAndHalf}>
2.5
</button>
</div>
<div className={s.block}>
<p>Межбуквенный интервал:</p>
<button type="button" onClick={setLetterSpacingOne}>
1
</button>
<button type="button" onClick={setLetterSpacingTwo}>
2
</button>
<button type="button" onClick={setLetterSpacingFour}>
4
</button>
</div>
</div>
<div className={s.block}>
<p>Изображения:</p>
<a onClick={showImages}>Включить</a>
<a onClick={hideImages}>Выключить</a>
</div>
<div className={s.block}>
<p>Цвет сайта:</p>
<a onClick={setBlackOnWhite} title="черные буквы на белом фоне" style={{color: "black", backgroundColor: "white"}}>Ц</a>
<a onClick={setWhiteOnBlack} title="белые буквы на черном фоне" style={{color: "white", backgroundColor: "black"}}>Ц</a>
<a onClick={setBlueOnBlue} title="синии буквы на голубом фоне" style={{color: "oklch(32% 24% 253deg)", backgroundColor: "oklch(84% 21% 245deg)"}}>Ц</a>
<a onClick={setBrownOnYellow} title="коричневые буквы на желтом фоне" style={{color: "oklch(41% 3% 92deg)", backgroundColor: "oklch(96% 9% 101deg)"}}>Ц</a>
<a onClick={setGreenOnBrown} title="зеленые буквы на коричневом фоне" style={{color: "oklch(85% 47% 128deg)", backgroundColor: "oklch(29% 10% 61deg)"}}>Ц</a>
</div>
<div className={s.block}>
<p>Шрифт:</p>
<a onClick={setSerifFont}>С засечками</a>
<a onClick={setSansSerifFont}>Без засечек</a>
{/*<a id="btn-on-braille" href="javascript:void(0)" onClick="smSetting('fontBraille', 'brailleOn')" title="только для печати">Брайля</a>*/}
{/*<a id="btn-off-braille" href="javascript:void(0)" onClick="smSetting('fontBraille', 'brailleOff')">Обычный</a>*/}
</div>
<div className={s.block}>
<p>Высота строк:</p>
<a onClick={setLineHeightOneAndHalf}>1.5</a>
<a onClick={setLineHeightTwo}>2.0</a>
<a onClick={setLineHeightTwoAndHalf}>2.5</a>
</div>
<div className={s.block}>
<p>Межбуквенный интервал:</p>
<a onClick={setLetterSpacingOne}>1</a>
<a onClick={setLetterSpacingTwo}>2</a>
<a onClick={setLetterSpacingFour}>4</a>
<div className={s.row}>
<button type="button" onClick={reset}>
<HiCog />
Сброс настроек
</button>
<button type="button" onClick={setDefaultTheme}>
<HiOutlineEyeOff />
Обычная версия
</button>
<button type="button" onClick={play}>
<HiPlay />
Прослушать всё
</button>
<VoiceSelect
value={p.voice}
onChange={event => {
setVoice(event.target.value)
}}
/>
</div>
</div>
<div className={s.row}>
<button onClick={reset}>
<HiCog />
Сброс настроек
</button>
<button onClick={setDefaultTheme}>
<HiOutlineEyeOff />
Обычная версия
</button>
<button onClick={play}>
<HiPlay />
Прослушать всё
</button>
<VoiceSelect value={p.voice} onChange={(event) => {
setVoice(event.target.value)
}} />
</div>
</div>
)
}
@@ -38,22 +38,6 @@
margin: 0;
}
.viewsettings a {
border: 1px solid;
text-decoration: none;
padding: 4px 10px;
cursor: pointer;
color: black;
}
.viewsettings a:hover {
background-color: #cfcfcf !important;
color: #000 !important;
}
.viewsettings a:focus {
outline: 1px solid !important;
}
.viewsettings .block {
display: inline-block;
vertical-align: top;
@@ -63,8 +47,23 @@
margin: 2px;
}
.viewsettings .block a {
.viewsettings .block button {
display: inline-block;
border: 1px solid;
text-decoration: none;
padding: 4px 10px;
margin-right: 8px;
cursor: pointer;
background-color: transparent;
color: black;
font: inherit;
}
.viewsettings .block button:hover {
background-color: #cfcfcf !important;
color: #000 !important;
}
.viewsettings .block button:focus {
outline: 1px solid !important;
}
.viewsettings button {
@@ -1,12 +1,10 @@
function useVoices() {
return window.speechSynthesis
.getVoices()
.map(v => {
return {
name: `${v.name} (${v.lang})`,
value: v.name,
}
})
return window.speechSynthesis.getVoices().map(v => {
return {
name: `${v.name} (${v.lang})`,
value: v.name,
}
})
}
export type VoiceSelectProps = React.SelectHTMLAttributes<HTMLSelectElement>
+25 -37
View File
@@ -1,26 +1,24 @@
"use client"
import s from "./app.module.css"
import { Wrapper } from "../Wrapper"
import { Footer } from "../Footer"
import { Content } from "../Content"
import type { BreadcrumbPart } from "@/types"
import { usePathname } from "next/navigation"
import { useMobile } from "@hudozka/hooks"
import { Navigation } from "@/components/Navigation"
import { Box, Breadcrumbs, Panel, Title } from "@hudozka/ui"
import { AccessibilityButton } from "../AccessibilityButton"
import { Copyright } from "@/components/Copyright"
import { yearRange } from "@/const"
import { Spacer } from "../Spacer"
import { Author } from "../Author"
import { Contacts } from "../Contacts"
import dynamic from "next/dynamic"
import Link from "next/link"
import AccessibilityPanel from "../AccessibilityPanel"
import { usePathname } from "next/navigation"
import { Copyright } from "@/components/Copyright"
import { Navigation } from "@/components/Navigation"
import { yearRange } from "@/const"
import useAccessibility from "@/hooks/useAccessibility"
import type { BreadcrumbPart } from "@/types"
import { AccessibilityButton } from "../AccessibilityButton"
import AccessibilityPanel from "../AccessibilityPanel"
import { Author } from "../Author"
import { Contacts } from "../Contacts"
import { Content } from "../Content"
import { Footer } from "../Footer"
import { Spacer } from "../Spacer"
import { Wrapper } from "../Wrapper"
import s from "./app.module.css"
const Gosuslugi = dynamic(() => import("../Gosuslugi").then(module => ({ default: module.Gosuslugi })), {
ssr: false,
@@ -40,11 +38,9 @@ export function App({ showAuthor = false, ...props }: AppProps) {
return (
<Wrapper
header={(
header={
<header>
{!accessibility ? null : (
<AccessibilityPanel/>
)}
{!accessibility ? null : <AccessibilityPanel />}
<div className={s.navigation}>
<Navigation />
@@ -52,16 +48,13 @@ export function App({ showAuthor = false, ...props }: AppProps) {
{hideBreadcrumbs || !props.breadcrumbs ? null : (
<Box center>
<Content>
<Breadcrumbs
items={props.breadcrumbs}
path={pathname}
/>
<Breadcrumbs items={props.breadcrumbs} path={pathname} />
</Content>
</Box>
)}
</header>
)}
footer={(
}
footer={
<Footer>
<Box wrap gap={40} align={false} className={s.footerLinks}>
<Panel ghost>
@@ -69,16 +62,12 @@ export function App({ showAuthor = false, ...props }: AppProps) {
</Panel>
<Panel ghost className={s.footerPanel}>
<Title level={3}>
Версия для слабовидящих
</Title>
<Title level={3}>Версия для слабовидящих</Title>
<AccessibilityButton />
</Panel>
<Panel ghost className={s.footerPanel}>
<Title level={3}>
Анкеты
</Title>
<Title level={3}>Анкеты</Title>
<ul>
<li>
@@ -95,7 +84,8 @@ export function App({ showAuthor = false, ...props }: AppProps) {
<li>
<Link href={"https://forms.yandex.ru/u/68ef8e9c90fa7b0bacf46e9d/"}>
Анкета получателей образовательных услуг ДОП Кировского муниципального района Ленинградской области 2025
Анкета получателей образовательных услуг ДОП Кировского муниципального района
Ленинградской области 2025
</Link>
</li>
</ul>
@@ -117,12 +107,10 @@ export function App({ showAuthor = false, ...props }: AppProps) {
)}
</Box>
</Footer>
)}
}
>
<Box center>
<Content style={props.contentStyle}>
{props.children}
</Content>
<Content style={props.contentStyle}>{props.children}</Content>
</Box>
</Wrapper>
)
+2 -4
View File
@@ -1,8 +1,6 @@
export const Author: React.FC = () => (
<span>
Разработка и поддержка — {" "}
<a href="http://tmshv.ru?utm_source=artshburg&utm_medium=developer&utm_campaign=tmshv_ru">
www.tmshv.ru
</a>
Разработка и поддержка —{" "}
<a href="http://tmshv.ru?utm_source=artshburg&utm_medium=developer&utm_campaign=tmshv_ru">www.tmshv.ru</a>
</span>
)
+9 -24
View File
@@ -1,12 +1,6 @@
import s from "./styles.module.css"
import cx from "classnames"
import Link from "next/link"
const layoutClass = {
simple: s.simple,
featured: s.featured,
}
import s from "./styles.module.css"
export type CardLayout = "simple" | "featured"
export type CardProps = {
@@ -17,21 +11,12 @@ export type CardProps = {
layout: CardLayout
}
export const Card: React.FC<CardProps> = props => (
<Link
href={props.href}
// className={cx(s.card, layoutClass[props.layout])}
className={cx(s.card, layoutClass["simple"])}
style={props.style}
>
<div className={s.image}>
{props.cover}
</div>
<div className={s.body}>
{props.children}
</div>
</Link>
)
export function Card(props: CardProps) {
return (
<Link href={props.href} className={cx(s.card, s.simple)} style={props.style}>
<div className={s.image}>{props.cover}</div>
<div className={s.body}>{props.children}</div>
</Link>
)
}
@@ -1,19 +1,17 @@
import s from "./contacts.module.css"
import { Title } from "@hudozka/ui"
import Link from "next/link"
import { Title } from "@hudozka/ui"
import { contacts } from "@/const"
import s from "./contacts.module.css"
export const Contacts: React.FC = () => {
export function Contacts() {
return (
<>
<Title level={3}>Контактная информация</Title>
{contacts.map((x, i) => (
<p key={i} className={s.link}>
<Link href={x.value}>
{x.title}
</Link>
{contacts.map(x => (
<p key={x.value} className={s.link}>
<Link href={x.value}>{x.title}</Link>
</p>
))}
</>
@@ -1,5 +1,5 @@
import cx from "classnames"
import { useReducedMotion } from "@hudozka/hooks"
import cx from "classnames"
export type ContentProps = {
style?: React.CSSProperties
@@ -3,7 +3,5 @@ export type CopyrightProps = {
}
export function Copyright({ years }: CopyrightProps) {
return (
<span>©&nbsp;{years} Шлиссельбургская детская художественная школа</span>
)
return <span>©&nbsp;{years} Шлиссельбургская детская художественная школа</span>
}
+4 -8
View File
@@ -1,9 +1,8 @@
import s from "./styles.module.css"
import type { FileDefinition } from "@/types"
import { ext, size } from "@hudozka/utils"
import Link from "next/link"
import Image from "next/image"
import Link from "next/link"
import type { FileDefinition } from "@/types"
import s from "./styles.module.css"
export type FileProps = FileDefinition & {
style?: React.CSSProperties
@@ -14,10 +13,7 @@ export const File: React.FC<FileProps> = props => (
{!props.cover?.src ? null : (
<div className={s.image}>
<Link href={props.file.src} target="_blank" rel="noreferrer" className="invisible">
<Image
src={props.cover.src}
alt={props.name}
/>
<Image src={props.cover.src} alt={props.name} />
</Link>
</div>
)}
@@ -1,9 +1,7 @@
import s from "./signature.module.css"
import type { Sign } from "@/types"
import { HiOutlineShieldCheck } from "react-icons/hi"
import * as Tooltip from "@radix-ui/react-tooltip"
import { HiOutlineShieldCheck } from "react-icons/hi"
import type { Sign } from "@/types"
import s from "./signature.module.css"
export type SignatureProps = Sign
export const Signature: React.FC<SignatureProps> = props => {
+9 -16
View File
@@ -1,11 +1,10 @@
import s from "./filecard.module.css"
import Image from "next/image"
import dynamic from "next/dynamic"
import type { FileTokenData, Sign } from "@/types"
import { ext, getResizedUrl, size } from "@hudozka/utils"
import Link from "next/link"
import { Box } from "@hudozka/ui"
import { ext, getResizedUrl, size } from "@hudozka/utils"
import dynamic from "next/dynamic"
import Image from "next/image"
import Link from "next/link"
import type { FileTokenData, Sign } from "@/types"
import s from "./filecard.module.css"
const Signature = dynamic(() => import("./Signature").then(m => ({ default: m.Signature })), {
ssr: false,
@@ -25,7 +24,7 @@ export const FileCard: React.FC<FileCardProps> = props => {
<div className={s.image}>
<Image
className={s.image}
loader={({ src, width, quality }) => {
loader={({ src, width }) => {
return getResizedUrl(src, {
width,
height: width,
@@ -42,14 +41,8 @@ export const FileCard: React.FC<FileCardProps> = props => {
<div>
<Box align={false} gap={"var(--size-xs)"}>
{!props.sign ? null : (
<Signature
{...props.sign}
/>
)}
<Link href={props.url}>
{props.title}
</Link>
{!props.sign ? null : <Signature {...props.sign} />}
<Link href={props.url}>{props.title}</Link>
</Box>
</div>
+1 -4
View File
@@ -5,8 +5,5 @@ export type FooterProps = {
}
export const Footer: React.FC<FooterProps> = ({ children }) => (
<footer className={`${s.footer} opposite`}>
{children}
</footer>
<footer className={`${s.footer} opposite`}>{children}</footer>
)
@@ -1,6 +1,6 @@
import InnerHTML from "dangerously-set-html-content"
import { useEffect } from "react"
import { getCode } from "./lib"
import InnerHTML from "dangerously-set-html-content"
async function sleep(ms: number) {
return new Promise(resolve => {
@@ -21,9 +21,7 @@ async function repeat(delay: number, times: number, fn: () => void): Promise<voi
}
}
export type GosuslugiProps = {}
export const Gosuslugi: React.FC<GosuslugiProps> = () => {
export function Gosuslugi() {
useEffect(() => {
let mount = true
repeat(1000, 50, () => {
@@ -40,8 +38,5 @@ export const Gosuslugi: React.FC<GosuslugiProps> = () => {
}
}, [])
return (
<InnerHTML html={getCode()} />
)
return <InnerHTML html={getCode()} />
}
@@ -1,8 +1,8 @@
"use client"
import { useMobile } from "@hudozka/hooks"
import { HudozkaTitle } from "@/components/HudozkaTitle"
import { PageGrid } from "@/components/PageGrid"
import { useMobile } from "@hudozka/hooks"
import type { PageCardDto } from "@/types"
export type HomeContentProps = {
@@ -1,17 +1,11 @@
import s from "./logo.module.css"
import Image from "next/image"
import src from "src/assets/hudozka-logo.png"
import s from "./logo.module.css"
function HudozkaLogo() {
return (
<div className={s.container}>
<Image
src={src}
width={36}
height={36}
alt={"Hudozka"}
/>
<Image src={src} width={36} height={36} alt={"Hudozka"} />
</div>
)
}
@@ -1,6 +1,5 @@
import s from "./title.module.css"
import { memo } from "react"
import s from "./title.module.css"
export type HudozkaTitleProps = {
compact?: boolean
@@ -9,11 +8,7 @@ export type HudozkaTitleProps = {
export const HudozkaTitle: React.FC<HudozkaTitleProps> = memo(({ style, compact = false }) => {
if (compact) {
return (
<span>
Шлиссельбургская ДХШ
</span>
)
return <span>Шлиссельбургская ДХШ</span>
}
return (
<h1 style={style} className={s.title}>
@@ -6,11 +6,9 @@ export type ActiveLinkProps = {
children?: React.ReactNode
}
export const ActiveLink: React.FC<ActiveLinkProps> = props => props.href
? (
<Link href={props.href}>
{props.children}
</Link>
export const ActiveLink: React.FC<ActiveLinkProps> = props =>
props.href ? (
<Link href={props.href}>{props.children}</Link>
) : (
<span style={props.activeStyle}>{props.children}</span>
)
+21 -28
View File
@@ -1,14 +1,13 @@
"use client"
import s from "./styles.module.css"
import type { MenuItem } from "@/types"
import * as NavigationMenu from "@radix-ui/react-navigation-menu"
import classnames from "classnames/bind"
import { usePathname } from "next/navigation"
import type { MenuItem } from "@/types"
import { ActiveLink } from "./ActiveLink"
import s from "./styles.module.css"
let cx = classnames.bind(s)
const cx = classnames.bind(s)
export type MenuProps = {
vertical?: boolean
@@ -21,31 +20,25 @@ export const Menu: React.FC<MenuProps> = ({ vertical = false, onItemClick, items
return (
<NavigationMenu.Root>
<NavigationMenu.List className={cx(s.menu, { vertical })}>
{
items.map(item => {
const current = pathname === item.href
const href = current
? undefined
: item.href
const className = !href
? `${s.menuItem} ${s.current}`
: `${s.menuItem} ${s.active}`
{items.map(item => {
const current = pathname === item.href
const href = current ? undefined : item.href
const className = !href ? `${s.menuItem} ${s.current}` : `${s.menuItem} ${s.active}`
return (
<NavigationMenu.Item
key={item.href}
className={className}
onClick={() => {
if (typeof onItemClick === "function") {
onItemClick(item)
}
}}
>
<ActiveLink href={href}>{item.name}</ActiveLink>
</NavigationMenu.Item>
)
})
}
return (
<NavigationMenu.Item
key={item.href}
className={className}
onClick={() => {
if (typeof onItemClick === "function") {
onItemClick(item)
}
}}
>
<ActiveLink href={href}>{item.name}</ActiveLink>
</NavigationMenu.Item>
)
})}
</NavigationMenu.List>
</NavigationMenu.Root>
)
@@ -1,10 +1,10 @@
import { Menu } from "../Menu"
import { Box } from "@hudozka/ui"
import HudozkaLogo from "../HudozkaLogo"
import Link from "next/link"
import { AccessibilityButton } from "../AccessibilityButton"
import { Spacer } from "../Spacer"
import { menu } from "@/const"
import { AccessibilityButton } from "../AccessibilityButton"
import HudozkaLogo from "../HudozkaLogo"
import { Menu } from "../Menu"
import { Spacer } from "../Spacer"
export type DesktopNavigationProps = {
className?: string
@@ -18,9 +18,7 @@ export function DesktopNavigation({ style, className }: DesktopNavigationProps)
<HudozkaLogo />
</Link>
<Menu
items={menu}
/>
<Menu items={menu} />
<Spacer />
<AccessibilityButton />
@@ -1,17 +1,16 @@
"use client"
import s from "./nav.module.css"
import { Menu } from "../Menu"
import { useCallback, useEffect, useRef } from "react"
import { Box } from "@hudozka/ui"
import { Spacer } from "../Spacer"
import HudozkaLogo from "../HudozkaLogo"
import { HudozkaTitle } from "../HudozkaTitle"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { useCallback, useEffect, useRef } from "react"
import { HiOutlineMenuAlt4, HiOutlineX } from "react-icons/hi"
import { menu } from "@/const"
import HudozkaLogo from "../HudozkaLogo"
import { HudozkaTitle } from "../HudozkaTitle"
import { Menu } from "../Menu"
import { Spacer } from "../Spacer"
import s from "./nav.module.css"
export type MobileNavigationProps = {
className?: string
@@ -29,8 +28,11 @@ export function MobileNavigation({ className, style }: MobileNavigationProps) {
}, [])
useEffect(() => {
close()
}, [pathname, close])
if (!pathname) return
if (detailsRef.current) {
detailsRef.current.open = false
}
}, [pathname])
return (
<nav className={className}>
@@ -47,7 +49,7 @@ export function MobileNavigation({ className, style }: MobileNavigationProps) {
</summary>
<div className={s.panel}>
<div className={s.panelClose}>
<button className={s.toggle} onClick={close}>
<button type="button" className={s.toggle} onClick={close}>
<HiOutlineX size={24} />
</button>
</div>
@@ -1,6 +1,6 @@
import s from "./nav.module.css"
import { DesktopNavigation } from "./DesktopNavigation"
import { MobileNavigation } from "./MobileNavigation"
import s from "./nav.module.css"
export type NavigationProps = {
style?: React.CSSProperties
@@ -1,8 +1,8 @@
import s from "./error.module.css"
import Image from "next/image"
import Link from "next/link"
import Image from "next/image"
import skull from "@/assets/graphics/skull.jpg"
import s from "./error.module.css"
export const NotFound: React.FC = () => (
<section className={s.section}>
@@ -12,9 +12,7 @@ export const NotFound: React.FC = () => (
</div>
<div>
<Link href={"/"}>
На главную
</Link>
<Link href={"/"}>На главную</Link>
</div>
</section>
)
+2 -2
View File
@@ -1,7 +1,7 @@
import s from "./styles.module.css"
import type { Tag } from "@/types"
import { PageMeta } from "../PageMeta"
import type { Tag } from "@/types"
import s from "./styles.module.css"
export type PageProps = {
children?: React.ReactNode
@@ -1,12 +1,12 @@
"use client"
import { Page } from "@/components/Page"
import { Html } from "@/components/Html"
import { Youtube } from "@/components/Youtube"
import { Picture } from "@/ui/Picture"
import { FileCard } from "@/components/FileCard"
import { Html } from "@/components/Html"
import { Page } from "@/components/Page"
import { PageGrid } from "@/components/PageGrid"
import type { Tag, Token, Sign } from "@/types"
import { Youtube } from "@/components/Youtube"
import type { Sign, Tag, Token } from "@/types"
import { Picture } from "@/ui/Picture"
export type PageContentProps = {
tags: Tag[]
@@ -17,74 +17,41 @@ export type PageContentProps = {
export function PageContent({ tags, date, tokens, documentSignature }: PageContentProps) {
return (
<Page
tags={tags}
date={date ? new Date(date) : undefined}
>
<Page tags={tags} date={date ? new Date(date) : undefined}>
<article className={"article"}>
{tokens.map((x, i) => {
{tokens.map(x => {
switch (x.token) {
case "html":
return (
<Html
key={i}
html={x.data}
/>
)
case "html":
return <Html key={x.id} html={x.data} />
case "instagram":
return (
<Html
key={i}
html={x.data.embed}
/>
)
case "instagram":
return <Html key={x.id} html={x.data.embed} />
case "youtube":
return (
<Youtube
key={i}
url={x.data.url}
/>
)
case "youtube":
return <Youtube key={x.id} url={x.data.url} />
case "image":
return (
<Picture
key={i}
src={x.data.src}
alt={x.data.alt}
width={x.data.width}
height={x.data.height}
caption={x.data.caption}
blur={x.data.blur}
wide={x.wide}
/>
)
case "image":
return (
<Picture
key={x.id}
src={x.data.src}
alt={x.data.alt}
width={x.data.width}
height={x.data.height}
caption={x.data.caption}
blur={x.data.blur}
wide={x.wide}
/>
)
case "file":
return (
<FileCard
key={i}
sign={documentSignature}
{...x.data}
/>
)
case "file":
return <FileCard key={x.id} sign={documentSignature} {...x.data} />
case "grid":
return (
<PageGrid
key={i}
items={x.data.items}
/>
)
case "grid":
return <PageGrid key={x.id} items={x.data.items} />
default:
return (
<pre key={i}>
{JSON.stringify(x)}
</pre>
)
default:
return <pre key={(x as { id: string }).id}>{JSON.stringify(x)}</pre>
}
})}
</article>
+10 -8
View File
@@ -1,13 +1,15 @@
import { dateFormat } from "@hudozka/utils"
import s from "./date.module.css"
import { dateFormat } from "@hudozka/utils"
export type DateProps = {
export type DateLineProps = {
style?: React.CSSProperties
children: string | Date
}
export const Date: React.FC<DateProps> = props => (
<p className={s.date} style={props.style}>
{dateFormat(props.children)}
</p>
)
export function DateLine(props: DateLineProps) {
return (
<p className={s.date} style={props.style}>
{dateFormat(props.children)}
</p>
)
}
+25 -26
View File
@@ -1,12 +1,12 @@
import { useMobile } from "@hudozka/hooks"
import { Box } from "@hudozka/ui"
import Image from "next/image"
import type { PageCardDto } from "@/types"
import { CardGrid } from "../CardGrid"
import { Card } from "../Card"
import type { CardLayout } from "../Card"
import { Date } from "./Date"
import { Card } from "../Card"
import { CardGrid } from "../CardGrid"
import { Spacer } from "../Spacer"
import { Box } from "@hudozka/ui"
import { useMobile } from "@hudozka/hooks"
import { DateLine } from "./Date"
function itemColumn(item: PageCardDto): string {
return item.featured ? "span 2" : "auto"
@@ -20,7 +20,7 @@ export type PageGridProps = {
items: PageCardDto[]
}
export const PageGrid: React.FC<PageGridProps> = props => {
export function PageGrid(props: PageGridProps) {
const mobile = useMobile()
return (
@@ -30,31 +30,32 @@ export const PageGrid: React.FC<PageGridProps> = props => {
}}
>
{props.items.map(item => {
const gridColumn = mobile
? "auto"
: itemColumn(item)
const gridColumn = mobile ? "auto" : itemColumn(item)
const layout = itemLayout(item)
const content = !item.featured
? (
<Box vertical>
{item.title}
<Spacer />
{!item.date ? null : (
<Date style={{
const content = !item.featured ? (
<Box vertical>
{item.title}
<Spacer />
{!item.date ? null : (
<DateLine
style={{
marginTop: "var(--size-m)",
}}>{item.date}</Date>
)}
</Box>
) : (
item.title
)
}}
>
{item.date}
</DateLine>
)}
</Box>
) : (
item.title
)
return (
<Card
key={item.id}
href={item.url}
cover={(
cover={
<Image
fill
style={{
@@ -65,7 +66,7 @@ export const PageGrid: React.FC<PageGridProps> = props => {
placeholder={item.cover.blur ? "blur" : "empty"}
blurDataURL={item.cover.blur}
/>
)}
}
layout={layout}
style={{
gridColumn,
@@ -78,5 +79,3 @@ export const PageGrid: React.FC<PageGridProps> = props => {
</CardGrid>
)
}
+14 -19
View File
@@ -1,11 +1,10 @@
import s from "./styles.module.css"
import cx from "classnames"
import { dateFormat } from "@hudozka/utils"
import type { Tag } from "@/types"
import { TagList } from "../TagList"
import type { Direction } from "../TagList"
import { useMobile } from "@hudozka/hooks"
import { dateFormat } from "@hudozka/utils"
import cx from "classnames"
import type { Tag } from "@/types"
import type { Direction } from "../TagList"
import { TagList } from "../TagList"
import s from "./styles.module.css"
export type PageMetaProps = {
style?: React.CSSProperties
@@ -19,19 +18,15 @@ export const PageMeta: React.FC<PageMetaProps> = props => {
const direction: Direction = mobile ? "vertical" : "horizontal"
return (
<div className={cx(s.pageMeta, {
[s.center]: center,
})} style={props.style}>
{!props.date ? null : (
<p className={"date"}>Опубликовано {dateFormat(props.date)}</p>
)}
<div
className={cx(s.pageMeta, {
[s.center]: center,
})}
style={props.style}
>
{!props.date ? null : <p className={"date"}>Опубликовано {dateFormat(props.date)}</p>}
{!(props.tags?.length) ? null : (
<TagList
direction={direction}
items={props.tags}
/>
)}
{!props.tags?.length ? null : <TagList direction={direction} items={props.tags} />}
</div>
)
}
+6 -5
View File
@@ -4,8 +4,9 @@ export type SpacerProps = {
as?: keyof HTMLElementTagNameMap
}
export const Spacer: React.FC<SpacerProps> = ({ as = "div" }) => createElement(as, {
style: {
flex: 1,
},
})
export const Spacer: React.FC<SpacerProps> = ({ as = "div" }) =>
createElement(as, {
style: {
flex: 1,
},
})
+4 -12
View File
@@ -1,8 +1,8 @@
import s from "./styles.module.css"
import { Button } from "@hudozka/ui"
import cx from "classnames"
import type { Tag } from "@/types"
import { Button } from "@hudozka/ui"
import s from "./styles.module.css"
const directionClass = {
horizontal: "",
@@ -19,11 +19,7 @@ export type TagItemProps = {
export const TagItem: React.FC<TagItemProps> = props => (
<li>
<Button
href={props.href}
size={"small"}
disabled={true}
>
<Button href={props.href} size={"small"} disabled={true}>
{props.children}
</Button>
</li>
@@ -37,11 +33,7 @@ export type TagListProps = {
export const TagList: React.FC<TagListProps> = props => (
<ul className={cx(s.tags, directionClass[props.direction])}>
{props.items.map(x => (
<TagItem
key={x.slug}
href={x.href}
direction={props.direction}
>
<TagItem key={x.slug} href={x.href} direction={props.direction}>
{x.name}
</TagItem>
))}
+11 -3
View File
@@ -1,10 +1,9 @@
"use client"
import s from "./wrapper.module.css"
import cx from "classnames"
import { useSnapshot } from "valtio"
import { theme } from "@/store/theme"
import s from "./wrapper.module.css"
export type WrapperProps = {
header: React.ReactNode
@@ -18,7 +17,16 @@ export const Wrapper: React.FC<WrapperProps> = props => {
const t = useSnapshot(theme)
return (
<div className={cx(s.container, `theme-${t.theme}`, `font-${t.fontType}`, t.colorScheme ?? "black-on-white", !t.showImages ? "no-images" : null)} style={props.style}>
<div
className={cx(
s.container,
`theme-${t.theme}`,
`font-${t.fontType}`,
t.colorScheme ?? "black-on-white",
!t.showImages ? "no-images" : null,
)}
style={props.style}
>
{props.header}
<main className={s.main} style={props.mainStyle}>
+1 -1
View File
@@ -1,4 +1,4 @@
import { describe, it, expect } from "vitest"
import { describe, expect, it } from "vitest"
import { script } from "./YMetrika"
describe("YMetrika script", () => {
@@ -2,7 +2,7 @@ export type YoutubeProps = {
url: string
}
export const Youtube: React.FC<{ url: string }> = props => {
export function Youtube(props: YoutubeProps) {
const url = new URL(props.url)
const videoId = url.searchParams.get("v")
if (!videoId) {
@@ -13,11 +13,7 @@ export const Youtube: React.FC<{ url: string }> = props => {
return (
<div className="kazimir__video">
<iframe
src={src}
frameBorder="0"
// allowFullscreen
/>
<iframe title="YouTube video player" src={src} frameBorder="0" />
</div>
)
}
+1 -1
View File
@@ -1,5 +1,5 @@
import { theme } from "@/store/theme"
import { useSnapshot } from "valtio"
import { theme } from "@/store/theme"
function useAccessibility(): boolean {
const t = useSnapshot(theme)
+5 -10
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest"
import { meta, MetaBuilder, buildMetadata } from "./meta"
import { describe, expect, it } from "vitest"
import { buildMetadata, MetaBuilder, meta } from "./meta"
describe("meta", () => {
it("should return defaults when called with empty object", () => {
@@ -25,10 +25,7 @@ describe("meta", () => {
describe("MetaBuilder", () => {
it("should support method chaining", () => {
const builder = new MetaBuilder()
const result = builder
.setTitle("Test")
.setDescription("Desc")
.build()
const result = builder.setTitle("Test").setDescription("Desc").build()
expect(result.title).toBe("Test")
expect(result.description).toBe("Desc")
@@ -51,9 +48,7 @@ describe("MetaBuilder", () => {
})
it("should apply data from setData", () => {
const result = new MetaBuilder()
.setData({ url: "/test", type: "article" })
.build()
const result = new MetaBuilder().setData({ url: "/test", type: "article" }).build()
expect(result.url).toBe("https://art.shlisselburg.org/test")
expect(result.type).toBe("article")
@@ -115,7 +110,7 @@ describe("buildMetadata", () => {
expect(result.title).toBe("Custom Page")
expect(result.description).toBe("A custom description")
const og = result.openGraph as { url: string, images: { url: string, width: number, height: number }[] }
const og = result.openGraph as { url: string; images: { url: string; width: number; height: number }[] }
expect(og.url).toBe("https://art.shlisselburg.org/custom")
expect(og.images[0]).toEqual({
url: "https://example.com/cover.jpg",
+2 -3
View File
@@ -1,16 +1,15 @@
import { state } from "./store/play"
function play() {
const synth = window.speechSynthesis
const a = document.querySelector("article")
if (!a || !a.textContent) {
if (!a?.textContent) {
return
}
const u = new SpeechSynthesisUtterance(a.textContent)
const voice = synth.getVoices().find((v) => v.name === state.voice)
const voice = synth.getVoices().find(v => v.name === state.voice)
if (voice) {
u.voice = voice
}
+12 -13
View File
@@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach } from "vitest"
import { beforeEach, describe, expect, it, vi } from "vitest"
vi.mock("./pb", () => ({
pb: {
@@ -7,7 +7,7 @@ vi.mock("./pb", () => ({
},
}))
import { getUrls, getRecentPages } from "./api"
import { getRecentPages, getUrls } from "./api"
import { pb } from "./pb"
beforeEach(() => {
@@ -16,10 +16,7 @@ beforeEach(() => {
describe("getUrls", () => {
it("should return slugs from all non-draft pages", async () => {
const mockGetFullList = vi.fn().mockResolvedValue([
{ slug: "/info" },
{ slug: "/2024/test" },
])
const mockGetFullList = vi.fn().mockResolvedValue([{ slug: "/info" }, { slug: "/2024/test" }])
vi.mocked(pb.collection).mockReturnValue({
getFullList: mockGetFullList,
} as never)
@@ -65,13 +62,15 @@ describe("getRecentPages", () => {
} as never)
const result = await getRecentPages()
expect(result).toEqual([{
id: "x1",
title: "Recent",
url: "/2024/recent",
date: "2024-03-01",
excerpt: "Summary",
}])
expect(result).toEqual([
{
id: "x1",
title: "Recent",
url: "/2024/recent",
date: "2024-03-01",
excerpt: "Summary",
},
])
expect(mockGetList).toHaveBeenCalledWith(1, 30, {
filter: "date!='' && draft=false",
sort: "-date",
+21 -28
View File
@@ -1,9 +1,9 @@
import { pb } from "./pb"
import { createPage, createHomeCards, createMenu, createFeedPages } from "./factory"
import type { PbPage, PbImage, PbFile, PbTag, PbHomeData, PbMenuData } from "./types"
import type { DocV1Block } from "./doc"
import type { BreadcrumbPart, MenuItem, Page, PageCardDto, FeedPage } from "@/types"
import { menu } from "@/const"
import type { BreadcrumbPart, FeedPage, Page, PageCardDto } from "@/types"
import type { DocV1Block } from "./doc"
import { createFeedPages, createHomeCards, createPage } from "./factory"
import { pb } from "./pb"
import type { PbFile, PbHomeData, PbImage, PbPage, PbTag } from "./types"
const MAX_PARENT_DEPTH = 10
@@ -82,17 +82,17 @@ function collectBlockRefs(blocks: DocV1Block[]) {
for (const block of blocks) {
switch (block.type) {
case "image":
imageIds.add(block.image)
break
case "document":
fileIds.add(block.file)
break
case "card-grid":
for (const item of block.items) {
pageIds.add(item.page)
}
break
case "image":
imageIds.add(block.image)
break
case "document":
fileIds.add(block.file)
break
case "card-grid":
for (const item of block.items) {
pageIds.add(item.page)
}
break
}
}
@@ -114,9 +114,7 @@ export async function getUrls(): Promise<string[]> {
export async function getPageBySlug(slug: string): Promise<Page | null> {
try {
const record = await pb.collection("pages").getFirstListItem<PbPage>(
`slug="${slug}"`,
)
const record = await pb.collection("pages").getFirstListItem<PbPage>(`slug="${slug}"`)
// Collect all referenced IDs from blocks
const refs = collectBlockRefs(record.doc.blocks)
@@ -142,9 +140,8 @@ export async function getPageBySlug(slug: string): Promise<Page | null> {
cardGridCoverIds.add(page.cover)
}
}
const cardGridImages = cardGridCoverIds.size > 0
? await fetchImagesByIds([...cardGridCoverIds])
: new Map<string, PbImage>()
const cardGridImages =
cardGridCoverIds.size > 0 ? await fetchImagesByIds([...cardGridCoverIds]) : new Map<string, PbImage>()
// Merge all images for card-grid covers
const allCardGridImages = new Map([...images, ...cardGridImages])
@@ -159,18 +156,14 @@ export async function getPageBySlug(slug: string): Promise<Page | null> {
export async function getHomeCards(): Promise<PageCardDto[]> {
try {
const kv = await pb.collection("kv").getFirstListItem<{ data: PbHomeData }>(
"key=\"home\"",
)
const kv = await pb.collection("kv").getFirstListItem<{ data: PbHomeData }>('key="home"')
const data = kv.data
if (!data.cards || data.cards.length === 0) return []
const pageIds = data.cards.map(c => c.page)
const pages = await fetchPagesByIds(pageIds)
const coverIds = [...pages.values()]
.map(p => p.cover)
.filter(Boolean)
const coverIds = [...pages.values()].map(p => p.cover).filter(Boolean)
const images = await fetchImagesByIds(coverIds)
return createHomeCards(data, pages, images)
+1 -6
View File
@@ -36,12 +36,7 @@ export type DocV1BlockCardGrid = {
items: DocV1BlockCardGridItem[]
}
export type DocV1Block =
| DocV1BlockText
| DocV1BlockImage
| DocV1BlockDocument
| DocV1BlockEmbed
| DocV1BlockCardGrid
export type DocV1Block = DocV1BlockText | DocV1BlockImage | DocV1BlockDocument | DocV1BlockEmbed | DocV1BlockCardGrid
export type DocV1 = {
version: 1
+11 -12
View File
@@ -1,4 +1,4 @@
import { describe, it, expect } from "vitest"
import { describe, expect, it } from "vitest"
import { createFeedPages } from "./factory"
import type { PbPage } from "./types"
@@ -25,20 +25,19 @@ describe("createFeedPages", () => {
it("should map PB pages to feed pages", () => {
const pages = [stubPage({ id: "x1", title: "Hello", slug: "/hello", date: "2024-03-01", excerpt: "Summary" })]
const result = createFeedPages(pages)
expect(result).toEqual([{
id: "x1",
title: "Hello",
url: "/hello",
date: "2024-03-01",
excerpt: "Summary",
}])
expect(result).toEqual([
{
id: "x1",
title: "Hello",
url: "/hello",
date: "2024-03-01",
excerpt: "Summary",
},
])
})
it("should filter out pages without date", () => {
const pages = [
stubPage({ id: "a", date: "2024-01-01" }),
stubPage({ id: "b", date: "" }),
]
const pages = [stubPage({ id: "a", date: "2024-01-01" }), stubPage({ id: "b", date: "" })]
const result = createFeedPages(pages)
expect(result).toHaveLength(1)
expect(result[0].id).toBe("a")
+87 -77
View File
@@ -1,8 +1,8 @@
import type { MenuItem, Page, Tag, PageCardDto, Pic, Token, FeedPage, BreadcrumbPart } from "@/types"
import type { PbPage, PbImage, PbFile, PbTag, PbHomeData, PbMenuData } from "./types"
import { markdownToHtml, typograf } from "@hudozka/text"
import type { BreadcrumbPart, FeedPage, MenuItem, Page, PageCardDto, Pic, Tag, Token } from "@/types"
import type { DocV1Block } from "./doc"
import { typograf, markdownToHtml } from "@hudozka/text"
import { pb } from "./pb"
import type { PbFile, PbHomeData, PbImage, PbMenuData, PbPage, PbTag } from "./types"
const md = (text: string) => typograf(markdownToHtml(text))
@@ -54,15 +54,17 @@ export function isYoutubeUrl(url: string): boolean {
return /youtube\.com/.test(url)
}
export function createEmbed(src: string): Token {
export function createEmbed(id: string, src: string): Token {
if (isYoutubeUrl(src)) {
return {
id,
token: "youtube",
data: { url: src },
}
}
return {
id,
token: "html",
data: `<iframe src="${src}" width="100%" height="480" frameborder="0"></iframe>`,
}
@@ -76,78 +78,85 @@ export function createPageToken(
cardGridImages: Map<string, PbImage>,
): Token {
switch (block.type) {
case "text":
return {
token: "html",
data: md(block.text),
case "text":
return {
id: block.id,
token: "html",
data: md(block.text),
}
case "image": {
const image = images.get(block.image)
if (!image) {
return { id: block.id, token: "text", data: "" }
}
return {
id: block.id,
token: "image",
wide: block.wide,
data: imageRecordToPic(image, {
alt: block.caption,
caption: block.caption,
}),
}
}
case "image": {
const image = images.get(block.image)
if (!image) {
return { token: "text", data: "" }
case "document": {
const file = files.get(block.file)
if (!file) {
return { id: block.id, token: "text", data: "" }
}
const fileUrl = pbFileUrl(file.collectionId, file.id, file.file)
return {
id: block.id,
token: "file",
data: {
url: fileUrl,
slug: "",
image_url: fileUrl,
file_url: fileUrl,
title: block.title,
file_size: file.size,
file_format: file.mime,
},
}
}
return {
token: "image",
wide: block.wide,
data: imageRecordToPic(image, {
alt: block.caption,
caption: block.caption,
}),
}
}
case "document": {
const file = files.get(block.file)
if (!file) {
return { token: "text", data: "" }
}
const fileUrl = pbFileUrl(file.collectionId, file.id, file.file)
return {
token: "file",
data: {
url: fileUrl,
slug: "",
image_url: fileUrl,
file_url: fileUrl,
title: block.title,
file_size: file.size,
file_format: file.mime,
},
}
}
case "embed":
return createEmbed(block.id, block.src)
case "embed":
return createEmbed(block.src)
case "card-grid": {
const items: PageCardDto[] = block.items
.map(item => {
const page = cardGridPages.get(item.page)
if (!page) {
return null
}
const cover = getCoverPic(page.cover, cardGridImages)
return {
id: page.id,
url: page.slug,
title: page.title,
featured: item.layout === "big" || item.layout === "medium",
date: page.date || null,
cover,
}
})
.filter((x): x is PageCardDto => x !== null)
return {
token: "grid",
data: { items },
case "card-grid": {
const items: PageCardDto[] = block.items
.map(item => {
const page = cardGridPages.get(item.page)
if (!page) {
return null
}
const cover = getCoverPic(page.cover, cardGridImages)
return {
id: page.id,
url: page.slug,
title: page.title,
featured: item.layout === "big" || item.layout === "medium",
date: page.date || null,
cover,
}
})
.filter((x): x is PageCardDto => x !== null)
return {
id: block.id,
token: "grid",
data: { items },
}
}
}
default:
return {
token: "text",
data: JSON.stringify(block),
default: {
const fallback = block as DocV1Block
return {
id: fallback.id,
token: "text",
data: JSON.stringify(fallback),
}
}
}
}
@@ -185,6 +194,7 @@ export function createPage(
cover,
tokens: [
{
id: `${record.id}__title`,
token: "html",
data: md(`# ${record.title}`),
},
@@ -220,10 +230,7 @@ export function createHomeCards(
.filter((x): x is PageCardDto => x !== null)
}
export function createMenu(
data: PbMenuData,
pages: Map<string, PbPage>,
): MenuItem[] {
export function createMenu(data: PbMenuData, pages: Map<string, PbPage>): MenuItem[] {
const items: MenuItem[] = data.items
.map(item => {
const page = pages.get(item.page)
@@ -237,10 +244,13 @@ export function createMenu(
})
.filter((x): x is MenuItem => x !== null)
return [{
href: "/",
name: data.homeLabel,
}, ...items]
return [
{
href: "/",
name: data.homeLabel,
},
...items,
]
}
export function createFeedPages(pages: PbPage[]): FeedPage[] {
+10 -5
View File
@@ -1,8 +1,13 @@
import { proxy, subscribe } from "valtio"
import { clamp } from "@hudozka/utils"
import { proxy, subscribe } from "valtio"
export type ThemeName = "default" | "contrast"
export type ColorSchemeVariant = "black-on-white" | "white-on-black" | "blue-on-blue" | "brown-on-yellow" | "green-on-brown"
export type ColorSchemeVariant =
| "black-on-white"
| "white-on-black"
| "blue-on-blue"
| "brown-on-yellow"
| "green-on-brown"
export type FontTypeVariant = "serif" | "sans-serif"
const MIN_FONT_SIZE = 8
@@ -115,7 +120,7 @@ export function setLetterSpacingFour() {
theme.letterSpacing = 4
}
const unsubscribe = subscribe(theme, () => {
subscribe(theme, () => {
document.documentElement.style.setProperty("--font-size-default", `${theme.fontSize}pt`)
document.documentElement.style.setProperty("--font-size-second", `${theme.fontSize}pt`)
document.documentElement.style.setProperty("--font-size-accent", `${theme.fontSize}pt`)
@@ -127,9 +132,9 @@ const unsubscribe = subscribe(theme, () => {
}
// TODO remove this from here and move this logic directly to body tag after moving from pages to app
document.body.classList.forEach(name => {
for (const name of [...document.body.classList]) {
document.body.classList.remove(name)
})
}
if (theme.theme === "contrast") {
document.body.classList.add("theme-contrast")
document.body.classList.add(theme.colorScheme)
+8 -9
View File
@@ -77,7 +77,6 @@ export type Meta = {
twitterCreator: string
}
export type Tag = {
id: string
name: string
@@ -96,11 +95,13 @@ export type Pic = {
}
export type TextToken = {
id: string
token: "text"
data: string
}
export type ImageToken = {
id: string
token: "image"
wide: boolean
data: Pic
@@ -117,16 +118,19 @@ export type FileTokenData = {
}
export type FileToken = {
id: string
token: "file"
data: FileTokenData
}
export type HtmlToken = {
id: string
token: "html"
data: string
}
export type YoutubeToken = {
id: string
token: "youtube"
data: {
url: string
@@ -134,6 +138,7 @@ export type YoutubeToken = {
}
export type InstagramToken = {
id: string
token: "instagram"
data: {
url: string
@@ -142,20 +147,14 @@ export type InstagramToken = {
}
export type GridToken = {
id: string
token: "grid"
data: {
items: PageCardDto[]
}
}
export type Token =
| TextToken
| ImageToken
| FileToken
| HtmlToken
| YoutubeToken
| InstagramToken
| GridToken
export type Token = TextToken | ImageToken | FileToken | HtmlToken | YoutubeToken | InstagramToken | GridToken
export type FeedPage = {
id: string
+23 -34
View File
@@ -1,9 +1,8 @@
import s from "./picture.module.css"
import { memo } from "react"
import Image from "next/image"
import classnames from "classnames/bind"
import Image from "next/image"
import { memo } from "react"
import { getLayoutFromSize } from "./lib"
import s from "./picture.module.css"
const cx = classnames.bind(s)
@@ -19,36 +18,26 @@ export type PictureProps = {
layoutTolerance?: number
}
export const Picture: React.FC<PictureProps> = memo(({
wide: userWide = false,
layoutTolerance = 0.95,
style,
src,
alt,
width,
height,
caption,
blur,
}) => {
const layout = getLayoutFromSize(width, height, layoutTolerance)
const wide = layout === "horizontal" ? userWide : false
export const Picture: React.FC<PictureProps> = memo(
({ wide: userWide = false, layoutTolerance = 0.95, style, src, alt, width, height, caption, blur }) => {
const layout = getLayoutFromSize(width, height, layoutTolerance)
const wide = layout === "horizontal" ? userWide : false
return (
<figure className={cx(s.pic, layout, { wide })} style={style}>
<Image
className={s.img}
src={src}
alt={alt ?? ""}
width={width}
height={height}
blurDataURL={blur}
placeholder={blur ? "blur" : "empty"}
/>
{!caption ? null : (
<figcaption>{caption}</figcaption>
)}
</figure>
)
})
return (
<figure className={cx(s.pic, layout, { wide })} style={style}>
<Image
className={s.img}
src={src}
alt={alt ?? ""}
width={width}
height={height}
blurDataURL={blur}
placeholder={blur ? "blur" : "empty"}
/>
{!caption ? null : <figcaption>{caption}</figcaption>}
</figure>
)
},
)
Picture.displayName = "Picture"
+1 -6
View File
@@ -8,10 +8,5 @@ export type ThemeColorProps = {
export const ThemeColor: React.FC<ThemeColorProps> = ({ color, darkColor }) => {
const dark = useDarkTheme()
return (
<meta
name="theme-color"
content={dark ? darkColor : color}
/>
)
return <meta name="theme-color" content={dark ? darkColor : color} />
}
+2 -5
View File
@@ -3,10 +3,7 @@ import sharp from "sharp"
// Tiny jpeg base64 data URL for blur placeholder (matches src/remote/image.ts semantics).
export async function encodeBlur(buffer: Buffer): Promise<string | undefined> {
try {
const data = await sharp(buffer)
.resize(8, 8, { fit: "inside" })
.jpeg({ quality: 50 })
.toBuffer()
const data = await sharp(buffer).resize(8, 8, { fit: "inside" }).jpeg({ quality: 50 }).toBuffer()
return `data:image/jpeg;base64,${data.toString("base64")}`
} catch (e) {
console.warn("blur failed:", (e as Error).message)
@@ -14,7 +11,7 @@ export async function encodeBlur(buffer: Buffer): Promise<string | undefined> {
}
}
export async function imageDims(buffer: Buffer): Promise<{ width: number, height: number } | undefined> {
export async function imageDims(buffer: Buffer): Promise<{ width: number; height: number } | undefined> {
try {
const meta = await sharp(buffer).metadata()
if (meta.width && meta.height) {
+96 -88
View File
@@ -1,22 +1,14 @@
import PocketBase from "pocketbase"
import { customAlphabet } from "nanoid"
import PocketBase from "pocketbase"
const nanoid = customAlphabet("ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", 10)
import { readFileSync, writeFileSync, existsSync } from "node:fs"
import type {
StrapiComponent,
StrapiMedia,
StrapiPageCard,
} from "./strapi.ts"
import {
fetchAllPages,
fetchHome,
fetchMenu,
fetchTags,
} from "./strapi.ts"
import { download, basenameFromUrl } from "./download.ts"
import { existsSync, readFileSync, writeFileSync } from "node:fs"
import { encodeBlur, imageDims } from "./blurhash.ts"
import { basenameFromUrl, download } from "./download.ts"
import { sha256 } from "./hash.ts"
import type { StrapiComponent, StrapiMedia, StrapiPageCard } from "./strapi.ts"
import { fetchAllPages, fetchHome, fetchMenu, fetchTags } from "./strapi.ts"
const STRAPI_URL = process.env.STRAPI_URL ?? "https://hudozka.tmshv.com"
const POCKETBASE_URL = required("POCKETBASE_URL")
@@ -25,7 +17,7 @@ const POCKETBASE_PASSWORD = required("POCKETBASE_PASSWORD")
const STATE_FILE = new URL("../.migrate-state.json", import.meta.url).pathname
const PHASES = ["tags", "images", "files", "pages", "rewrite", "kv"] as const
type Phase = typeof PHASES[number]
type Phase = (typeof PHASES)[number]
function required(name: string): string {
const v = process.env[name]
@@ -39,21 +31,21 @@ function required(name: string): string {
// are strapi numeric ids; rewrite phase remaps them to PB string ids.
type Ref = string | number
type BlockBase = { id: string }
type BlockText = BlockBase & { type: "text", text: string }
type BlockImage = BlockBase & { type: "image", image: Ref, wide: boolean, caption: string }
type BlockDocument = BlockBase & { type: "document", file: Ref, title: string }
type BlockEmbed = BlockBase & { type: "embed", src: string }
type CardRef = { page: Ref, layout: "small" | "medium" | "big" }
type BlockCardGrid = BlockBase & { type: "card-grid", items: CardRef[] }
type BlockText = BlockBase & { type: "text"; text: string }
type BlockImage = BlockBase & { type: "image"; image: Ref; wide: boolean; caption: string }
type BlockDocument = BlockBase & { type: "document"; file: Ref; title: string }
type BlockEmbed = BlockBase & { type: "embed"; src: string }
type CardRef = { page: Ref; layout: "small" | "medium" | "big" }
type BlockCardGrid = BlockBase & { type: "card-grid"; items: CardRef[] }
type Block = BlockText | BlockImage | BlockDocument | BlockEmbed | BlockCardGrid
type DocContent = { version: 1, blocks: Block[] }
type DocContent = { version: 1; blocks: Block[] }
type State = {
tagMap: Record<string, string>,
imageMap: Record<string, string>,
fileMap: Record<string, string>,
pageMap: Record<string, string>,
tagMap: Record<string, string>
imageMap: Record<string, string>
fileMap: Record<string, string>
pageMap: Record<string, string>
}
function loadState(): State {
@@ -117,7 +109,9 @@ async function main() {
fetchHome(STRAPI_URL),
fetchMenu(STRAPI_URL),
])
console.log(`strapi: pages=${strapiPages.length} tags=${strapiTags.length} home.cards=${strapiHome.cards.length} menu.items=${strapiMenu.menu.items.length}`)
console.log(
`strapi: pages=${strapiPages.length} tags=${strapiTags.length} home.cards=${strapiHome.cards.length} menu.items=${strapiMenu.menu.items.length}`,
)
// phase: tags
if (phases.includes("tags")) {
@@ -174,9 +168,10 @@ async function main() {
continue
}
const filename = m.name || basenameFromUrl(m.url)
const dims = (m.width && m.height)
? { width: m.width, height: m.height }
: (await imageDims(buf) ?? { width: 0, height: 0 })
const dims =
m.width && m.height
? { width: m.width, height: m.height }
: ((await imageDims(buf)) ?? { width: 0, height: 0 })
const blur = await encodeBlur(buf)
const form = new FormData()
@@ -196,19 +191,28 @@ async function main() {
const rec = await pb.collection("images").create(form)
imageMap.set(sid, rec.id)
imageHashIndex.set(hash, rec.id)
console.log(` [${ii}/${imageMedia.size}] ${filename} (${dims.width}x${dims.height}, ${Math.round(buf.byteLength / 1024)}KB, blur=${blur ? blur.length + "ch" : "none"}) -> ${rec.id}`)
console.log(
` [${ii}/${imageMedia.size}] ${filename} (${dims.width}x${dims.height}, ${Math.round(buf.byteLength / 1024)}KB, blur=${blur ? `${blur.length}ch` : "none"}) -> ${rec.id}`,
)
} catch (e) {
console.error(` [${ii}/${imageMedia.size}] image ${sid} failed:`)
console.error(` strapi media:`, JSON.stringify({
id: m.id,
name: m.name,
mime: m.mime,
ext: m.ext,
sizeKB: m.size,
width: m.width,
height: m.height,
url: m.url,
}, null, 2))
console.error(
` strapi media:`,
JSON.stringify(
{
id: m.id,
name: m.name,
mime: m.mime,
ext: m.ext,
sizeKB: m.size,
width: m.width,
height: m.height,
url: m.url,
},
null,
2,
),
)
printErr(e)
}
}
@@ -219,7 +223,7 @@ async function main() {
// phase: files
if (phases.includes("files")) {
const docMedia = new Map<number, { id: number, url: string, name: string, mime: string, size: number }>()
const docMedia = new Map<number, { id: number; url: string; name: string; mime: string; size: number }>()
for (const p of strapiPages) {
for (const c of p.content) {
if (c.__component === "hudozka.document" && c.media) {
@@ -307,7 +311,9 @@ async function main() {
try {
const rec = await pb.collection("pages").create(data)
pageMap.set(p.id, rec.id)
console.log(` [${pi}/${strapiPages.length}] ${p.slug} -> ${rec.id} (${blocks.length} blocks, ${tagIds.length} tags${coverId ? ", cover" : ""})`)
console.log(
` [${pi}/${strapiPages.length}] ${p.slug} -> ${rec.id} (${blocks.length} blocks, ${tagIds.length} tags${coverId ? ", cover" : ""})`,
)
} catch (e) {
console.error(` [${pi}/${strapiPages.length}] page ${p.id} (${p.slug}) failed:`)
printErr(e)
@@ -334,7 +340,9 @@ async function main() {
const rec = await pb.collection("pages").getOne(pbId, { fields: "doc" })
const doc = (rec as unknown as { doc: DocContent }).doc
const before = doc.blocks.length
doc.blocks = doc.blocks.map(b => remapBlock(b, imageMap, fileMap, pageMap)).filter((b): b is Block => b !== null)
doc.blocks = doc.blocks
.map(b => remapBlock(b, imageMap, fileMap, pageMap))
.filter((b): b is Block => b !== null)
dropped += before - doc.blocks.length
const tagIds = p.tags.map(t => tagMap.get(t.id)).filter((x): x is string => !!x)
@@ -355,7 +363,7 @@ async function main() {
console.log(`\n[kv] creating home + menu...`)
const homeCards = strapiHome.cards
.map(c => ({ page: pageMap.get(c.page.id), layout: c.layout }))
.filter((c): c is { page: string, layout: "small" | "medium" | "big" } => !!c.page)
.filter((c): c is { page: string; layout: "small" | "medium" | "big" } => !!c.page)
await pb.collection("kv").create({
key: "home",
@@ -385,24 +393,24 @@ async function main() {
function toBlock(c: StrapiComponent): Block | null {
const id = nanoid()
switch (c.__component) {
case "hudozka.text":
return { id, type: "text", text: c.text }
case "hudozka.image":
if (!c.media) return null
return { id, type: "image", image: c.media.id, wide: !!c.wide, caption: c.caption ?? "" }
case "hudozka.document":
if (!c.media) return null
return { id, type: "document", file: c.media.id, title: c.title ?? "" }
case "hudozka.embed":
return { id, type: "embed", src: c.src }
case "hudozka.card-grid": {
const items: CardRef[] = (c.items ?? [])
.filter((it: StrapiPageCard) => it && it.page)
.map((it: StrapiPageCard) => ({ page: it.page.id, layout: it.layout }))
return { id, type: "card-grid", items }
}
default:
return null
case "hudozka.text":
return { id, type: "text", text: c.text }
case "hudozka.image":
if (!c.media) return null
return { id, type: "image", image: c.media.id, wide: !!c.wide, caption: c.caption ?? "" }
case "hudozka.document":
if (!c.media) return null
return { id, type: "document", file: c.media.id, title: c.title ?? "" }
case "hudozka.embed":
return { id, type: "embed", src: c.src }
case "hudozka.card-grid": {
const items: CardRef[] = (c.items ?? [])
.filter((it: StrapiPageCard) => it?.page)
.map((it: StrapiPageCard) => ({ page: it.page.id, layout: it.layout }))
return { id, type: "card-grid", items }
}
default:
return null
}
}
@@ -415,30 +423,30 @@ function remapBlock(
pageMap: Map<number, string>,
): Block | null {
switch (b.type) {
case "image": {
if (typeof b.image === "string") return b
const ref = imageMap.get(b.image)
if (!ref) return null
return { ...b, image: ref }
}
case "document": {
if (typeof b.file === "string") return b
const ref = fileMap.get(b.file)
if (!ref) return null
return { ...b, file: ref }
}
case "card-grid": {
const items = b.items
.map(it => {
if (typeof it.page === "string") return it
const mapped = pageMap.get(it.page)
return mapped ? { page: mapped, layout: it.layout } : null
})
.filter((x): x is CardRef => x !== null)
return { ...b, items }
}
default:
return b
case "image": {
if (typeof b.image === "string") return b
const ref = imageMap.get(b.image)
if (!ref) return null
return { ...b, image: ref }
}
case "document": {
if (typeof b.file === "string") return b
const ref = fileMap.get(b.file)
if (!ref) return null
return { ...b, file: ref }
}
case "card-grid": {
const items = b.items
.map(it => {
if (typeof it.page === "string") return it
const mapped = pageMap.get(it.page)
return mapped ? { page: mapped, layout: it.layout } : null
})
.filter((x): x is CardRef => x !== null)
return { ...b, items }
}
default:
return b
}
}
@@ -453,7 +461,7 @@ async function buildHashIndex(pb: PocketBase, collection: string): Promise<Map<s
}
function printErr(e: unknown, input?: unknown) {
const err = e as { message?: string, url?: string, status?: number, response?: { data?: unknown } }
const err = e as { message?: string; url?: string; status?: number; response?: { data?: unknown } }
console.error("ERROR:", err.message)
if (err.url) console.error(" url:", err.url, "status:", err.status)
if (input !== undefined) {
+59 -59
View File
@@ -1,57 +1,57 @@
export type StrapiMediaObject = {
url: string,
name: string,
hash: string,
ext: string,
mime: string,
width: number,
height: number,
size: number,
url: string
name: string
hash: string
ext: string
mime: string
width: number
height: number
size: number
}
export type StrapiMedia = StrapiMediaObject & {
id: number,
alternativeText: string | null,
caption: string | null,
id: number
alternativeText: string | null
caption: string | null
formats: null | {
thumbnail: StrapiMediaObject,
large: StrapiMediaObject,
medium: StrapiMediaObject,
small: StrapiMediaObject,
},
thumbnail: StrapiMediaObject
large: StrapiMediaObject
medium: StrapiMediaObject
small: StrapiMediaObject
}
}
export type StrapiComponentText = {
__component: "hudozka.text",
id: number,
text: string,
__component: "hudozka.text"
id: number
text: string
}
export type StrapiComponentImage = {
__component: "hudozka.image",
id: number,
wide: boolean,
caption: string,
media: StrapiMedia,
__component: "hudozka.image"
id: number
wide: boolean
caption: string
media: StrapiMedia
}
export type StrapiComponentDocument = {
__component: "hudozka.document",
id: number,
title: string,
media: StrapiMediaObject & { id: number },
__component: "hudozka.document"
id: number
title: string
media: StrapiMediaObject & { id: number }
}
export type StrapiComponentEmbed = {
__component: "hudozka.embed",
id: number,
src: string,
__component: "hudozka.embed"
id: number
src: string
}
export type StrapiComponentCardGrid = {
__component: "hudozka.card-grid",
id: number,
items: StrapiPageCard[],
__component: "hudozka.card-grid"
id: number
items: StrapiPageCard[]
}
export type StrapiComponent =
@@ -62,42 +62,42 @@ export type StrapiComponent =
| StrapiComponentCardGrid
export type StrapiTag = {
id: number,
slug: string,
name: string,
id: number
slug: string
name: string
}
export type StrapiPage = {
id: number,
title: string,
excerpt: string,
slug: string,
date: string | null,
cover?: StrapiMedia,
published_at: string,
created_at: string,
updated_at: string,
content: StrapiComponent[],
tags: StrapiTag[],
id: number
title: string
excerpt: string
slug: string
date: string | null
cover?: StrapiMedia
published_at: string
created_at: string
updated_at: string
content: StrapiComponent[]
tags: StrapiTag[]
}
export type StrapiPageCard = {
__component: "hudozka.page-card",
id: number,
page: StrapiPage,
layout: "small" | "medium" | "big",
__component: "hudozka.page-card"
id: number
page: StrapiPage
layout: "small" | "medium" | "big"
}
export type StrapiHome = {
id: number,
title: string,
cards: StrapiPageCard[],
id: number
title: string
cards: StrapiPageCard[]
}
export type StrapiMenu = {
id: number,
homeLabel: string,
menu: StrapiComponentCardGrid,
id: number
homeLabel: string
menu: StrapiComponentCardGrid
}
async function get<T>(base: string, path: string): Promise<T> {
@@ -105,7 +105,7 @@ async function get<T>(base: string, path: string): Promise<T> {
if (!res.ok) {
throw new Error(`GET ${path} -> ${res.status}`)
}
return await res.json() as T
return (await res.json()) as T
}
export async function fetchAllPages(base: string): Promise<StrapiPage[]> {
+3 -3
View File
@@ -1,5 +1,5 @@
import PocketBase from "pocketbase"
import { existsSync, readFileSync, writeFileSync } from "node:fs"
import PocketBase from "pocketbase"
const STATE_FILE = new URL("../.migrate-state.json", import.meta.url).pathname
const POCKETBASE_URL = required("POCKETBASE_URL")
@@ -39,7 +39,7 @@ async function main() {
const args = process.argv.slice(2)
const targets = args.length > 0 ? args : [...KNOWN]
const unknown = targets.filter(t => !KNOWN.includes(t as typeof KNOWN[number]))
const unknown = targets.filter(t => !KNOWN.includes(t as (typeof KNOWN)[number]))
if (unknown.length > 0) {
console.error(`unknown collections: ${unknown.join(", ")}`)
console.error(`known: ${KNOWN.join(", ")}`)
@@ -80,7 +80,7 @@ async function main() {
}
main().catch(e => {
const err = e as { message?: string, url?: string, status?: number, response?: { data?: unknown } }
const err = e as { message?: string; url?: string; status?: number; response?: { data?: unknown } }
console.error("ERROR:", err.message)
if (err.url) console.error(" url:", err.url, "status:", err.status)
if (err.response?.data !== undefined) {
+75
View File
@@ -0,0 +1,75 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.13/schema.json",
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 4,
"lineEnding": "lf",
"lineWidth": 120
},
"javascript": {
"formatter": {
"quoteStyle": "double",
"trailingCommas": "all",
"semicolons": "asNeeded",
"arrowParentheses": "asNeeded"
}
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"correctness": {
"noUnusedVariables": "error",
"noUnusedImports": "error",
"useExhaustiveDependencies": "error"
},
"suspicious": {
"noExplicitAny": "error",
"noDebugger": "error",
"noConsole": {
"level": "error",
"options": {
"allow": ["error", "warn"]
}
},
"noArrayIndexKey": "error",
"noShadowRestrictedNames": "error",
"noVar": "error"
},
"style": {
"noNonNullAssertion": "error"
},
"performance": {
"noAccumulatingSpread": "error"
},
"a11y": {
"useButtonType": "error",
"noSvgWithoutTitle": "error",
"noStaticElementInteractions": "off"
},
"complexity": {
"noForEach": "error",
"noBannedTypes": "error"
},
"security": {
"noDangerouslySetInnerHtml": "off"
}
}
},
"overrides": [
{
"includes": ["apps/migrate-to-pocketbase/**"],
"linter": {
"rules": {
"suspicious": {
"noConsole": "off"
}
}
}
}
],
"files": {
"includes": ["**/src/**/*.ts", "**/src/**/*.tsx", "**/src/**/*.js"]
}
}
-42
View File
@@ -1,42 +0,0 @@
import { defineConfig, globalIgnores } from "eslint/config"
import nextVitals from "eslint-config-next/core-web-vitals"
import globals from "globals"
export default defineConfig([
globalIgnores([
"**/node_modules/**",
"**/.next/**",
"**/dist/**",
"**/build/**",
"**/out/**",
"apps/hudozka/public/static/**",
"pb/pb_data/**",
"pb/pb_migrations/**",
"**/next-env.d.ts",
]),
{
files: ["apps/hudozka/**/*.{js,jsx,ts,tsx}"],
extends: nextVitals,
rules: {
"react/jsx-uses-react": "error",
"react/jsx-uses-vars": "error",
},
},
{
languageOptions: {
globals: globals.browser,
},
},
{
rules: {
semi: ["error", "never"],
indent: ["warn", 4],
quotes: ["warn", "double"],
"eol-last": ["error", "always"],
"comma-dangle": ["error", "always-multiline"],
"no-tabs": "error",
"no-var": "error",
"space-before-function-paren": "off",
},
},
])
+164 -4572
View File
File diff suppressed because it is too large. Load diff
+7 -8
View File
@@ -4,13 +4,16 @@
"version": "7.0.0",
"type": "module",
"description": "",
"workspaces": ["apps/*", "packages/*"],
"workspaces": [
"apps/*",
"packages/*"
],
"scripts": {
"dev": "npm run dev -w apps/hudozka",
"build": "npm run build -w apps/hudozka",
"start": "npm run start -w apps/hudozka",
"lint": "eslint .",
"format": "eslint --fix .",
"lint": "biome check",
"format": "biome check --write",
"test": "npm test --workspaces --if-present"
},
"repository": {
@@ -20,15 +23,11 @@
"author": "Roman Timashev",
"license": "ISC",
"devDependencies": {
"@biomejs/biome": "^2.4.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/node": "^25.2.0",
"@types/react": "^19.2.0",
"@typescript-eslint/eslint-plugin": "^8.59.0",
"@typescript-eslint/parser": "^8.59.0",
"eslint": "^9.39.0",
"eslint-config-next": "^16.2.0",
"globals": "^17.5.0",
"happy-dom": "^20.9.0",
"typescript": "^6.0.0",
"vitest": "^4.1.0"
+1 -1
View File
@@ -1,5 +1,5 @@
export * from "./useToggle"
export * from "./useLockBodyScroll"
export * from "./useMediaQuery"
export * from "./useMobile"
export * from "./useReducedMotion"
export * from "./useToggle"
+2 -5
View File
@@ -1,5 +1,5 @@
import { renderHook } from "@testing-library/react"
import { describe, it, expect, beforeEach } from "vitest"
import { beforeEach, describe, expect, it } from "vitest"
import { useLockBodyScroll } from "./useLockBodyScroll"
describe("useLockBodyScroll", () => {
@@ -27,10 +27,7 @@ describe("useLockBodyScroll", () => {
})
it("responds to lock/unlock transitions", () => {
const { rerender } = renderHook(
({ locked }) => useLockBodyScroll(locked),
{ initialProps: { locked: false } },
)
const { rerender } = renderHook(({ locked }) => useLockBodyScroll(locked), { initialProps: { locked: false } })
expect(document.body.style.overflow).toBe("")
rerender({ locked: true })
expect(document.body.style.overflow).toBe("hidden")
+10 -6
View File
@@ -1,5 +1,5 @@
import { renderHook, act } from "@testing-library/react"
import { describe, it, expect, beforeEach, vi } from "vitest"
import { act, renderHook } from "@testing-library/react"
import { beforeEach, describe, expect, it, vi } from "vitest"
import { useMediaQuery } from "./useMediaQuery"
describe("useMediaQuery", () => {
@@ -13,10 +13,12 @@ describe("useMediaQuery", () => {
vi.stubGlobal("matchMedia", (query: string) => ({
matches: matches.get(query) ?? false,
addEventListener: (_: string, cb: () => void) => {
if (!listeners.has(query)) {
listeners.set(query, new Set())
let set = listeners.get(query)
if (!set) {
set = new Set()
listeners.set(query, set)
}
listeners.get(query)!.add(cb)
set.add(cb)
},
removeEventListener: (_: string, cb: () => void) => {
listeners.get(query)?.delete(cb)
@@ -42,7 +44,9 @@ describe("useMediaQuery", () => {
act(() => {
matches.set(query, true)
listeners.get(query)?.forEach((cb) => cb())
for (const cb of listeners.get(query) ?? []) {
cb()
}
})
expect(result.current).toBe(true)
})
+1 -1
View File
@@ -2,7 +2,7 @@ import { useSyncExternalStore } from "react"
export function useMediaQuery(query: string, defaultValue = false): boolean {
return useSyncExternalStore(
(callback) => {
callback => {
const mql = window.matchMedia(query)
mql.addEventListener("change", callback)
return () => mql.removeEventListener("change", callback)
+2 -2
View File
@@ -1,5 +1,5 @@
import { renderHook, act } from "@testing-library/react"
import { describe, it, expect } from "vitest"
import { act, renderHook } from "@testing-library/react"
import { describe, expect, it } from "vitest"
import { useToggle } from "./useToggle"
describe("useToggle", () => {
+1 -1
View File
@@ -3,7 +3,7 @@ import { useCallback, useState } from "react"
export function useToggle(initialValue: boolean): [boolean, (nextValue?: boolean) => void] {
const [state, setState] = useState(initialValue)
const toggle = useCallback((next?: boolean) => {
setState(prev => typeof next === "boolean" ? next : !prev)
setState(prev => (typeof next === "boolean" ? next : !prev))
}, [])
return [state, toggle]
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { describe, it, expect } from "vitest"
import { describe, expect, it } from "vitest"
import { markdownToHtml, typograf } from "./text"
describe("markdownToHtml", () => {
+9 -5
View File
@@ -1,5 +1,5 @@
import { render, screen } from "@testing-library/react"
import { describe, it, expect } from "vitest"
import { describe, expect, it } from "vitest"
import { Box } from "."
describe("Box", () => {
@@ -20,25 +20,29 @@ describe("Box", () => {
it("applies vertical class", () => {
const { container } = render(<Box vertical>v</Box>)
const el = container.firstElementChild!
const el = container.firstElementChild
if (!el) throw new Error("expected rendered element")
expect(el.className).toMatch(/vertical/)
})
it("applies center class", () => {
const { container } = render(<Box center>c</Box>)
const el = container.firstElementChild!
const el = container.firstElementChild
if (!el) throw new Error("expected rendered element")
expect(el.className).toMatch(/center/)
})
it("applies wrap class", () => {
const { container } = render(<Box wrap>w</Box>)
const el = container.firstElementChild!
const el = container.firstElementChild
if (!el) throw new Error("expected rendered element")
expect(el.className).toMatch(/wrap/)
})
it("passes gap to inline style", () => {
const { container } = render(<Box gap={8}>g</Box>)
const el = container.firstElementChild!
const el = container.firstElementChild
if (!el) throw new Error("expected rendered element")
expect(el).toHaveStyle({ gap: "8px" })
})
})
+1 -2
View File
@@ -1,7 +1,6 @@
import s from "./box.module.css"
import classnames from "classnames/bind"
import { createElement } from "react"
import s from "./box.module.css"
const cx = classnames.bind(s)
@@ -1,5 +1,5 @@
import { render, within } from "@testing-library/react"
import { describe, it, expect } from "vitest"
import { describe, expect, it } from "vitest"
import { Breadcrumbs } from "."
describe("Breadcrumbs", () => {
@@ -9,9 +9,21 @@ describe("Breadcrumbs", () => {
{ name: "Team", href: "/about/team" },
]
function getNav(container: HTMLElement) {
const navEl = container.querySelector("nav")
if (!navEl) throw new Error("expected <nav> element to be rendered")
return within(navEl)
}
function getClosestLink(el: HTMLElement) {
const link = el.closest("a")
if (!link) throw new Error("expected ancestor <a> element")
return link
}
it("renders breadcrumb items with separators", () => {
const { container } = render(<Breadcrumbs items={items} path="/about/team" />)
const nav = within(container.querySelector("nav")!)
const nav = getNav(container)
expect(nav.getByText("Home")).toBeInTheDocument()
expect(nav.getByText("About")).toBeInTheDocument()
@@ -23,12 +35,12 @@ describe("Breadcrumbs", () => {
it("disables the button matching the current path", () => {
const { container } = render(<Breadcrumbs items={items} path="/about" />)
const nav = within(container.querySelector("nav")!)
const nav = getNav(container)
const aboutLink = nav.getByText("About").closest("a")!
const aboutLink = getClosestLink(nav.getByText("About"))
expect(aboutLink.className).toMatch(/disabled/)
const homeLink = nav.getByText("Home").closest("a")!
const homeLink = getClosestLink(nav.getByText("Home"))
expect(homeLink.className).not.toMatch(/disabled/)
})
})
+13 -24
View File
@@ -1,7 +1,6 @@
import s from "./styles.module.css"
import { Fragment } from "react"
import { Button } from "../Button"
import { insertBetween } from "@hudozka/utils"
import s from "./styles.module.css"
export type Crumb = {
name: string
@@ -14,33 +13,23 @@ export type BreadcrumbsProps = {
path: string
}
export const Breadcrumbs: React.FC<BreadcrumbsProps> = props => {
const items = insertBetween(props.items, () => "/")
export function Breadcrumbs(props: BreadcrumbsProps) {
return (
<nav className={s.breadcrumbs} style={props.style}>
{items.map((x, i) => {
if (typeof x === "string") {
return (
<li key={i}>
<span style={{ margin: "0 var(--size-xs)" }}>{x}</span>
{props.items.map((x, i) => (
<Fragment key={x.href}>
{i > 0 && (
<li>
<span style={{ margin: "0 var(--size-xs)" }}>/</span>
</li>
)
}
return (
<li key={x.href}>
<Button
href={x.href}
theme={"ghost"}
size={"small"}
disabled={x.href === props.path}
>
)}
<li>
<Button href={x.href} theme={"ghost"} size={"small"} disabled={x.href === props.path}>
{x.name}
</Button>
</li>
)
})}
</Fragment>
))}
</nav>
)
}
+10 -4
View File
@@ -1,5 +1,5 @@
import { render, screen, fireEvent } from "@testing-library/react"
import { describe, it, expect, vi } from "vitest"
import { fireEvent, render, screen } from "@testing-library/react"
import { describe, expect, it, vi } from "vitest"
import { Button } from "."
describe("Button", () => {
@@ -19,7 +19,11 @@ describe("Button", () => {
it("calls onClick with value when clicked", () => {
const onClick = vi.fn()
render(<Button value="test-value" onClick={onClick}>Click</Button>)
render(
<Button value="test-value" onClick={onClick}>
Click
</Button>,
)
fireEvent.click(screen.getByRole("button", { name: "Click" }))
expect(onClick).toHaveBeenCalledOnce()
expect(onClick).toHaveBeenCalledWith("test-value", expect.anything())
@@ -35,6 +39,8 @@ describe("Button", () => {
it("applies disabled class when disabled is true", () => {
const { container } = render(<Button disabled>Disabled</Button>)
expect(container.firstElementChild!.className).toMatch(/disabled/)
const el = container.firstElementChild
if (!el) throw new Error("expected rendered element")
expect(el.className).toMatch(/disabled/)
})
})
+13 -13
View File
@@ -1,9 +1,9 @@
import s from "./button.module.css"
import cx from "classnames"
import Link from "next/link"
import cx from "classnames"
import { useCallback } from "react"
import type { MouseEvent, MouseEventHandler } from "react"
import { useCallback } from "react"
import s from "./button.module.css"
const sizeClass = {
default: s.sizeDefault,
@@ -31,11 +31,15 @@ export const Button: React.FC<ButtonProps> = ({ size = "default", theme = "defau
const className = cx(s.button, sizeClass[size], themeClass[theme], {
[s.disabled]: disabled,
})
const onClick = useCallback<MouseEventHandler<HTMLButtonElement>>(event => {
if (typeof props.onClick === "function") {
props.onClick(props.value, event)
}
}, [props])
const { onClick: onClickProp, value } = props
const onClick = useCallback<MouseEventHandler<HTMLButtonElement>>(
event => {
if (typeof onClickProp === "function") {
onClickProp(value, event)
}
},
[onClickProp, value],
)
if (props.href) {
return (
@@ -46,11 +50,7 @@ export const Button: React.FC<ButtonProps> = ({ size = "default", theme = "defau
}
return (
<button
className={className}
style={props.style}
onClick={onClick}
>
<button type="button" className={className} style={props.style} onClick={onClick}>
{props.children}
</button>
)
+1 -4
View File
@@ -17,10 +17,7 @@ export function Transition(props: TransitionProps) {
setMounted(false)
}
}, [opacity])
const transition = useMemo(
() => `all ${props.duration}ms`,
[props.duration],
)
const transition = useMemo(() => `all ${props.duration}ms`, [props.duration])
if (props.show && !mounted) {
setMounted(true)
+9 -8
View File
@@ -1,7 +1,6 @@
import s from "./styles.module.css"
import { Transition } from "./Transition"
import { useLockBodyScroll, useReducedMotion } from "@hudozka/hooks"
import s from "./styles.module.css"
import { Transition } from "./Transition"
export type OverlayProps = {
children?: React.ReactNode
@@ -18,16 +17,18 @@ export const Overlay: React.FC<OverlayProps> = props => {
useLockBodyScroll(props.show)
return (
<Transition
duration={duration}
show={props.show}
>
<Transition duration={duration} show={props.show}>
<div
onClick={(event) => {
onClick={event => {
if (event.target === event.currentTarget) {
props.onClickOverlay()
}
}}
onKeyDown={event => {
if (event.key === "Escape") {
props.onClickOverlay()
}
}}
className={s.overlay}
style={props.style}
>
+7 -4
View File
@@ -1,5 +1,5 @@
import { render } from "@testing-library/react"
import { describe, it, expect } from "vitest"
import { describe, expect, it } from "vitest"
import { Panel } from "."
describe("Panel", () => {
@@ -10,20 +10,23 @@ describe("Panel", () => {
it("applies the default variant class", () => {
const { container } = render(<Panel>content</Panel>)
const el = container.firstElementChild!
const el = container.firstElementChild
if (!el) throw new Error("expected rendered element")
expect(el.className).toMatch(/default/)
})
it("applies the ghost variant class when ghost is true", () => {
const { container } = render(<Panel ghost>content</Panel>)
const el = container.firstElementChild!
const el = container.firstElementChild
if (!el) throw new Error("expected rendered element")
expect(el.className).toMatch(/ghost/)
expect(el.className).not.toMatch(/default/)
})
it("forwards a custom className", () => {
const { container } = render(<Panel className="custom">content</Panel>)
const el = container.firstElementChild!
const el = container.firstElementChild
if (!el) throw new Error("expected rendered element")
expect(el.className).toContain("custom")
})
})
+8 -9
View File
@@ -1,6 +1,5 @@
import s from "./panel.module.css"
import { Box } from "../Box"
import s from "./panel.module.css"
export type PanelProps = {
children?: React.ReactNode
@@ -9,16 +8,16 @@ export type PanelProps = {
ghost?: boolean
}
export function Panel({
style,
className,
children,
ghost = false,
}: PanelProps) {
export function Panel({ style, className, children, ghost = false }: PanelProps) {
const variant = ghost ? s.ghost : s.default
return (
<Box vertical align={false} className={`${s.panel} ${variant}${className ? ` ${className}` : ""}`} style={style}>
<Box
vertical
align={false}
className={`${s.panel} ${variant}${className ? ` ${className}` : ""}`}
style={style}
>
{children}
</Box>
)
Loaded 100 of 112 files, more files were not shown because too many files have changed in this diff. Show more