A very simple bookmarking webapp bookmarker.finxol.deno.net/
at main 253 lines 9.4 kB view raw
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}