@peaske7/readit 0.2.0 → 0.3.0-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) hide show
  1. package/.claude/CLAUDE.md +118 -76
  2. package/.claude/commands/review.md +1 -1
  3. package/.claude/roadmap.md +32 -9
  4. package/.claude/user-stories.md +100 -15
  5. package/AGENTS.md +30 -26
  6. package/Makefile +32 -0
  7. package/README.md +90 -2
  8. package/biome.json +18 -8
  9. package/bun.lock +426 -568
  10. package/bunfig.toml +2 -0
  11. package/docs/perf-baseline.md +56 -1
  12. package/docs/superpowers/specs/2026-03-27-go-server-rewrite-design.md +284 -0
  13. package/e2e/comments.spec.ts +14 -58
  14. package/e2e/document-load.spec.ts +1 -23
  15. package/e2e/export.spec.ts +4 -4
  16. package/e2e/perf/add-comment.spec.ts +9 -11
  17. package/e2e/perf/fixtures/generate.ts +1 -5
  18. package/e2e/perf/screenshot-final.png +0 -0
  19. package/e2e/perf/utils/metrics.ts +73 -9
  20. package/e2e/persistence-file.spec.ts +41 -26
  21. package/e2e/utils/selection.ts +17 -73
  22. package/go/cmd/readit/main.go +416 -0
  23. package/go/go.mod +20 -0
  24. package/go/go.sum +41 -0
  25. package/go/internal/server/anchor.go +302 -0
  26. package/go/internal/server/anchor_test.go +111 -0
  27. package/go/internal/server/comments.go +390 -0
  28. package/go/internal/server/documents.go +113 -0
  29. package/go/internal/server/embed.go +17 -0
  30. package/go/internal/server/headings.go +33 -0
  31. package/go/internal/server/headings_test.go +75 -0
  32. package/go/internal/server/htmltext.go +123 -0
  33. package/go/internal/server/markdown.go +157 -0
  34. package/go/internal/server/markdown_bench_test.go +42 -0
  35. package/go/internal/server/markdown_test.go +79 -0
  36. package/go/internal/server/server.go +453 -0
  37. package/go/internal/server/server_bench_test.go +122 -0
  38. package/go/internal/server/settings.go +110 -0
  39. package/go/internal/server/sse.go +140 -0
  40. package/go/internal/server/storage.go +275 -0
  41. package/go/internal/server/storage_test.go +152 -0
  42. package/go/internal/server/template.go +66 -0
  43. package/go/internal/server/types.go +101 -0
  44. package/go/internal/server/watcher.go +74 -0
  45. package/index.html +4 -14
  46. package/nvim-readit/lua/readit/health.lua +64 -0
  47. package/nvim-readit/lua/readit/init.lua +463 -0
  48. package/nvim-readit/plugin/readit.lua +19 -0
  49. package/package.json +20 -28
  50. package/shell/_readit +158 -0
  51. package/shell/readit.zsh +87 -0
  52. package/src/App.svelte +890 -0
  53. package/src/cli.ts +183 -21
  54. package/src/components/ActionsMenu.svelte +95 -0
  55. package/src/components/CommentBadge.svelte +67 -0
  56. package/src/components/CommentErrorBanner.svelte +33 -0
  57. package/src/components/CommentInput.svelte +75 -0
  58. package/src/components/CommentListItem.svelte +95 -0
  59. package/src/components/CommentManager.svelte +129 -0
  60. package/src/components/CommentNav.svelte +109 -0
  61. package/src/components/DocumentViewer.svelte +233 -0
  62. package/src/components/FloatingComment.svelte +107 -0
  63. package/src/components/Header.svelte +76 -0
  64. package/src/components/InlineEditor.svelte +72 -0
  65. package/src/components/MarginNote.svelte +167 -0
  66. package/src/components/MarginNotesContainer.svelte +33 -0
  67. package/src/components/MermaidEnhancer.svelte +218 -0
  68. package/src/components/MermaidModal.svelte +67 -0
  69. package/src/components/RawModal.svelte +126 -0
  70. package/src/components/ReanchorConfirm.svelte +30 -0
  71. package/src/components/SettingsModal.svelte +220 -0
  72. package/src/components/ShortcutCapture.svelte +82 -0
  73. package/src/components/ShortcutList.svelte +145 -0
  74. package/src/components/TabBar.svelte +52 -0
  75. package/src/components/TableOfContents.svelte +125 -0
  76. package/src/components/ui/ActionLink.svelte +40 -0
  77. package/src/components/ui/{Button.tsx → Button.svelte} +19 -20
  78. package/src/components/ui/Dialog.svelte +97 -0
  79. package/src/components/ui/DropdownMenu.svelte +85 -0
  80. package/src/components/ui/DropdownMenuItem.svelte +38 -0
  81. package/src/components/ui/DropdownMenuSeparator.svelte +11 -0
  82. package/src/components/ui/{Text.tsx → Text.svelte} +18 -23
  83. package/src/env.d.ts +6 -0
  84. package/src/index.css +141 -166
  85. package/src/lib/__fixtures__/bench-data.ts +0 -13
  86. package/src/lib/anchor.bench.ts +1 -12
  87. package/src/lib/anchor.test.ts +0 -8
  88. package/src/lib/anchor.ts +0 -4
  89. package/src/lib/comment-storage.bench.ts +49 -0
  90. package/src/lib/comment-storage.test.ts +103 -33
  91. package/src/lib/comment-storage.ts +25 -18
  92. package/src/lib/export.bench.ts +21 -0
  93. package/src/lib/export.ts +0 -1
  94. package/src/lib/fetch-or-throw.test.ts +59 -0
  95. package/src/lib/fetch-or-throw.ts +12 -0
  96. package/src/{hooks/useHeadings.test.ts → lib/headings.test.ts} +10 -24
  97. package/src/{hooks/useHeadings.ts → lib/headings.ts} +11 -13
  98. package/src/lib/highlight/core.test.ts +0 -5
  99. package/src/lib/highlight/dom.ts +52 -216
  100. package/src/lib/highlight/highlight-registry.ts +221 -0
  101. package/src/lib/highlight/highlight.bench.ts +92 -0
  102. package/src/lib/highlight/highlighter.ts +112 -132
  103. package/src/lib/highlight/resolver.ts +5 -79
  104. package/src/lib/highlight/types.ts +0 -5
  105. package/src/lib/html-text.test.ts +162 -0
  106. package/src/lib/html-text.ts +161 -0
  107. package/src/lib/i18n/en.ts +34 -0
  108. package/src/lib/i18n/ja.ts +34 -0
  109. package/src/lib/i18n/types.ts +33 -0
  110. package/src/lib/key-lock.test.ts +104 -0
  111. package/src/lib/key-lock.ts +23 -0
  112. package/src/lib/margin-layout.bench.ts +61 -0
  113. package/src/lib/margin-layout.ts +0 -7
  114. package/src/lib/markdown-renderer.test.ts +154 -0
  115. package/src/lib/markdown-renderer.ts +178 -0
  116. package/src/lib/mermaid-config.ts +38 -0
  117. package/src/lib/mermaid-renderer.ts +162 -0
  118. package/src/lib/mermaid-worker.ts +60 -0
  119. package/src/lib/positions.ts +31 -24
  120. package/src/lib/shortcut-registry.ts +244 -0
  121. package/src/lib/utils.ts +0 -29
  122. package/src/main.ts +16 -0
  123. package/src/schema.ts +16 -5
  124. package/src/server.ts +355 -95
  125. package/src/stores/app.svelte.ts +231 -0
  126. package/src/stores/locale.svelte.ts +46 -0
  127. package/src/stores/settings.svelte.ts +90 -0
  128. package/src/stores/shortcuts.svelte.ts +104 -0
  129. package/src/stores/ui.svelte.ts +12 -0
  130. package/src/template.ts +104 -0
  131. package/src/test-setup.ts +47 -0
  132. package/svelte.config.js +5 -0
  133. package/tsconfig.json +2 -2
  134. package/vite.config.ts +23 -3
  135. package/vscode-readit/.mcp.json +7 -0
  136. package/vscode-readit/.vscodeignore +7 -0
  137. package/vscode-readit/bun.lock +78 -0
  138. package/vscode-readit/icon.svg +10 -0
  139. package/vscode-readit/package.json +110 -0
  140. package/vscode-readit/src/extension.ts +117 -0
  141. package/vscode-readit/src/server-manager.ts +272 -0
  142. package/vscode-readit/src/webview-provider.ts +204 -0
  143. package/vscode-readit/tsconfig.json +20 -0
  144. package/e2e/fixtures/sample.html +0 -13
  145. package/src/App.tsx +0 -368
  146. package/src/components/ActionsMenu.tsx +0 -91
  147. package/src/components/DocumentViewer/CodeBlock.tsx +0 -160
  148. package/src/components/DocumentViewer/DocumentViewer.tsx +0 -230
  149. package/src/components/DocumentViewer/MermaidDiagram.tsx +0 -136
  150. package/src/components/Header.tsx +0 -54
  151. package/src/components/InlineEditor.tsx +0 -74
  152. package/src/components/MarginNote.tsx +0 -185
  153. package/src/components/MarginNotes.tsx +0 -23
  154. package/src/components/RawModal.tsx +0 -144
  155. package/src/components/ReanchorConfirm.tsx +0 -36
  156. package/src/components/SettingsModal.tsx +0 -232
  157. package/src/components/TabBar.tsx +0 -60
  158. package/src/components/TableOfContents.tsx +0 -108
  159. package/src/components/comments/CommentBadge.tsx +0 -49
  160. package/src/components/comments/CommentInput.tsx +0 -86
  161. package/src/components/comments/CommentListItem.tsx +0 -90
  162. package/src/components/comments/CommentManager.tsx +0 -129
  163. package/src/components/comments/CommentNav.tsx +0 -109
  164. package/src/components/ui/ActionLink.tsx +0 -28
  165. package/src/components/ui/Dialog.tsx +0 -116
  166. package/src/components/ui/DropdownMenu.tsx +0 -158
  167. package/src/contexts/CommentContext.tsx +0 -198
  168. package/src/contexts/LocaleContext.tsx +0 -76
  169. package/src/contexts/PositionsContext.tsx +0 -16
  170. package/src/contexts/SettingsContext.tsx +0 -133
  171. package/src/hooks/useClickOutside.ts +0 -31
  172. package/src/hooks/useCommentNavigation.ts +0 -107
  173. package/src/hooks/useComments.ts +0 -311
  174. package/src/hooks/useDocument.ts +0 -157
  175. package/src/hooks/useScrollSpy.ts +0 -77
  176. package/src/hooks/useTextSelection.ts +0 -86
  177. package/src/lib/highlight/worker.ts +0 -45
  178. package/src/main.tsx +0 -13
  179. package/src/store.ts +0 -222
@@ -1,116 +0,0 @@
1
- import { X } from "lucide-react";
2
- import { useEffect, useRef } from "react";
3
- import { cn } from "../../lib/utils";
4
-
5
- interface DialogProps {
6
- open: boolean;
7
- onOpenChange: (open: boolean) => void;
8
- children: React.ReactNode;
9
- }
10
-
11
- function Dialog({ open, onOpenChange, children }: DialogProps) {
12
- const ref = useRef<HTMLDialogElement>(null);
13
-
14
- useEffect(() => {
15
- const dialog = ref.current;
16
- if (!dialog) return;
17
-
18
- if (open && !dialog.open) {
19
- dialog.showModal();
20
- } else if (!open && dialog.open) {
21
- dialog.close();
22
- }
23
- }, [open]);
24
-
25
- useEffect(() => {
26
- const dialog = ref.current;
27
- if (!dialog) return;
28
-
29
- const handleClose = () => onOpenChange(false);
30
- dialog.addEventListener("close", handleClose);
31
- return () => dialog.removeEventListener("close", handleClose);
32
- }, [onOpenChange]);
33
-
34
- const handleClick = (e: React.MouseEvent<HTMLDialogElement>) => {
35
- if (e.target === ref.current) onOpenChange(false);
36
- };
37
-
38
- return (
39
- <dialog
40
- ref={ref}
41
- onClick={handleClick}
42
- className="backdrop:bg-black/20 dark:backdrop:bg-black/40 backdrop:backdrop-blur-sm bg-transparent p-0 m-auto max-w-none"
43
- >
44
- {open ? children : null}
45
- </dialog>
46
- );
47
- }
48
-
49
- function DialogContent({
50
- className,
51
- children,
52
- onClose,
53
- }: {
54
- className?: string;
55
- children: React.ReactNode;
56
- onClose?: () => void;
57
- }) {
58
- return (
59
- <div
60
- className={cn(
61
- "w-full bg-white/95 dark:bg-zinc-900/95 backdrop-blur-sm shadow-lg border border-zinc-200/40 dark:border-zinc-700/40 rounded-xl flex flex-col",
62
- className,
63
- )}
64
- >
65
- {children}
66
- {onClose && (
67
- <button
68
- type="button"
69
- onClick={onClose}
70
- className="absolute top-3 right-3 size-7 inline-flex items-center justify-center rounded-lg text-zinc-600 dark:text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800"
71
- >
72
- <X className="w-4 h-4" />
73
- </button>
74
- )}
75
- </div>
76
- );
77
- }
78
-
79
- function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
80
- return (
81
- <div
82
- className={cn(
83
- "flex items-center justify-between pl-4 pr-12 py-3 border-b border-zinc-100 dark:border-zinc-800",
84
- className,
85
- )}
86
- {...props}
87
- />
88
- );
89
- }
90
-
91
- function DialogTitle({
92
- className,
93
- children,
94
- }: {
95
- className?: string;
96
- children: React.ReactNode;
97
- }) {
98
- return (
99
- <h2
100
- className={cn(
101
- "text-sm font-medium text-zinc-900 dark:text-zinc-100",
102
- className,
103
- )}
104
- >
105
- {children}
106
- </h2>
107
- );
108
- }
109
-
110
- function DialogBody({ className, ...props }: React.ComponentProps<"div">) {
111
- return (
112
- <div className={cn("flex-1 overflow-auto p-4", className)} {...props} />
113
- );
114
- }
115
-
116
- export { Dialog, DialogBody, DialogContent, DialogHeader, DialogTitle };
@@ -1,158 +0,0 @@
1
- import { createContext, use, useCallback, useRef, useState } from "react";
2
- import { useClickOutside } from "../../hooks/useClickOutside";
3
- import { cn } from "../../lib/utils";
4
-
5
- interface DropdownState {
6
- open: boolean;
7
- setOpen: (open: boolean) => void;
8
- }
9
-
10
- const DropdownContext = createContext<DropdownState>({
11
- open: false,
12
- setOpen: () => {},
13
- });
14
-
15
- function DropdownMenu({
16
- open: controlledOpen,
17
- onOpenChange,
18
- children,
19
- }: {
20
- open?: boolean;
21
- onOpenChange?: (open: boolean) => void;
22
- children: React.ReactNode;
23
- }) {
24
- const [internalOpen, setInternalOpen] = useState(false);
25
- const isControlled = controlledOpen !== undefined;
26
- const open = isControlled ? controlledOpen : internalOpen;
27
- const setOpen = useCallback(
28
- (v: boolean) => {
29
- if (!isControlled) setInternalOpen(v);
30
- onOpenChange?.(v);
31
- },
32
- [isControlled, onOpenChange],
33
- );
34
-
35
- const ref = useRef<HTMLDivElement>(null);
36
- useClickOutside(ref, () => setOpen(false), open);
37
-
38
- return (
39
- <DropdownContext value={{ open, setOpen }}>
40
- <div ref={ref} className="relative inline-block">
41
- {children}
42
- </div>
43
- </DropdownContext>
44
- );
45
- }
46
-
47
- function DropdownMenuTrigger({
48
- asChild,
49
- children,
50
- ...props
51
- }: {
52
- asChild?: boolean;
53
- children: React.ReactNode;
54
- } & React.ButtonHTMLAttributes<HTMLButtonElement>) {
55
- const { open, setOpen } = use(DropdownContext);
56
-
57
- if (
58
- asChild &&
59
- children &&
60
- typeof children === "object" &&
61
- "props" in children
62
- ) {
63
- const child = children as React.ReactElement<Record<string, unknown>>;
64
- return (
65
- <child.type
66
- {...child.props}
67
- onClick={(e: React.MouseEvent) => {
68
- setOpen(!open);
69
- if (typeof child.props.onClick === "function") child.props.onClick(e);
70
- }}
71
- />
72
- );
73
- }
74
-
75
- return (
76
- <button type="button" onClick={() => setOpen(!open)} {...props}>
77
- {children}
78
- </button>
79
- );
80
- }
81
-
82
- function DropdownMenuContent({
83
- className,
84
- align = "start",
85
- children,
86
- }: {
87
- className?: string;
88
- align?: "start" | "end";
89
- children: React.ReactNode;
90
- }) {
91
- const { open } = use(DropdownContext);
92
- if (!open) return null;
93
-
94
- return (
95
- <div
96
- className={cn(
97
- "absolute top-full mt-1 z-50 min-w-[8rem] overflow-hidden rounded-xl py-1",
98
- "bg-white/95 dark:bg-zinc-900/95 backdrop-blur-sm shadow-lg border border-zinc-200/40 dark:border-zinc-700/40",
99
- align === "end" ? "right-0" : "left-0",
100
- className,
101
- )}
102
- >
103
- {children}
104
- </div>
105
- );
106
- }
107
-
108
- function DropdownMenuItem({
109
- className,
110
- variant = "default",
111
- onSelect,
112
- children,
113
- ...props
114
- }: {
115
- className?: string;
116
- variant?: "default" | "destructive";
117
- onSelect?: () => void;
118
- children: React.ReactNode;
119
- title?: string;
120
- }) {
121
- const { setOpen } = use(DropdownContext);
122
-
123
- return (
124
- <button
125
- type="button"
126
- className={cn(
127
- "w-full px-3 py-1.5 text-left text-sm outline-none select-none transition-colors duration-150 flex items-center gap-2 cursor-default",
128
- variant === "default" &&
129
- "text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800 hover:text-zinc-900 dark:hover:text-zinc-100",
130
- variant === "destructive" &&
131
- "text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950 hover:text-red-700 dark:hover:text-red-300",
132
- "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
133
- className,
134
- )}
135
- onClick={() => {
136
- onSelect?.();
137
- setOpen(false);
138
- }}
139
- {...props}
140
- >
141
- {children}
142
- </button>
143
- );
144
- }
145
-
146
- function DropdownMenuSeparator({ className }: { className?: string }) {
147
- return (
148
- <div className={cn("my-1 h-px bg-zinc-100 dark:bg-zinc-800", className)} />
149
- );
150
- }
151
-
152
- export {
153
- DropdownMenu,
154
- DropdownMenuContent,
155
- DropdownMenuItem,
156
- DropdownMenuSeparator,
157
- DropdownMenuTrigger,
158
- };
@@ -1,198 +0,0 @@
1
- import {
2
- createContext,
3
- type ReactNode,
4
- use,
5
- useCallback,
6
- useEffect,
7
- useMemo,
8
- } from "react";
9
- import { toast } from "sonner";
10
- import { useCommentNavigation } from "../hooks/useCommentNavigation";
11
- import { useComments } from "../hooks/useComments";
12
- import { formatComment } from "../lib/export";
13
- import { truncate } from "../lib/utils";
14
- import type { Comment } from "../schema";
15
- import { appStore, useAppStore } from "../store";
16
- import { useLocale } from "./LocaleContext";
17
-
18
- // Stable callbacks — never causes re-renders
19
- interface CommentActionsValue {
20
- addComment: (
21
- selectedText: string,
22
- comment: string,
23
- startOffset: number,
24
- endOffset: number,
25
- ) => void;
26
- editComment: (id: string, newText: string) => void;
27
- deleteComment: (id: string) => void;
28
- deleteAll: () => void;
29
- reanchorComment: (
30
- id: string,
31
- selectedText: string,
32
- startOffset: number,
33
- endOffset: number,
34
- ) => void;
35
- setHoveredCommentId: (id: string | undefined) => void;
36
- navigateToComment: (commentId: string) => void;
37
- navigatePrevious: () => void;
38
- navigateNext: () => void;
39
- startReanchor: (commentId: string) => void;
40
- cancelReanchor: () => void;
41
- copyComment: (comment: Comment) => void;
42
- scrollToHighlight: (commentId: string) => void;
43
- }
44
-
45
- const CommentActionsContext = createContext<CommentActionsValue | null>(null);
46
-
47
- export function useCommentActions(): CommentActionsValue {
48
- const value = use(CommentActionsContext);
49
- if (!value) {
50
- throw new Error("useCommentActions must be used within a CommentProvider");
51
- }
52
- return value;
53
- }
54
-
55
- // Volatile — re-renders consumers on change
56
- interface CommentDataValue {
57
- comments: Comment[];
58
- commentCount: number;
59
- sortedComments: Comment[];
60
- currentIndex: number;
61
- reanchorTarget: { commentId: string } | null;
62
- }
63
-
64
- const CommentDataContext = createContext<CommentDataValue | null>(null);
65
-
66
- export function useCommentData(): CommentDataValue {
67
- const value = use(CommentDataContext);
68
- if (!value) {
69
- throw new Error("useCommentData must be used within a CommentProvider");
70
- }
71
- return value;
72
- }
73
-
74
- export type CommentContextValue = CommentActionsValue & CommentDataValue;
75
-
76
- export function useCommentContext(): CommentContextValue {
77
- return { ...useCommentActions(), ...useCommentData() };
78
- }
79
-
80
- export const CommentContext = CommentDataContext;
81
-
82
- interface CommentProviderProps {
83
- filePath: string;
84
- clean: boolean;
85
- children: ReactNode;
86
- }
87
-
88
- export function CommentProvider({
89
- filePath,
90
- clean,
91
- children,
92
- }: CommentProviderProps) {
93
- const {
94
- comments,
95
- error: commentsError,
96
- addComment,
97
- deleteComment,
98
- deleteAll,
99
- editComment,
100
- reanchorComment,
101
- } = useComments(filePath, { clean });
102
-
103
- const sortedComments = useAppStore(
104
- (s) => s.documents.get(filePath)?.sortedComments ?? [],
105
- );
106
-
107
- const {
108
- currentIndex,
109
- setHoveredCommentId,
110
- navigateToComment,
111
- navigatePrevious,
112
- navigateNext,
113
- } = useCommentNavigation(sortedComments);
114
-
115
- const reanchorTarget = useAppStore(
116
- (s) => s.getActiveDocumentState()?.reanchorTarget ?? null,
117
- );
118
- const startReanchor = useCallback((commentId: string) => {
119
- appStore.getState().setReanchorTarget({ commentId });
120
- }, []);
121
- const cancelReanchor = useCallback(() => {
122
- appStore.getState().setReanchorTarget(null);
123
- }, []);
124
- const { t } = useLocale();
125
-
126
- useEffect(() => {
127
- if (commentsError) {
128
- toast.error(commentsError);
129
- }
130
- }, [commentsError]);
131
-
132
- const copyComment = useCallback(
133
- (comment: Comment) => {
134
- navigator.clipboard.writeText(formatComment(comment));
135
- toast.success(t("toast.copied", { text: truncate(comment.comment) }));
136
- },
137
- [t],
138
- );
139
-
140
- const scrollToHighlight = useCallback((commentId: string) => {
141
- const mark = window.document.querySelector(
142
- `mark[data-comment-id="${commentId}"]`,
143
- );
144
- if (mark) {
145
- mark.scrollIntoView({ behavior: "smooth", block: "center" });
146
- }
147
- }, []);
148
-
149
- const actions = useMemo<CommentActionsValue>(
150
- () => ({
151
- addComment,
152
- editComment,
153
- deleteComment,
154
- deleteAll,
155
- reanchorComment,
156
- setHoveredCommentId,
157
- navigateToComment,
158
- navigatePrevious,
159
- navigateNext,
160
- startReanchor,
161
- cancelReanchor,
162
- copyComment,
163
- scrollToHighlight,
164
- }),
165
- [
166
- addComment,
167
- editComment,
168
- deleteComment,
169
- deleteAll,
170
- reanchorComment,
171
- setHoveredCommentId,
172
- navigateToComment,
173
- navigatePrevious,
174
- navigateNext,
175
- startReanchor,
176
- cancelReanchor,
177
- copyComment,
178
- scrollToHighlight,
179
- ],
180
- );
181
-
182
- const data = useMemo<CommentDataValue>(
183
- () => ({
184
- comments,
185
- commentCount: comments.length,
186
- sortedComments,
187
- currentIndex,
188
- reanchorTarget,
189
- }),
190
- [comments, sortedComments, currentIndex, reanchorTarget],
191
- );
192
-
193
- return (
194
- <CommentActionsContext value={actions}>
195
- <CommentDataContext value={data}>{children}</CommentDataContext>
196
- </CommentActionsContext>
197
- );
198
- }
@@ -1,76 +0,0 @@
1
- import {
2
- createContext,
3
- type ReactNode,
4
- use,
5
- useCallback,
6
- useMemo,
7
- useState,
8
- } from "react";
9
- import {
10
- createT,
11
- type Locale,
12
- Locales,
13
- type TranslationKey,
14
- } from "../lib/i18n";
15
-
16
- const STORAGE_KEY = "readit:locale";
17
-
18
- function detectLocale(): Locale {
19
- const browserLang = navigator.language.slice(0, 2).toLowerCase();
20
- if (browserLang === "ja") return Locales.JA;
21
- return Locales.EN;
22
- }
23
-
24
- function getStoredLocale(): Locale {
25
- try {
26
- const stored = localStorage.getItem(STORAGE_KEY);
27
- if (stored === Locales.JA || stored === Locales.EN) {
28
- return stored;
29
- }
30
- } catch {
31
- // localStorage may be unavailable
32
- }
33
- return detectLocale();
34
- }
35
-
36
- interface LocaleContextValue {
37
- locale: Locale;
38
- setLocale: (locale: Locale) => void;
39
- t: (key: TranslationKey, params?: Record<string, string | number>) => string;
40
- }
41
-
42
- const LocaleContext = createContext<LocaleContextValue | null>(null);
43
-
44
- export function useLocale(): LocaleContextValue {
45
- const value = use(LocaleContext);
46
- if (!value) {
47
- throw new Error("useLocale must be used within a LocaleProvider");
48
- }
49
- return value;
50
- }
51
-
52
- interface LocaleProviderProps {
53
- children: ReactNode;
54
- }
55
-
56
- export function LocaleProvider({ children }: LocaleProviderProps) {
57
- const [locale, setLocaleState] = useState<Locale>(getStoredLocale);
58
-
59
- const setLocale = useCallback((newLocale: Locale) => {
60
- setLocaleState(newLocale);
61
- try {
62
- localStorage.setItem(STORAGE_KEY, newLocale);
63
- } catch {
64
- // localStorage may be unavailable
65
- }
66
- }, []);
67
-
68
- const t = useMemo(() => createT(locale), [locale]);
69
-
70
- const value = useMemo<LocaleContextValue>(
71
- () => ({ locale, setLocale, t }),
72
- [locale, setLocale, t],
73
- );
74
-
75
- return <LocaleContext value={value}>{children}</LocaleContext>;
76
- }
@@ -1,16 +0,0 @@
1
- import { createContext, type ReactNode, use, useRef } from "react";
2
- import { Positions } from "../lib/positions";
3
-
4
- const Ctx = createContext<Positions | null>(null);
5
-
6
- export function usePositions(): Positions {
7
- const value = use(Ctx);
8
- if (!value) throw new Error("usePositions requires PositionsProvider");
9
- return value;
10
- }
11
-
12
- export function PositionsProvider({ children }: { children: ReactNode }) {
13
- const ref = useRef<Positions | null>(null);
14
- if (!ref.current) ref.current = new Positions();
15
- return <Ctx value={ref.current}>{children}</Ctx>;
16
- }
@@ -1,133 +0,0 @@
1
- import {
2
- createContext,
3
- type ReactNode,
4
- use,
5
- useCallback,
6
- useEffect,
7
- useMemo,
8
- useState,
9
- } from "react";
10
- import { toast } from "sonner";
11
- import {
12
- FontFamilies,
13
- type FontFamily,
14
- type ThemeMode,
15
- ThemeModes,
16
- } from "../schema";
17
-
18
- const THEME_STORAGE_KEY = "readit:theme";
19
- const DARK_MQ = "(prefers-color-scheme: dark)";
20
-
21
- function getStoredTheme(): ThemeMode {
22
- try {
23
- const stored = localStorage.getItem(THEME_STORAGE_KEY);
24
- if (
25
- stored === ThemeModes.LIGHT ||
26
- stored === ThemeModes.DARK ||
27
- stored === ThemeModes.SYSTEM
28
- ) {
29
- return stored;
30
- }
31
- } catch {
32
- // localStorage may be unavailable
33
- }
34
- return ThemeModes.SYSTEM;
35
- }
36
-
37
- function applyTheme(mode: ThemeMode): void {
38
- const isDark =
39
- mode === ThemeModes.DARK ||
40
- (mode === ThemeModes.SYSTEM && window.matchMedia(DARK_MQ).matches);
41
-
42
- document.documentElement.classList.toggle("dark", isDark);
43
- }
44
-
45
- interface SettingsContextValue {
46
- fontFamily: FontFamily;
47
- setFontFamily: (font: FontFamily) => Promise<void>;
48
- themeMode: ThemeMode;
49
- setThemeMode: (mode: ThemeMode) => void;
50
- }
51
-
52
- export const SettingsContext = createContext<SettingsContextValue | null>(null);
53
-
54
- export function useSettings(): SettingsContextValue {
55
- const value = use(SettingsContext);
56
- if (!value) {
57
- throw new Error("useSettings must be used within a SettingsProvider");
58
- }
59
- return value;
60
- }
61
-
62
- export function SettingsProvider({ children }: { children: ReactNode }) {
63
- const [fontFamily, setFontFamilyState] = useState<FontFamily>(
64
- FontFamilies.SERIF,
65
- );
66
-
67
- useEffect(() => {
68
- const fetchSettings = async () => {
69
- try {
70
- const response = await fetch("/api/settings");
71
- if (response.ok) {
72
- const settings = await response.json();
73
- setFontFamilyState(settings.fontFamily || FontFamilies.SERIF);
74
- }
75
- } catch (err) {
76
- console.error("Failed to fetch settings:", err);
77
- }
78
- };
79
-
80
- fetchSettings();
81
- }, []);
82
-
83
- const setFontFamily = useCallback(async (font: FontFamily) => {
84
- setFontFamilyState(font);
85
-
86
- try {
87
- const response = await fetch("/api/settings", {
88
- method: "PUT",
89
- headers: { "Content-Type": "application/json" },
90
- body: JSON.stringify({ fontFamily: font }),
91
- });
92
-
93
- if (!response.ok) {
94
- throw new Error("Failed to save settings");
95
- }
96
- } catch (err) {
97
- console.error("Failed to save font preference:", err);
98
- toast.error("Failed to save font preference");
99
- }
100
- }, []);
101
-
102
- const [themeMode, setThemeModeState] = useState<ThemeMode>(getStoredTheme);
103
-
104
- useEffect(() => {
105
- applyTheme(themeMode);
106
- }, [themeMode]);
107
-
108
- useEffect(() => {
109
- if (themeMode !== ThemeModes.SYSTEM) return;
110
-
111
- const mq = window.matchMedia(DARK_MQ);
112
- const handler = () => applyTheme(ThemeModes.SYSTEM);
113
-
114
- mq.addEventListener("change", handler);
115
- return () => mq.removeEventListener("change", handler);
116
- }, [themeMode]);
117
-
118
- const setThemeMode = useCallback((mode: ThemeMode) => {
119
- setThemeModeState(mode);
120
- try {
121
- localStorage.setItem(THEME_STORAGE_KEY, mode);
122
- } catch {
123
- // localStorage may be unavailable
124
- }
125
- }, []);
126
-
127
- const value = useMemo<SettingsContextValue>(
128
- () => ({ fontFamily, setFontFamily, themeMode, setThemeMode }),
129
- [fontFamily, setFontFamily, themeMode, setThemeMode],
130
- );
131
-
132
- return <SettingsContext value={value}>{children}</SettingsContext>;
133
- }