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