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