A very simple bookmarking webapp
bookmarker.finxol.deno.net/
1import { createSignal, For, Show } from "solid-js"
2import type { Bookmark } from "../../server/utils/bookmarks.ts"
3import {
4 ExternalLinkIcon,
5 LoaderIcon,
6 PlusIcon,
7 TrashIcon,
8 XIcon,
9} from "lucide-solid"
10import { BookmarkEditModal } from "./BookmarkEditModal.tsx"
11import { useMutation, useQueryClient } from "@tanstack/solid-query"
12import { useWebHaptics } from "../utils/haptics.ts"
13import { client } from "../apiclient.ts"
14import { InternetArchiveIcon } from "../utils/icons.tsx"
15
16import "./ui/tooltip.css"
17
18interface BookmarkProps {
19 bookmark: Bookmark & { id: string }
20}
21
22export function Bookmark(props: BookmarkProps) {
23 const bookmark = () => props.bookmark
24 const [addingTag, setAddingTag] = createSignal(false)
25 const [tagInput, setTagInput] = createSignal("")
26 const queryClient = useQueryClient()
27 const haptics = useWebHaptics()
28
29 const deleteBookmark = useMutation(() => ({
30 mutationFn: async () => {
31 const res = await client.api.v1.bookmarks[":id"].$delete({
32 param: { id: bookmark().id },
33 })
34 if (!res.ok) {
35 haptics.trigger([
36 { duration: 40, intensity: 0.7 },
37 { delay: 40, duration: 40, intensity: 0.7 },
38 { delay: 40, duration: 40, intensity: 0.9 },
39 { delay: 40, duration: 50, intensity: 0.6 },
40 ])
41 throw new Error("Failed to delete bookmark")
42 }
43 },
44 onSuccess: async () => {
45 await Promise.allSettled([
46 queryClient.invalidateQueries({
47 queryKey: [client.api.v1.bookmarks.all.$url().pathname],
48 }),
49 queryClient.invalidateQueries({
50 queryKey: [client.api.v1.bookmarks.tags.$url().pathname],
51 }),
52 ])
53 haptics.trigger([
54 { duration: 30 },
55 { delay: 60, duration: 40, intensity: 1 },
56 ])
57 },
58 }))
59
60 const updateTags = useMutation(() => ({
61 mutationFn: async (tags: string[]) => {
62 const res = await client.api.v1.bookmarks[":id"].tags.$put({
63 param: { id: bookmark().id },
64 json: { tags: tags },
65 })
66 if (!res.ok) {
67 haptics.trigger([
68 { duration: 40, intensity: 0.7 },
69 { delay: 40, duration: 40, intensity: 0.7 },
70 { delay: 40, duration: 40, intensity: 0.9 },
71 { delay: 40, duration: 50, intensity: 0.6 },
72 ])
73 throw new Error("Failed to update tags")
74 }
75 },
76 onSuccess: async () => {
77 await Promise.allSettled([
78 queryClient.invalidateQueries({
79 queryKey: [client.api.v1.bookmarks.all.$url().pathname],
80 }),
81 queryClient.invalidateQueries({
82 queryKey: [client.api.v1.bookmarks.tags.$url().pathname],
83 }),
84 ])
85 haptics.trigger([
86 { duration: 30 },
87 { delay: 60, duration: 40, intensity: 1 },
88 ])
89 },
90 }))
91
92 function addTag(value: string) {
93 const tag = value.trim().toLowerCase()
94 if (!tag) return
95 const existing = bookmark().tags ?? []
96 if (existing.includes(tag)) return
97 updateTags.mutate([...existing, tag])
98 setTagInput("")
99 setAddingTag(false)
100 }
101
102 function removeTag(tag: string) {
103 const existing = bookmark().tags ?? []
104 updateTags.mutate(existing.filter((t) => t !== tag))
105 }
106
107 function cancelAdding() {
108 setTagInput("")
109 setAddingTag(false)
110 }
111
112 function formatDate(dateStr: string) {
113 const date = Temporal.Instant.from(dateStr)
114 return date.toLocaleString("en-GB", {
115 month: "short",
116 day: "numeric",
117 year: "numeric",
118 })
119 }
120
121 function getDomain(url: string) {
122 try {
123 return new URL(url).hostname.replace(/^www\./, "")
124 } catch {
125 return url
126 }
127 }
128
129 return (
130 <article class="bookmark-card">
131 <Show when={bookmark().image}>
132 <img
133 class="card-image"
134 src={bookmark().image}
135 alt={bookmark().title}
136 loading="lazy"
137 />
138 </Show>
139 <div class="card-body">
140 <div class="card-domain">
141 {getDomain(bookmark().url)}
142 </div>
143 <h3 class="card-title">
144 {bookmark().title}
145 </h3>
146 <Show when={bookmark().description}>
147 <p class="card-description">
148 {bookmark().description}
149 </p>
150 </Show>
151 <div class="card-tags">
152 <For each={bookmark().tags}>
153 {(tag) => (
154 <span class="tag">
155 {tag}
156 <button
157 type="button"
158 class="tag-remove"
159 title={`Remove tag "${tag}"`}
160 onClick={(e) => {
161 e.stopPropagation()
162 removeTag(tag)
163 }}
164 disabled={updateTags.isPending}
165 >
166 <XIcon size={10} />
167 </button>
168 </span>
169 )}
170 </For>
171 <Show when={updateTags.isPending}>
172 <span class="tag tag-loading">
173 <LoaderIcon size={12} class="spinner" />
174 </span>
175 </Show>
176 <Show
177 when={addingTag()}
178 fallback={
179 <button
180 type="button"
181 class="tag tag-add"
182 onClick={() => setAddingTag(true)}
183 disabled={updateTags.isPending}
184 >
185 <PlusIcon size={12} />
186 Add tag
187 </button>
188 }
189 >
190 <input
191 ref={(el) => {
192 requestAnimationFrame(() => el.focus())
193 }}
194 type="text"
195 class="tag-input"
196 placeholder="tag name"
197 value={tagInput()}
198 onInput={(e) => setTagInput(e.currentTarget.value)}
199 onKeyDown={(e) => {
200 if (e.key === "Enter") {
201 e.preventDefault()
202 addTag(e.currentTarget.value)
203 }
204 if (e.key === "Escape") {
205 cancelAdding()
206 }
207 }}
208 onBlur={() => cancelAdding()}
209 />
210 </Show>
211 </div>
212 </div>
213 <div class="card-footer">
214 <time>
215 {formatDate(bookmark().updatedAt)}
216 </time>
217 <div class="card-actions">
218 <a
219 href={bookmark().url}
220 target="_blank"
221 rel="noopener noreferrer"
222 class="button-icon button-ghost cover"
223 title="Open link"
224 >
225 <ExternalLinkIcon size={16} />
226 </a>
227 <a
228 href={`https://web.archive.org/web/${bookmark().url}`}
229 target="_blank"
230 rel="noopener noreferrer"
231 class="button-icon button-ghost"
232 title="Open in Web Archive"
233 data-tooltip="Open in Web Archive"
234 >
235 <InternetArchiveIcon size={16} />
236 </a>
237 <BookmarkEditModal
238 bookmark={bookmark()}
239 />
240 <button
241 type="button"
242 class="button-icon button-ghost button-danger"
243 title="Delete bookmark"
244 onClick={() => deleteBookmark.mutate()}
245 disabled={deleteBookmark.isPending}
246 >
247 <TrashIcon size={16} />
248 </button>
249 </div>
250 </div>
251 </article>
252 )
253}