@peaske7/readit 0.2.0 → 0.2.1

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 (173) 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 +118 -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 +881 -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 +218 -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/RawModal.svelte +126 -0
  68. package/src/components/ReanchorConfirm.svelte +30 -0
  69. package/src/components/SettingsModal.svelte +220 -0
  70. package/src/components/ShortcutCapture.svelte +82 -0
  71. package/src/components/ShortcutList.svelte +145 -0
  72. package/src/components/TabBar.svelte +52 -0
  73. package/src/components/TableOfContents.svelte +125 -0
  74. package/src/components/ui/ActionLink.svelte +40 -0
  75. package/src/components/ui/{Button.tsx → Button.svelte} +19 -20
  76. package/src/components/ui/Dialog.svelte +97 -0
  77. package/src/components/ui/DropdownMenu.svelte +85 -0
  78. package/src/components/ui/DropdownMenuItem.svelte +38 -0
  79. package/src/components/ui/DropdownMenuSeparator.svelte +11 -0
  80. package/src/components/ui/{Text.tsx → Text.svelte} +18 -23
  81. package/src/env.d.ts +6 -0
  82. package/src/index.css +36 -166
  83. package/src/lib/__fixtures__/bench-data.ts +0 -13
  84. package/src/lib/anchor.bench.ts +1 -12
  85. package/src/lib/anchor.test.ts +0 -8
  86. package/src/lib/anchor.ts +0 -4
  87. package/src/lib/comment-storage.bench.ts +49 -0
  88. package/src/lib/comment-storage.test.ts +41 -33
  89. package/src/lib/comment-storage.ts +21 -18
  90. package/src/lib/export.bench.ts +21 -0
  91. package/src/lib/export.ts +0 -1
  92. package/src/{hooks/useHeadings.test.ts → lib/headings.test.ts} +10 -24
  93. package/src/{hooks/useHeadings.ts → lib/headings.ts} +11 -13
  94. package/src/lib/highlight/core.test.ts +0 -5
  95. package/src/lib/highlight/dom.ts +52 -216
  96. package/src/lib/highlight/highlight-registry.ts +221 -0
  97. package/src/lib/highlight/highlight.bench.ts +92 -0
  98. package/src/lib/highlight/highlighter.ts +112 -132
  99. package/src/lib/highlight/resolver.ts +5 -79
  100. package/src/lib/highlight/types.ts +0 -5
  101. package/src/lib/html-text.test.ts +162 -0
  102. package/src/lib/html-text.ts +161 -0
  103. package/src/lib/i18n/en.ts +26 -0
  104. package/src/lib/i18n/ja.ts +26 -0
  105. package/src/lib/i18n/types.ts +25 -0
  106. package/src/lib/margin-layout.bench.ts +61 -0
  107. package/src/lib/margin-layout.ts +0 -7
  108. package/src/lib/markdown-renderer.test.ts +154 -0
  109. package/src/lib/markdown-renderer.ts +177 -0
  110. package/src/lib/mermaid-config.ts +38 -0
  111. package/src/lib/mermaid-renderer.ts +162 -0
  112. package/src/lib/mermaid-worker.ts +60 -0
  113. package/src/lib/positions.ts +31 -24
  114. package/src/lib/shortcut-registry.ts +244 -0
  115. package/src/lib/utils.ts +0 -29
  116. package/src/main.ts +16 -0
  117. package/src/schema.ts +16 -5
  118. package/src/server.ts +355 -91
  119. package/src/stores/app.svelte.ts +231 -0
  120. package/src/stores/locale.svelte.ts +46 -0
  121. package/src/stores/settings.svelte.ts +90 -0
  122. package/src/stores/shortcuts.svelte.ts +104 -0
  123. package/src/stores/ui.svelte.ts +12 -0
  124. package/src/template.ts +104 -0
  125. package/src/test-setup.ts +47 -0
  126. package/svelte.config.js +5 -0
  127. package/tsconfig.json +2 -2
  128. package/vite.config.ts +23 -3
  129. package/vscode-readit/.mcp.json +7 -0
  130. package/vscode-readit/.vscodeignore +7 -0
  131. package/vscode-readit/bun.lock +78 -0
  132. package/vscode-readit/icon.svg +10 -0
  133. package/vscode-readit/package.json +110 -0
  134. package/vscode-readit/src/extension.ts +117 -0
  135. package/vscode-readit/src/server-manager.ts +272 -0
  136. package/vscode-readit/src/webview-provider.ts +204 -0
  137. package/vscode-readit/tsconfig.json +20 -0
  138. package/e2e/fixtures/sample.html +0 -13
  139. package/src/App.tsx +0 -368
  140. package/src/components/ActionsMenu.tsx +0 -91
  141. package/src/components/DocumentViewer/CodeBlock.tsx +0 -160
  142. package/src/components/DocumentViewer/DocumentViewer.tsx +0 -230
  143. package/src/components/DocumentViewer/MermaidDiagram.tsx +0 -136
  144. package/src/components/Header.tsx +0 -54
  145. package/src/components/InlineEditor.tsx +0 -74
  146. package/src/components/MarginNote.tsx +0 -185
  147. package/src/components/MarginNotes.tsx +0 -23
  148. package/src/components/RawModal.tsx +0 -144
  149. package/src/components/ReanchorConfirm.tsx +0 -36
  150. package/src/components/SettingsModal.tsx +0 -232
  151. package/src/components/TabBar.tsx +0 -60
  152. package/src/components/TableOfContents.tsx +0 -108
  153. package/src/components/comments/CommentBadge.tsx +0 -49
  154. package/src/components/comments/CommentInput.tsx +0 -86
  155. package/src/components/comments/CommentListItem.tsx +0 -90
  156. package/src/components/comments/CommentManager.tsx +0 -129
  157. package/src/components/comments/CommentNav.tsx +0 -109
  158. package/src/components/ui/ActionLink.tsx +0 -28
  159. package/src/components/ui/Dialog.tsx +0 -116
  160. package/src/components/ui/DropdownMenu.tsx +0 -158
  161. package/src/contexts/CommentContext.tsx +0 -198
  162. package/src/contexts/LocaleContext.tsx +0 -76
  163. package/src/contexts/PositionsContext.tsx +0 -16
  164. package/src/contexts/SettingsContext.tsx +0 -133
  165. package/src/hooks/useClickOutside.ts +0 -31
  166. package/src/hooks/useCommentNavigation.ts +0 -107
  167. package/src/hooks/useComments.ts +0 -311
  168. package/src/hooks/useDocument.ts +0 -157
  169. package/src/hooks/useScrollSpy.ts +0 -77
  170. package/src/hooks/useTextSelection.ts +0 -86
  171. package/src/lib/highlight/worker.ts +0 -45
  172. package/src/main.tsx +0 -13
  173. package/src/store.ts +0 -222
@@ -1,185 +0,0 @@
1
- import { memo, useCallback, useState } from "react";
2
- import { useCommentActions } from "../contexts/CommentContext";
3
- import { useLocale } from "../contexts/LocaleContext";
4
- import { usePositions } from "../contexts/PositionsContext";
5
- import { useSettings } from "../contexts/SettingsContext";
6
- import { cn } from "../lib/utils";
7
- import { type Comment, FontFamilies } from "../schema";
8
- import { useUI } from "../store";
9
- import { InlineEditor } from "./InlineEditor";
10
- import { ActionLink } from "./ui/ActionLink";
11
-
12
- interface MarginNoteProps {
13
- comment: Comment;
14
- commentIndex?: number;
15
- }
16
-
17
- function selectedTextClass(hovered: boolean) {
18
- return cn(
19
- "text-sm italic mb-1 line-clamp-1 flex items-center gap-1 transition-colors duration-150",
20
- hovered
21
- ? "text-zinc-600 dark:text-zinc-400"
22
- : "text-zinc-400 dark:text-zinc-500",
23
- );
24
- }
25
-
26
- function commentTextClass(hovered: boolean) {
27
- return cn(
28
- "text-sm whitespace-pre-wrap transition-colors duration-150",
29
- hovered
30
- ? "text-zinc-800 dark:text-zinc-200"
31
- : "text-zinc-500 dark:text-zinc-400",
32
- );
33
- }
34
-
35
- function badgeClass(hovered: boolean) {
36
- return cn(
37
- "absolute -left-4 top-2 text-xs tabular-nums transition-colors duration-150",
38
- hovered
39
- ? "text-zinc-600 dark:text-zinc-400"
40
- : "text-zinc-400 dark:text-zinc-500",
41
- );
42
- }
43
-
44
- export const MarginNote = memo(function MarginNote({
45
- comment,
46
- commentIndex = 0,
47
- }: MarginNoteProps) {
48
- const { fontFamily } = useSettings();
49
- const { t } = useLocale();
50
- const {
51
- editComment,
52
- deleteComment,
53
- copyComment,
54
- setHoveredCommentId,
55
- scrollToHighlight,
56
- } = useCommentActions();
57
-
58
- const pos = usePositions();
59
- const refCallback = useCallback(
60
- (el: HTMLElement | null) => {
61
- if (el) pos.register(comment.id, el);
62
- else pos.unregister(comment.id);
63
- },
64
- [pos, comment.id],
65
- );
66
-
67
- const isHovered = useUI((s) => s.hoveredCommentId === comment.id);
68
- const fontClass =
69
- fontFamily === FontFamilies.SANS_SERIF ? "font-sans" : "font-serif";
70
- const [isEditing, setIsEditing] = useState(false);
71
-
72
- const hasNote = comment.comment.trim().length > 0;
73
-
74
- const handleCopy = () => copyComment(comment);
75
-
76
- const createdAtFormatted = new Date(comment.createdAt).toLocaleString();
77
-
78
- // Highlight-only (no note): minimal em-dash marker
79
- if (!hasNote && !isEditing) {
80
- return (
81
- <article
82
- ref={refCallback}
83
- className="absolute left-0 right-0 group"
84
- style={{
85
- visibility: "hidden",
86
- contentVisibility: "auto",
87
- containIntrinsicSize: "auto 80px",
88
- }}
89
- title={`Added: ${createdAtFormatted}`}
90
- data-comment-id={comment.id}
91
- onMouseEnter={() => setHoveredCommentId(comment.id)}
92
- onMouseLeave={() => setHoveredCommentId(undefined)}
93
- >
94
- <span className={badgeClass(isHovered)}>—</span>
95
-
96
- <div className="pt-2 pb-2 pl-3">
97
- <div
98
- className={cn(
99
- "flex items-center text-xs text-zinc-400 opacity-0 group-hover:opacity-100 transition-opacity",
100
- "gap-1.5 duration-150",
101
- isHovered && "opacity-100",
102
- )}
103
- >
104
- <ActionLink onClick={() => setIsEditing(true)}>
105
- {t("marginNote.addNote")}
106
- </ActionLink>
107
- <span aria-hidden="true">·</span>
108
- <ActionLink
109
- variant="destructive"
110
- onClick={() => deleteComment(comment.id)}
111
- >
112
- {t("marginNote.delete")}
113
- </ActionLink>
114
- </div>
115
- </div>
116
- </article>
117
- );
118
- }
119
-
120
- return (
121
- <article
122
- ref={refCallback}
123
- className="absolute left-0 right-0 group"
124
- style={{ visibility: "hidden" }}
125
- title={`Added: ${createdAtFormatted}`}
126
- data-comment-id={comment.id}
127
- onMouseEnter={() => setHoveredCommentId(comment.id)}
128
- onMouseLeave={() => setHoveredCommentId(undefined)}
129
- >
130
- <span className={badgeClass(isHovered)}>{commentIndex + 1}</span>
131
-
132
- <div
133
- className={cn(
134
- "relative border-t border-zinc-100 dark:border-zinc-800 pt-3 pb-2 pl-3 transition-colors duration-150",
135
- comment.anchorConfidence === "unresolved" && "opacity-60",
136
- )}
137
- >
138
- {!isEditing && (
139
- <div className={cn(fontClass, selectedTextClass(isHovered))}>
140
- <button
141
- type="button"
142
- onClick={() => scrollToHighlight(comment.id)}
143
- className="cursor-pointer hover:underline text-left"
144
- >
145
- "{comment.selectedText}"
146
- </button>
147
- </div>
148
- )}
149
-
150
- {isEditing ? (
151
- <InlineEditor
152
- initialText={comment.comment}
153
- onSave={(text) => {
154
- editComment(comment.id, text);
155
- setIsEditing(false);
156
- }}
157
- onCancel={() => setIsEditing(false)}
158
- />
159
- ) : (
160
- <>
161
- <p className={cn(fontClass, commentTextClass(isHovered))}>
162
- {comment.comment}
163
- </p>
164
- <div className="flex items-center text-xs text-zinc-400 opacity-0 group-hover:opacity-100 transition-opacity gap-1.5 mt-2">
165
- <ActionLink onClick={() => setIsEditing(true)}>
166
- {t("marginNote.edit")}
167
- </ActionLink>
168
- <span aria-hidden="true">·</span>
169
- <ActionLink
170
- variant="destructive"
171
- onClick={() => deleteComment(comment.id)}
172
- >
173
- {t("marginNote.delete")}
174
- </ActionLink>
175
- <span aria-hidden="true">·</span>
176
- <ActionLink onClick={handleCopy}>
177
- {t("marginNote.copy")}
178
- </ActionLink>
179
- </div>
180
- </>
181
- )}
182
- </div>
183
- </article>
184
- );
185
- });
@@ -1,23 +0,0 @@
1
- import { memo } from "react";
2
- import type { Comment } from "../schema";
3
- import { MarginNote } from "./MarginNote";
4
-
5
- interface MarginNotesProps {
6
- sortedComments: Comment[];
7
- }
8
-
9
- export const MarginNotes = memo(function MarginNotes({
10
- sortedComments,
11
- }: MarginNotesProps) {
12
- if (sortedComments.length === 0) {
13
- return null;
14
- }
15
-
16
- return (
17
- <div className="relative w-64">
18
- {sortedComments.map((comment, index) => (
19
- <MarginNote key={comment.id} comment={comment} commentIndex={index} />
20
- ))}
21
- </div>
22
- );
23
- });
@@ -1,144 +0,0 @@
1
- import { Copy } from "lucide-react";
2
- import { useCallback, useEffect, useState } from "react";
3
- import { toast } from "sonner";
4
- import { useLocale } from "../contexts/LocaleContext";
5
- import { useAppStore } from "../store";
6
- import { Button } from "./ui/Button";
7
- import {
8
- Dialog,
9
- DialogBody,
10
- DialogContent,
11
- DialogHeader,
12
- DialogTitle,
13
- } from "./ui/Dialog";
14
- import { Text } from "./ui/Text";
15
-
16
- interface RawModalProps {
17
- isOpen: boolean;
18
- onClose: () => void;
19
- }
20
-
21
- type ModalState =
22
- | { status: "idle" }
23
- | { status: "loading" }
24
- | { status: "error"; error: string }
25
- | { status: "empty"; path: string }
26
- | { status: "success"; content: string; path: string };
27
-
28
- export function RawModal({ isOpen, onClose }: RawModalProps) {
29
- const { t } = useLocale();
30
- const [state, setState] = useState<ModalState>({ status: "idle" });
31
- const activeDocumentPath = useAppStore((s) => s.activeDocumentPath);
32
-
33
- // Fetch raw comments when modal opens
34
- useEffect(() => {
35
- if (!isOpen) {
36
- setState({ status: "idle" });
37
- return;
38
- }
39
-
40
- setState({ status: "loading" });
41
-
42
- const fetchRawComments = async () => {
43
- try {
44
- const query = activeDocumentPath
45
- ? `?path=${encodeURIComponent(activeDocumentPath)}`
46
- : "";
47
- const response = await fetch(`/api/comments/raw${query}`);
48
- if (!response.ok) {
49
- throw new Error("Failed to fetch raw comments");
50
- }
51
- const result = await response.json();
52
- if (result.content === null) {
53
- setState({ status: "empty", path: result.path });
54
- } else {
55
- setState({
56
- status: "success",
57
- content: result.content,
58
- path: result.path,
59
- });
60
- }
61
- } catch (err) {
62
- setState({
63
- status: "error",
64
- error: err instanceof Error ? err.message : "Unknown error",
65
- });
66
- }
67
- };
68
-
69
- fetchRawComments();
70
- }, [isOpen, activeDocumentPath]);
71
-
72
- const handleCopy = useCallback(async () => {
73
- if (state.status !== "success") return;
74
-
75
- try {
76
- await navigator.clipboard.writeText(state.content);
77
- toast.success(t("rawModal.copiedToClipboard"));
78
- } catch {
79
- toast.error(t("rawModal.failedToCopy"));
80
- }
81
- }, [state, t]);
82
-
83
- return (
84
- <Dialog
85
- open={isOpen}
86
- onOpenChange={(open) => {
87
- if (!open) onClose();
88
- }}
89
- >
90
- <DialogContent className="max-w-2xl max-h-[80vh]" onClose={onClose}>
91
- <DialogHeader>
92
- <DialogTitle>{t("rawModal.title")}</DialogTitle>
93
- {state.status === "success" && (
94
- <Button
95
- variant="ghost"
96
- size="icon"
97
- className="size-7"
98
- onClick={handleCopy}
99
- title={t("rawModal.copyTitle")}
100
- >
101
- <Copy className="w-4 h-4" />
102
- </Button>
103
- )}
104
- </DialogHeader>
105
-
106
- {(state.status === "success" || state.status === "empty") && (
107
- <div className="px-4 py-2 border-b border-zinc-50 dark:border-zinc-800 text-xs text-zinc-400 dark:text-zinc-500 font-mono truncate">
108
- {state.path}
109
- </div>
110
- )}
111
-
112
- <DialogBody>
113
- {state.status === "loading" && (
114
- <Text variant="caption" className="text-center py-8">
115
- {t("rawModal.loading")}
116
- </Text>
117
- )}
118
-
119
- {state.status === "error" && (
120
- <Text variant="body" className="text-red-500 text-center py-8">
121
- {state.error}
122
- </Text>
123
- )}
124
-
125
- {state.status === "empty" && (
126
- <Text variant="caption" className="text-center py-8">
127
- {t("rawModal.noComments")}
128
- </Text>
129
- )}
130
-
131
- {state.status === "success" && (
132
- <Text
133
- variant="body"
134
- as="pre"
135
- className="text-xs font-mono whitespace-pre-wrap break-words leading-relaxed"
136
- >
137
- {state.content}
138
- </Text>
139
- )}
140
- </DialogBody>
141
- </DialogContent>
142
- </Dialog>
143
- );
144
- }
@@ -1,36 +0,0 @@
1
- import { useLocale } from "../contexts/LocaleContext";
2
- import { Button } from "./ui/Button";
3
- import { Text } from "./ui/Text";
4
-
5
- interface ReanchorConfirmProps {
6
- selectionText: string;
7
- onConfirm: () => void;
8
- onCancel: () => void;
9
- }
10
-
11
- export function ReanchorConfirm({
12
- selectionText,
13
- onConfirm,
14
- onCancel,
15
- }: ReanchorConfirmProps) {
16
- const { t } = useLocale();
17
-
18
- return (
19
- <div className="border-t border-zinc-200 dark:border-zinc-700 pt-2 pb-3 pl-6">
20
- <Text variant="body" className="mb-2">
21
- {t("reanchor.question")}
22
- </Text>
23
- <Text variant="caption" className="italic line-clamp-2 mb-2">
24
- "{selectionText}"
25
- </Text>
26
- <div className="flex gap-3 text-sm">
27
- <Button variant="link" size="sm" onClick={onConfirm}>
28
- {t("reanchor.confirm")}
29
- </Button>
30
- <Button variant="ghost" size="sm" onClick={onCancel}>
31
- {t("reanchor.cancel")}
32
- </Button>
33
- </div>
34
- </div>
35
- );
36
- }
@@ -1,232 +0,0 @@
1
- import { Check, ChevronDown } from "lucide-react";
2
- import { useLocale } from "../contexts/LocaleContext";
3
- import { useSettings } from "../contexts/SettingsContext";
4
- import { type Locale, Locales } from "../lib/i18n";
5
- import { cn } from "../lib/utils";
6
- import {
7
- FontFamilies,
8
- type FontFamily,
9
- type ThemeMode,
10
- ThemeModes,
11
- } from "../schema";
12
- import {
13
- Dialog,
14
- DialogBody,
15
- DialogContent,
16
- DialogHeader,
17
- DialogTitle,
18
- } from "./ui/Dialog";
19
- import {
20
- DropdownMenu,
21
- DropdownMenuContent,
22
- DropdownMenuItem,
23
- DropdownMenuTrigger,
24
- } from "./ui/DropdownMenu";
25
- import { Text } from "./ui/Text";
26
-
27
- interface SettingsModalProps {
28
- isOpen: boolean;
29
- onClose: () => void;
30
- }
31
-
32
- const LOCALE_OPTIONS = [
33
- { value: Locales.JA, label: "日本語" },
34
- { value: Locales.EN, label: "English" },
35
- ] as const;
36
-
37
- function ThemeDot({
38
- mode,
39
- className,
40
- }: {
41
- mode: ThemeMode;
42
- className?: string;
43
- }) {
44
- if (mode === ThemeModes.SYSTEM) {
45
- return (
46
- <span
47
- className={cn(
48
- "size-2.5 rounded-full bg-gradient-to-r from-amber-400 to-indigo-400",
49
- className,
50
- )}
51
- />
52
- );
53
- }
54
-
55
- return (
56
- <span
57
- className={cn(
58
- "size-2.5 rounded-full",
59
- mode === ThemeModes.LIGHT ? "bg-amber-400" : "bg-indigo-400",
60
- className,
61
- )}
62
- />
63
- );
64
- }
65
-
66
- function ThemePreviewBadge() {
67
- return (
68
- <span className="text-[10px] font-semibold leading-none text-zinc-500 dark:text-zinc-400 bg-zinc-100 dark:bg-zinc-800 rounded px-1 py-0.5">
69
- Aa
70
- </span>
71
- );
72
- }
73
-
74
- function FontPreviewBadge({ fontClass }: { fontClass: string }) {
75
- return (
76
- <span
77
- className={cn(
78
- "text-[10px] font-semibold leading-none text-zinc-500 dark:text-zinc-400 bg-zinc-100 dark:bg-zinc-800 rounded px-1 py-0.5",
79
- fontClass,
80
- )}
81
- >
82
- Aa
83
- </span>
84
- );
85
- }
86
-
87
- const triggerClassName = cn(
88
- "inline-flex items-center gap-2 px-2.5 py-1.5 rounded-lg text-sm",
89
- "border border-zinc-200 dark:border-zinc-700",
90
- "bg-white dark:bg-zinc-800",
91
- "text-zinc-700 dark:text-zinc-300",
92
- "hover:bg-zinc-50 dark:hover:bg-zinc-700/50",
93
- "transition-colors cursor-pointer",
94
- );
95
-
96
- export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
97
- const { fontFamily, setFontFamily, themeMode, setThemeMode } = useSettings();
98
- const { locale, setLocale, t } = useLocale();
99
-
100
- const themeOptions = [
101
- { value: ThemeModes.SYSTEM, label: t("settings.theme.system") },
102
- { value: ThemeModes.LIGHT, label: t("settings.theme.light") },
103
- { value: ThemeModes.DARK, label: t("settings.theme.dark") },
104
- ];
105
-
106
- const fontOptions = [
107
- {
108
- value: FontFamilies.SERIF,
109
- label: t("settings.font.serif"),
110
- fontClass: "font-serif",
111
- },
112
- {
113
- value: FontFamilies.SANS_SERIF,
114
- label: t("settings.font.sansSerif"),
115
- fontClass: "font-sans",
116
- },
117
- ];
118
-
119
- const activeTheme =
120
- themeOptions.find((o) => o.value === themeMode) ?? themeOptions[0];
121
- const activeFont =
122
- fontOptions.find((o) => o.value === fontFamily) ?? fontOptions[0];
123
- const activeLocale =
124
- LOCALE_OPTIONS.find((o) => o.value === locale) ?? LOCALE_OPTIONS[0];
125
-
126
- return (
127
- <Dialog
128
- open={isOpen}
129
- onOpenChange={(open) => {
130
- if (!open) onClose();
131
- }}
132
- >
133
- <DialogContent className="max-w-md" onClose={onClose}>
134
- <DialogHeader>
135
- <DialogTitle>{t("settings.title")}</DialogTitle>
136
- </DialogHeader>
137
-
138
- <DialogBody className="space-y-4">
139
- <div>
140
- <Text variant="overline" as="h3" className="mb-3">
141
- {t("settings.theme")}
142
- </Text>
143
- <DropdownMenu>
144
- <DropdownMenuTrigger asChild>
145
- <button type="button" className={triggerClassName}>
146
- <ThemeDot mode={activeTheme.value} />
147
- <ThemePreviewBadge />
148
- <span>{activeTheme.label}</span>
149
- <ChevronDown className="size-3 text-zinc-400 dark:text-zinc-500" />
150
- </button>
151
- </DropdownMenuTrigger>
152
- <DropdownMenuContent align="start" className="min-w-[160px]">
153
- {themeOptions.map((option) => (
154
- <DropdownMenuItem
155
- key={option.value}
156
- onSelect={() => setThemeMode(option.value)}
157
- className="flex items-center gap-2"
158
- >
159
- <ThemeDot mode={option.value} />
160
- <ThemePreviewBadge />
161
- <span className="flex-1">{option.label}</span>
162
- {themeMode === option.value && (
163
- <Check className="size-3.5 text-zinc-500 dark:text-zinc-400" />
164
- )}
165
- </DropdownMenuItem>
166
- ))}
167
- </DropdownMenuContent>
168
- </DropdownMenu>
169
- </div>
170
-
171
- <div>
172
- <Text variant="overline" as="h3" className="mb-3">
173
- {t("settings.font")}
174
- </Text>
175
- <DropdownMenu>
176
- <DropdownMenuTrigger asChild>
177
- <button type="button" className={triggerClassName}>
178
- <FontPreviewBadge fontClass={activeFont.fontClass} />
179
- <span>{activeFont.label}</span>
180
- <ChevronDown className="size-3 text-zinc-400 dark:text-zinc-500" />
181
- </button>
182
- </DropdownMenuTrigger>
183
- <DropdownMenuContent align="start" className="min-w-[160px]">
184
- {fontOptions.map((option) => (
185
- <DropdownMenuItem
186
- key={option.value}
187
- onSelect={() => setFontFamily(option.value as FontFamily)}
188
- className="flex items-center gap-2"
189
- >
190
- <FontPreviewBadge fontClass={option.fontClass} />
191
- <span className="flex-1">{option.label}</span>
192
- {fontFamily === option.value && (
193
- <Check className="size-3.5 text-zinc-500 dark:text-zinc-400" />
194
- )}
195
- </DropdownMenuItem>
196
- ))}
197
- </DropdownMenuContent>
198
- </DropdownMenu>
199
- </div>
200
-
201
- <div>
202
- <Text variant="overline" as="h3" className="mb-3">
203
- {t("settings.language")}
204
- </Text>
205
- <DropdownMenu>
206
- <DropdownMenuTrigger asChild>
207
- <button type="button" className={triggerClassName}>
208
- <span>{activeLocale.label}</span>
209
- <ChevronDown className="size-3 text-zinc-400 dark:text-zinc-500" />
210
- </button>
211
- </DropdownMenuTrigger>
212
- <DropdownMenuContent align="start" className="min-w-[160px]">
213
- {LOCALE_OPTIONS.map((option) => (
214
- <DropdownMenuItem
215
- key={option.value}
216
- onSelect={() => setLocale(option.value as Locale)}
217
- className="flex items-center gap-2"
218
- >
219
- <span className="flex-1">{option.label}</span>
220
- {locale === option.value && (
221
- <Check className="size-3.5 text-zinc-500 dark:text-zinc-400" />
222
- )}
223
- </DropdownMenuItem>
224
- ))}
225
- </DropdownMenuContent>
226
- </DropdownMenu>
227
- </div>
228
- </DialogBody>
229
- </DialogContent>
230
- </Dialog>
231
- );
232
- }
@@ -1,60 +0,0 @@
1
- import { X } from "lucide-react";
2
- import { cn } from "../lib/utils";
3
- import { useAppStore } from "../store";
4
-
5
- export function TabBar() {
6
- const documentOrder = useAppStore((s) => s.documentOrder);
7
- const activeDocumentPath = useAppStore((s) => s.activeDocumentPath);
8
- const documents = useAppStore((s) => s.documents);
9
- const setActiveDocument = useAppStore((s) => s.setActiveDocument);
10
- const closeDocument = useAppStore((s) => s.closeDocument);
11
-
12
- if (documentOrder.length <= 1) return null;
13
-
14
- return (
15
- <div
16
- className="flex border-b border-zinc-200 dark:border-zinc-800 bg-zinc-50 dark:bg-zinc-900 px-2 overflow-x-auto"
17
- role="tablist"
18
- >
19
- {documentOrder.map((filePath) => {
20
- const docState = documents.get(filePath);
21
- if (!docState) return null;
22
- const isActive = filePath === activeDocumentPath;
23
-
24
- return (
25
- <div
26
- key={filePath}
27
- role="tab"
28
- tabIndex={isActive ? 0 : -1}
29
- aria-selected={isActive}
30
- className={cn(
31
- "flex items-center gap-1.5 px-3 py-1.5 text-sm border-b-2 whitespace-nowrap cursor-pointer select-none",
32
- isActive
33
- ? "border-zinc-900 dark:border-zinc-100 text-zinc-900 dark:text-zinc-100"
34
- : "border-transparent text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800",
35
- )}
36
- onClick={() => setActiveDocument(filePath)}
37
- onKeyDown={(e) => {
38
- if (e.key === "Enter" || e.key === " ") {
39
- e.preventDefault();
40
- setActiveDocument(filePath);
41
- }
42
- }}
43
- >
44
- <span>{docState.document.fileName}</span>
45
- <button
46
- type="button"
47
- className="ml-1 rounded p-0.5 hover:bg-zinc-200 dark:hover:bg-zinc-700"
48
- onClick={(e) => {
49
- e.stopPropagation();
50
- closeDocument(filePath);
51
- }}
52
- >
53
- <X className="h-3 w-3" />
54
- </button>
55
- </div>
56
- );
57
- })}
58
- </div>
59
- );
60
- }