HistoryItem.svelte RawHistoryFolder
1<script lang="ts">
2 import type { HistoryEntry } from '../types';
3
4 interface Props {
5 entry: HistoryEntry;
6 focused?: boolean;
7 onSelect?: (entry: HistoryEntry) => void;
8 onDelete?: (entry: HistoryEntry) => void;
9 }
10
11 let { entry, focused = false, onSelect, onDelete }: Props = $props();
12
13 let deleting = $state(false);
14
15 const getDomain = (url: string): string => {
16 try {
17 return new URL(url).hostname;
18 } catch {
19 return url;
20 }
21 };
22
23 const getFaviconUrl = (item: HistoryEntry): string => {
24 // Use provided favicon_url if available
25 if (item.favicon_url) return item.favicon_url;
26 // Handle internal dumb:// URLs - use embedded favicon
27 if (item.url.startsWith('dumb://')) {
28 return 'favicon.png';
29 }
30 // Construct DuckDuckGo favicon URL from domain
31 const domain = getDomain(item.url).replace(/^www\./, '');
32 return `https://icons.duckduckgo.com/ip3/${encodeURIComponent(domain)}.ico`;
33 };
34
35 const getDisplayTitle = (item: HistoryEntry): string => {
36 const trimmed = item.title?.trim();
37 if (trimmed) return trimmed;
38 return getDomain(item.url) || item.url;
39 };
40
41 const formatTime = (timestamp: string): string => {
42 try {
43 const date = new Date(timestamp);
44 const now = new Date();
45 const diff = now.getTime() - date.getTime();
46
47 const minutes = Math.floor(diff / (1000 * 60));
48 const hours = Math.floor(diff / (1000 * 60 * 60));
49
50 if (minutes < 1) return 'now';
51 if (minutes < 60) return `${minutes}m`;
52 if (hours < 24) return `${hours}h`;
53
54 return date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
55 } catch {
56 return '';
57 }
58 };
59
60 const handleSelect = () => {
61 onSelect?.(entry);
62 };
63
64 const handleDelete = (e: Event) => {
65 e.stopPropagation();
66 deleting = true;
67 onDelete?.(entry);
68 };
69
70 const handleKeyDown = (e: KeyboardEvent) => {
71 if (e.key === 'Enter') {
72 handleSelect();
73 } else if (e.key === 'd' || e.key === 'x') {
74 handleDelete(e);
75 }
76 };
77</script>
78
79<div
80 class="history-item"
81 class:focused
82 class:deleting
83 role="button"
84 tabindex="0"
85 data-entry-id={entry.id}
86 onclick={handleSelect}
87 onkeydown={handleKeyDown}
88>
89 <div class="item-favicon">
90 <img
91 src={getFaviconUrl(entry)}
92 alt=""
93 class="favicon-img"
94 onerror={(e) => {
95 const target = e.target as HTMLImageElement;
96 target.style.display = 'none';
97 const fallback = target.nextElementSibling as HTMLElement;
98 if (fallback) fallback.style.display = 'flex';
99 }}
100 />
101 <div class="favicon-fallback" style="display: none;"></div>
102 </div>
103
104 <div class="item-content">
105 <div class="item-row">
106 <span class="item-title">{getDisplayTitle(entry)}</span>
107 <span class="item-time">{formatTime(entry.last_visited)}</span>
108 </div>
109 <div class="item-url">{entry.url}</div>
110 </div>
111
112 <div class="item-actions">
113 {#if entry.visit_count > 1}
114 <span class="visit-count">{entry.visit_count}x</span>
115 {/if}
116 <button
117 class="action-btn delete-btn"
118 type="button"
119 onclick={handleDelete}
120 title="Delete entry (d)"
121 >
122 <span aria-hidden="true">×</span>
123 </button>
124 </div>
125</div>
126
127<style>
128 .history-item {
129 display: grid;
130 grid-template-columns: auto 1fr auto;
131 align-items: center;
132 gap: 0.75rem;
133 padding: 0.55rem 0.85rem;
134 border-bottom-width: 1px;
135 border-bottom-style: solid;
136 border-bottom-color: color-mix(in srgb, var(--border) 50%, transparent);
137 transition: background-color 100ms ease;
138 cursor: pointer;
139 }
140
141 .history-item:last-child {
142 border-bottom: none;
143 }
144
145 .history-item:hover,
146 .history-item.focused {
147 background: color-mix(in srgb, var(--card) 40%, transparent);
148 }
149
150 .history-item.focused {
151 outline: 1px solid var(--primary, #4ade80);
152 outline-offset: -1px;
153 }
154
155 .history-item.deleting {
156 opacity: 0.3;
157 pointer-events: none;
158 }
159
160 .item-favicon {
161 width: 28px;
162 height: 28px;
163 border-width: 1px;
164 border-style: solid;
165 border-color: var(--border);
166 background: var(--background);
167 display: flex;
168 align-items: center;
169 justify-content: center;
170 flex-shrink: 0;
171 }
172
173 .favicon-img {
174 width: 16px;
175 height: 16px;
176 object-fit: contain;
177 }
178
179 .favicon-fallback {
180 width: 16px;
181 height: 16px;
182 display: flex;
183 align-items: center;
184 justify-content: center;
185 color: var(--muted-foreground);
186 }
187
188 .favicon-fallback::before {
189 content: '';
190 font-size: 0.75rem;
191 }
192
193 .item-content {
194 display: flex;
195 flex-direction: column;
196 gap: 0.15rem;
197 min-width: 0;
198 }
199
200 .item-row {
201 display: flex;
202 align-items: center;
203 justify-content: space-between;
204 gap: 0.75rem;
205 }
206
207 .item-title {
208 font-size: 0.8rem;
209 font-weight: 500;
210 color: var(--foreground);
211 white-space: nowrap;
212 overflow: hidden;
213 text-overflow: ellipsis;
214 }
215
216 .item-time {
217 font-size: 0.65rem;
218 color: var(--muted-foreground);
219 text-transform: uppercase;
220 letter-spacing: 0.08em;
221 white-space: nowrap;
222 flex-shrink: 0;
223 }
224
225 .item-url {
226 font-size: 0.68rem;
227 color: var(--muted-foreground);
228 white-space: nowrap;
229 overflow: hidden;
230 text-overflow: ellipsis;
231 }
232
233 .item-actions {
234 display: flex;
235 align-items: center;
236 gap: 0.5rem;
237 }
238
239 .visit-count {
240 font-size: 0.6rem;
241 color: var(--muted-foreground);
242 padding: 0.15rem 0.35rem;
243 border-width: 1px;
244 border-style: solid;
245 border-color: var(--border);
246 background: var(--background);
247 }
248
249 .action-btn {
250 width: 24px;
251 height: 24px;
252 display: flex;
253 align-items: center;
254 justify-content: center;
255 border-width: 1px;
256 border-style: solid;
257 border-color: var(--border);
258 background: transparent;
259 color: var(--muted-foreground);
260 cursor: pointer;
261 transition: all 100ms ease;
262 opacity: 0;
263 }
264
265 .history-item:hover .action-btn,
266 .history-item.focused .action-btn {
267 opacity: 1;
268 }
269
270 .action-btn:hover {
271 color: var(--foreground);
272 border-color: var(--foreground);
273 background: color-mix(in srgb, var(--card) 50%, transparent);
274 }
275
276 .delete-btn:hover {
277 color: var(--destructive);
278 border-color: var(--destructive);
279 }
280</style>