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