diff --git a/bun.lockb b/bun.lockb index 5c0e788..c2e7684 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index f088837..af29904 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,8 @@ "@tiptap/extension-image": "^3.14.0", "@tiptap/extension-link": "^3.14.0", "@tiptap/extension-list-item": "^3.14.0", + "@tiptap/extension-subscript": "^3.14.0", + "@tiptap/extension-superscript": "^3.14.0", "@tiptap/extension-table": "^3.14.0", "@tiptap/extension-table-cell": "^3.14.0", "@tiptap/extension-table-header": "^3.14.0", diff --git a/src/app.css b/src/app.css index a43a2b8..e6f1f35 100644 --- a/src/app.css +++ b/src/app.css @@ -348,26 +348,6 @@ label.underlinedInputLabel { color: var(--color-surface1); } -.logoSpinner:hover { - animation: spinner 1.5s ease; -} -@keyframes spinner { - from { - transform: rotate(0deg); - } - to { - transform: rotate(-360deg); - } -} -@keyframes spinReverse { - to { - transform: rotate(-360deg); - } -} -.animate-spin-reverse { - animation: spinReverse 1s linear infinite; -} - .vertical-rule-around { display: flex; flex-direction: column; @@ -1221,3 +1201,6 @@ svg.mermaid text { display: block; margin-right: auto; } +.reference-item > span.ml-2 { + font-style: italic; +} diff --git a/src/components/LoadingSpinner.tsx b/src/components/LoadingSpinner.tsx index 011fdbc..5665cc0 100644 --- a/src/components/LoadingSpinner.tsx +++ b/src/components/LoadingSpinner.tsx @@ -1,16 +1,12 @@ +import { Spinner } from "~/components/Spinner"; + export default function LoadingSpinner(props: { height: number; width: number; }) { return ( - - - logo - +
+ +
); } diff --git a/src/components/SkeletonLoader.tsx b/src/components/SkeletonLoader.tsx index f52c37b..747e87c 100644 --- a/src/components/SkeletonLoader.tsx +++ b/src/components/SkeletonLoader.tsx @@ -1,68 +1,42 @@ -import { onMount, onCleanup, createSignal, JSX } from "solid-js"; -import { isServer } from "solid-js/web"; - -const spinnerChars = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; +import { JSX } from "solid-js"; +import { Spinner } from "~/components/Spinner"; interface SkeletonProps { class?: string; } -function useSpinner() { - const [showing, setShowing] = createSignal(0); - - onMount(() => { - if (isServer) return; - - const interval = setInterval(() => { - setShowing((prev) => (prev + 1) % spinnerChars.length); - }, 50); - - onCleanup(() => { - clearInterval(interval); - }); - }); - - return () => spinnerChars[showing()]; -} - export function SkeletonBox(props: SkeletonProps) { - const spinner = useSpinner(); - return (
- {spinner()} +
); } export function SkeletonText(props: SkeletonProps) { - const spinner = useSpinner(); - return (
- {spinner()} +
); } export function SkeletonCircle(props: SkeletonProps) { - const spinner = useSpinner(); - return (
- {spinner()} +
); } diff --git a/src/components/Spinner.tsx b/src/components/Spinner.tsx new file mode 100644 index 0000000..cee7ce4 --- /dev/null +++ b/src/components/Spinner.tsx @@ -0,0 +1,58 @@ +import { onMount, onCleanup, createSignal, JSX } from "solid-js"; +import { isServer } from "solid-js/web"; + +const spinnerChars = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + +export interface SpinnerProps { + size?: "sm" | "md" | "lg" | "xl" | number; + class?: string; + "aria-label"?: string; +} + +const sizeMap = { + sm: "text-base", + md: "text-2xl", + lg: "text-4xl", + xl: "text-6xl" +}; + +export function Spinner(props: SpinnerProps) { + const [showing, setShowing] = createSignal(0); + + onMount(() => { + if (isServer) return; + + const interval = setInterval(() => { + setShowing((prev) => (prev + 1) % spinnerChars.length); + }, 50); + + onCleanup(() => { + clearInterval(interval); + }); + }); + + const sizeClass = () => { + if (typeof props.size === "number") { + return ""; + } + return sizeMap[props.size || "md"]; + }; + + const style = () => { + if (typeof props.size === "number") { + return { "font-size": `${props.size}px`, "line-height": "1" }; + } + return {}; + }; + + return ( + + {spinnerChars[showing()]} + + ); +} diff --git a/src/components/TerminalSplash.tsx b/src/components/TerminalSplash.tsx index 43dab6e..ee3e5b6 100644 --- a/src/components/TerminalSplash.tsx +++ b/src/components/TerminalSplash.tsx @@ -1,29 +1,11 @@ -import { onMount, onCleanup, createSignal } from "solid-js"; -import { isServer } from "solid-js/web"; - -const spinnerChars = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; +import { Spinner } from "~/components/Spinner"; export function TerminalSplash() { - const [showing, setShowing] = createSignal(0); - - onMount(() => { - // Only run animation on client - if (isServer) return; - - const interval = setInterval(() => { - setShowing((prev) => (prev + 1) % spinnerChars.length); - }, 50); - - onCleanup(() => { - clearInterval(interval); - }); - }); - return (
-
+
- {spinnerChars[showing()]} +
diff --git a/src/components/blog/PostBodyClient.tsx b/src/components/blog/PostBodyClient.tsx index 5c2933b..4da78ad 100644 --- a/src/components/blog/PostBodyClient.tsx +++ b/src/components/blog/PostBodyClient.tsx @@ -1,5 +1,4 @@ -import { createEffect } from "solid-js"; -import { createSignal } from "solid-js"; +import { createEffect, createSignal, onMount } from "solid-js"; import type { HLJSApi } from "highlight.js"; import MermaidRenderer from "./MermaidRenderer"; @@ -98,6 +97,161 @@ export default function PostBodyClient(props: PostBodyClientProps) { let contentRef: HTMLDivElement | undefined; const [hljs, setHljs] = createSignal(null); + // Process superscript references and enhance the References section + const processReferences = () => { + if (!contentRef) return; + + const foundRefs = new Map(); + + // Find all elements with [n] pattern + const supElements = contentRef.querySelectorAll("sup"); + + supElements.forEach((sup) => { + const text = sup.textContent?.trim() || ""; + // Match patterns like [1], [2], [a], [*], etc. + const match = text.match(/^\[(.+?)\]$/); + + if (match) { + const refNumber = match[1]; + const refId = `ref-${refNumber}`; + const refBackId = `ref-${refNumber}-back`; + + // Add ID to the sup element itself for back navigation + sup.id = refBackId; + + // Replace sup content with a clickable link + sup.innerHTML = ""; + const link = document.createElement("a"); + link.href = `#${refId}`; + link.textContent = `[${refNumber}]`; + link.className = + "reference-link text-blue hover:text-sky no-underline cursor-pointer"; + link.style.cssText = + "text-decoration: none; font-size: 0.75em; vertical-align: super;"; + + // Add smooth scroll behavior + link.onclick = (e) => { + e.preventDefault(); + const target = document.getElementById(refId); + if (target) { + target.scrollIntoView({ behavior: "smooth", block: "center" }); + // Highlight the reference briefly + target.style.backgroundColor = "rgba(137, 180, 250, 0.2)"; + setTimeout(() => { + target.style.backgroundColor = ""; + }, 2000); + } + }; + + sup.appendChild(link); + } + }); + + // Find and enhance the References section + const headings = contentRef.querySelectorAll("h2"); + let referencesSection: HTMLElement | null = null; + + headings.forEach((heading) => { + if (heading.textContent?.trim() === "References") { + referencesSection = heading; + } + }); + + if (referencesSection) { + // Style the References heading + referencesSection.className = "text-2xl font-bold mb-4 text-text"; + + // Find the parent container and add styling + const parentDiv = referencesSection.parentElement; + if (parentDiv) { + // Add top border and padding + parentDiv.style.cssText = + "border-top: 1px solid var(--surface2); margin-top: 4rem; padding-top: 2rem;"; + } + + // Find all paragraphs after the References heading that start with [n] + let currentElement = referencesSection.nextElementSibling; + + while (currentElement) { + if (currentElement.tagName === "P") { + const text = currentElement.textContent?.trim() || ""; + const match = text.match(/^\[(.+?)\]\s*/); + + if (match) { + const refNumber = match[1]; + const refId = `ref-${refNumber}`; + + // Set the ID for linking + currentElement.id = refId; + + // Add styling + currentElement.className = + "reference-item transition-colors duration-500 text-sm mb-3"; + currentElement.style.cssText = "scroll-margin-top: 100px;"; + + // Parse and style the content - get everything after [n] + let refText = text.substring(match[0].length); + + // Remove any existing "↑ Back" text (including various Unicode arrow variants) + refText = refText.replace(/[↑⬆️]\s*Back\s*$/i, "").trim(); + + // Create styled content + currentElement.innerHTML = ""; + + // Add bold reference number + const refNumSpan = document.createElement("span"); + refNumSpan.className = "text-blue font-semibold"; + refNumSpan.textContent = `[${refNumber}]`; + currentElement.appendChild(refNumSpan); + + // Add reference text + if (refText) { + const refTextSpan = document.createElement("span"); + refTextSpan.className = "ml-2"; + refTextSpan.textContent = refText; + currentElement.appendChild(refTextSpan); + } else { + const refTextSpan = document.createElement("span"); + refTextSpan.className = "ml-2 text-subtext0 italic"; + refTextSpan.textContent = "Add your reference text here"; + currentElement.appendChild(refTextSpan); + } + + // Add back button + const backLink = document.createElement("a"); + backLink.href = `#ref-${refNumber}-back`; + backLink.className = + "text-mauve hover:text-pink ml-2 text-xs cursor-pointer"; + backLink.textContent = "↑ Back"; + backLink.onclick = (e) => { + e.preventDefault(); + const target = document.getElementById(`ref-${refNumber}-back`); + if (target) { + target.scrollIntoView({ behavior: "smooth", block: "center" }); + // Highlight the reference link briefly + target.style.backgroundColor = "rgba(203, 166, 247, 0.2)"; + setTimeout(() => { + target.style.backgroundColor = ""; + }, 2000); + } + }; + currentElement.appendChild(backLink); + } + } + + // Check if we've reached another heading (end of references) + if ( + currentElement.tagName.match(/^H[1-6]$/) && + currentElement !== referencesSection + ) { + break; + } + + currentElement = currentElement.nextElementSibling; + } + } + }; + // Load highlight.js only when needed createEffect(() => { if (props.hasCodeBlock && !hljs()) { @@ -115,6 +269,22 @@ export default function PostBodyClient(props: PostBodyClientProps) { } }); + // Process references after content is mounted and when body changes + onMount(() => { + setTimeout(() => { + processReferences(); + }, 150); + }); + + createEffect(() => { + // Re-process when body changes + if (props.body && contentRef) { + setTimeout(() => { + processReferences(); + }, 150); + } + }); + return (
Hello! World

`, editorProps: { @@ -474,6 +480,8 @@ export default function TextEditor(props: TextEditorProps) { onUpdate: ({ editor }) => { untrack(() => { props.updateContent(editor.getHTML()); + // Auto-manage references section + setTimeout(() => updateReferencesSection(editor), 100); }); }, onSelectionUpdate: ({ editor }) => { @@ -505,13 +513,179 @@ export default function TextEditor(props: TextEditorProps) { (newContent) => { const instance = editor(); if (instance && newContent && instance.getHTML() !== newContent) { - instance.commands.setContent(newContent, false); // false = don't emit update event + instance.commands.setContent(newContent, { emitUpdate: false }); } }, { defer: true } ) ); + // Auto-manage references section + const updateReferencesSection = (editorInstance: any) => { + if (!editorInstance) return; + + const doc = editorInstance.state.doc; + const foundRefs = new Set(); + + // Scan document for superscript marks containing [n] patterns + doc.descendants((node: any) => { + if (node.isText && node.marks) { + const hasSuperscript = node.marks.some( + (mark: any) => mark.type.name === "superscript" + ); + if (hasSuperscript) { + const text = node.text || ""; + const match = text.match(/^\[(.+?)\]$/); + if (match) { + foundRefs.add(match[1]); + } + } + } + }); + + // If no references found, remove references section if it exists + if (foundRefs.size === 0) { + let hasReferencesSection = false; + let hrPos = -1; + let sectionStartPos = -1; + + doc.descendants((node: any, pos: number) => { + if (node.type.name === "heading" && node.textContent === "References") { + hasReferencesSection = true; + sectionStartPos = pos; + } + }); + + if (hasReferencesSection && sectionStartPos > 0) { + // Find the HR before References heading + doc.nodesBetween( + Math.max(0, sectionStartPos - 50), + sectionStartPos, + (node: any, pos: number) => { + if (node.type.name === "horizontalRule") { + hrPos = pos; + } + } + ); + + // Delete from HR to end of document + if (hrPos >= 0) { + const tr = editorInstance.state.tr; + tr.delete(hrPos, doc.content.size); + editorInstance.view.dispatch(tr); + } + } + return; + } + + // Convert Set to sorted array + const refNumbers = Array.from(foundRefs).sort((a, b) => { + const numA = parseInt(a); + const numB = parseInt(b); + if (!isNaN(numA) && !isNaN(numB)) { + return numA - numB; + } + return a.localeCompare(b); + }); + + // Check if References section already exists + let referencesHeadingPos = -1; + let existingRefs = new Set(); + + doc.descendants((node: any, pos: number) => { + if (node.type.name === "heading" && node.textContent === "References") { + referencesHeadingPos = pos; + } + // Check for existing reference list items + if (referencesHeadingPos >= 0 && node.type.name === "paragraph") { + const match = node.textContent.match(/^\[(.+?)\]/); + if (match) { + existingRefs.add(match[1]); + } + } + }); + + // If references section doesn't exist, create it + if (referencesHeadingPos === -1) { + const content: any[] = [ + { type: "horizontalRule" }, + { + type: "heading", + attrs: { level: 2 }, + content: [{ type: "text", text: "References" }] + } + ]; + + // Add each reference as a paragraph + refNumbers.forEach((refNum) => { + content.push({ + type: "paragraph", + content: [ + { + type: "text", + text: `[${refNum}] `, + marks: [{ type: "bold" }] + } as any, + { + type: "text", + text: "Add your reference text here" + } + ] + }); + }); + + // Insert at the end + const tr = editorInstance.state.tr; + tr.insert( + doc.content.size, + editorInstance.schema.nodeFromJSON({ type: "doc", content }).content + ); + editorInstance.view.dispatch(tr); + } else { + // Update existing references section - add missing refs + const newRefs = refNumbers.filter((ref) => !existingRefs.has(ref)); + + if (newRefs.length > 0) { + // Find position after References heading to insert new refs + let insertPos = referencesHeadingPos; + doc.nodesBetween( + referencesHeadingPos, + doc.content.size, + (node: any, pos: number) => { + if (pos > insertPos) { + insertPos = pos + node.nodeSize; + } + } + ); + + const content: any[] = []; + newRefs.forEach((refNum) => { + content.push({ + type: "paragraph", + content: [ + { + type: "text", + text: `[${refNum}] `, + marks: [{ type: "bold" }] + } as any, + { + type: "text", + text: "Add your reference text here" + } + ] + }); + }); + + const tr = editorInstance.state.tr; + content.forEach((item) => { + tr.insert(insertPos, editorInstance.schema.nodeFromJSON(item)); + insertPos += editorInstance.schema.nodeFromJSON(item).nodeSize; + }); + editorInstance.view.dispatch(tr); + } + } + }; + const setLink = () => { const instance = editor(); if (!instance) return; @@ -1057,6 +1231,34 @@ export default function TextEditor(props: TextEditorProps) { > Code + + + +