diff --git a/packages/ui/public/locales/de.json b/packages/ui/public/locales/de.json index 78a4589a6..ae9f9a400 100644 --- a/packages/ui/public/locales/de.json +++ b/packages/ui/public/locales/de.json @@ -270,6 +270,20 @@ "desktop.app.context.settings": "Einstellungen", "desktop.app.context.show-default-credentials": "Standardanmeldeinformationen anzeigen", "desktop.app.context.uninstall": "Deinstallieren", + "desktop.bookmark.add": "Lesezeichen hinzufügen", + "desktop.bookmark.context.delete": "Lesezeichen löschen", + "desktop.bookmark.context.edit": "Lesezeichen bearbeiten", + "desktop.bookmark.dialog.icon-label": "Symbol", + "desktop.bookmark.dialog.icon-placeholder": "Hochladen oder Favicon verwenden", + "desktop.bookmark.dialog.name-label": "Name", + "desktop.bookmark.dialog.name-placeholder": "Meine Website", + "desktop.bookmark.dialog.open-in-new-tab": "In neuem Tab öffnen", + "desktop.bookmark.dialog.title-add": "Lesezeichen hinzufügen", + "desktop.bookmark.dialog.title-edit": "Lesezeichen bearbeiten", + "desktop.bookmark.dialog.upload-icon": "Symbol hochladen", + "desktop.bookmark.dialog.url-label": "URL", + "desktop.bookmark.dialog.url-placeholder": "https://beispiel.de", + "desktop.bookmark.dialog.use-favicon": "Favicon von URL verwenden", "desktop.context-menu.change-wallpaper": "Hintergrundbild ändern", "desktop.context-menu.edit-widgets": "Widgets bearbeiten", "desktop.context-menu.logout": "Abmelden", diff --git a/packages/ui/public/locales/en.json b/packages/ui/public/locales/en.json index 2aef4149d..befedcc6b 100644 --- a/packages/ui/public/locales/en.json +++ b/packages/ui/public/locales/en.json @@ -270,6 +270,20 @@ "desktop.app.context.settings": "Settings", "desktop.app.context.show-default-credentials": "Show default credentials", "desktop.app.context.uninstall": "Uninstall", + "desktop.bookmark.add": "Add Bookmark", + "desktop.bookmark.context.delete": "Delete Bookmark", + "desktop.bookmark.context.edit": "Edit Bookmark", + "desktop.bookmark.dialog.icon-label": "Icon", + "desktop.bookmark.dialog.icon-placeholder": "Upload or use favicon", + "desktop.bookmark.dialog.name-label": "Name", + "desktop.bookmark.dialog.name-placeholder": "My Website", + "desktop.bookmark.dialog.open-in-new-tab": "Open in new tab", + "desktop.bookmark.dialog.title-add": "Add Bookmark", + "desktop.bookmark.dialog.title-edit": "Edit Bookmark", + "desktop.bookmark.dialog.upload-icon": "Upload icon", + "desktop.bookmark.dialog.url-label": "URL", + "desktop.bookmark.dialog.url-placeholder": "https://example.com", + "desktop.bookmark.dialog.use-favicon": "Use favicon from URL", "desktop.context-menu.change-wallpaper": "Change wallpaper", "desktop.context-menu.edit-widgets": "Edit widgets", "desktop.context-menu.logout": "Log out", diff --git a/packages/ui/public/locales/es.json b/packages/ui/public/locales/es.json index 1bed05de8..aa3d219b3 100644 --- a/packages/ui/public/locales/es.json +++ b/packages/ui/public/locales/es.json @@ -270,6 +270,20 @@ "desktop.app.context.settings": "Configuración", "desktop.app.context.show-default-credentials": "Mostrar credenciales predeterminadas", "desktop.app.context.uninstall": "Desinstalar", + "desktop.bookmark.add": "Añadir marcador", + "desktop.bookmark.context.delete": "Eliminar marcador", + "desktop.bookmark.context.edit": "Editar marcador", + "desktop.bookmark.dialog.icon-label": "Icono", + "desktop.bookmark.dialog.icon-placeholder": "Subir o usar favicon", + "desktop.bookmark.dialog.name-label": "Nombre", + "desktop.bookmark.dialog.name-placeholder": "Mi sitio web", + "desktop.bookmark.dialog.open-in-new-tab": "Abrir en nueva pestaña", + "desktop.bookmark.dialog.title-add": "Añadir marcador", + "desktop.bookmark.dialog.title-edit": "Editar marcador", + "desktop.bookmark.dialog.upload-icon": "Subir icono", + "desktop.bookmark.dialog.url-label": "URL", + "desktop.bookmark.dialog.url-placeholder": "https://ejemplo.com", + "desktop.bookmark.dialog.use-favicon": "Usar favicon de URL", "desktop.context-menu.change-wallpaper": "Cambiar fondo de pantalla", "desktop.context-menu.edit-widgets": "Editar widgets", "desktop.context-menu.logout": "Cerrar sesión", diff --git a/packages/ui/public/locales/fr.json b/packages/ui/public/locales/fr.json index 6f30ac7d9..128d0cd34 100644 --- a/packages/ui/public/locales/fr.json +++ b/packages/ui/public/locales/fr.json @@ -270,6 +270,20 @@ "desktop.app.context.settings": "Réglages", "desktop.app.context.show-default-credentials": "Afficher les identifiants par défaut", "desktop.app.context.uninstall": "Désinstaller", + "desktop.bookmark.add": "Ajouter un favori", + "desktop.bookmark.context.delete": "Supprimer le favori", + "desktop.bookmark.context.edit": "Modifier le favori", + "desktop.bookmark.dialog.icon-label": "Icône", + "desktop.bookmark.dialog.icon-placeholder": "Télécharger ou utiliser le favicon", + "desktop.bookmark.dialog.name-label": "Nom", + "desktop.bookmark.dialog.name-placeholder": "Mon site web", + "desktop.bookmark.dialog.open-in-new-tab": "Ouvrir dans un nouvel onglet", + "desktop.bookmark.dialog.title-add": "Ajouter un favori", + "desktop.bookmark.dialog.title-edit": "Modifier le favori", + "desktop.bookmark.dialog.upload-icon": "Télécharger l'icône", + "desktop.bookmark.dialog.url-label": "URL", + "desktop.bookmark.dialog.url-placeholder": "https://exemple.com", + "desktop.bookmark.dialog.use-favicon": "Utiliser le favicon de l'URL", "desktop.context-menu.change-wallpaper": "Changer de fond d'écran", "desktop.context-menu.edit-widgets": "Modifier les widgets", "desktop.context-menu.logout": "Se déconnecter", diff --git a/packages/ui/public/locales/hu.json b/packages/ui/public/locales/hu.json index 565b03474..ee04a6312 100644 --- a/packages/ui/public/locales/hu.json +++ b/packages/ui/public/locales/hu.json @@ -270,6 +270,20 @@ "desktop.app.context.settings": "Beállítások", "desktop.app.context.show-default-credentials": "Alapértelmezett hitelesítési adatok megjelenítése", "desktop.app.context.uninstall": "Eltávolítás", + "desktop.bookmark.add": "Könyvjelző hozzáadása", + "desktop.bookmark.context.delete": "Könyvjelző törlése", + "desktop.bookmark.context.edit": "Könyvjelző szerkesztése", + "desktop.bookmark.dialog.icon-label": "Ikon", + "desktop.bookmark.dialog.icon-placeholder": "Feltöltés vagy favicon használata", + "desktop.bookmark.dialog.name-label": "Név", + "desktop.bookmark.dialog.name-placeholder": "Weboldalám", + "desktop.bookmark.dialog.open-in-new-tab": "Megnyitás új lapon", + "desktop.bookmark.dialog.title-add": "Könyvjelző hozzáadása", + "desktop.bookmark.dialog.title-edit": "Könyvjelző szerkesztése", + "desktop.bookmark.dialog.upload-icon": "Ikon feltöltése", + "desktop.bookmark.dialog.url-label": "URL", + "desktop.bookmark.dialog.url-placeholder": "https://pelda.hu", + "desktop.bookmark.dialog.use-favicon": "Favicon használata URL-ből", "desktop.context-menu.change-wallpaper": "Háttérkép megváltoztatása", "desktop.context-menu.edit-widgets": "Widgetek szerkesztése", "desktop.context-menu.logout": "Kijelentkezés", diff --git a/packages/ui/public/locales/it.json b/packages/ui/public/locales/it.json index 89effc002..c4fff230b 100644 --- a/packages/ui/public/locales/it.json +++ b/packages/ui/public/locales/it.json @@ -270,6 +270,20 @@ "desktop.app.context.settings": "Impostazioni", "desktop.app.context.show-default-credentials": "Mostra credenziali predefinite", "desktop.app.context.uninstall": "Disinstalla", + "desktop.bookmark.add": "Aggiungi segnalibro", + "desktop.bookmark.context.delete": "Elimina segnalibro", + "desktop.bookmark.context.edit": "Modifica segnalibro", + "desktop.bookmark.dialog.icon-label": "Icona", + "desktop.bookmark.dialog.icon-placeholder": "Carica o usa favicon", + "desktop.bookmark.dialog.name-label": "Nome", + "desktop.bookmark.dialog.name-placeholder": "Il mio sito web", + "desktop.bookmark.dialog.open-in-new-tab": "Apri in una nuova scheda", + "desktop.bookmark.dialog.title-add": "Aggiungi segnalibro", + "desktop.bookmark.dialog.title-edit": "Modifica segnalibro", + "desktop.bookmark.dialog.upload-icon": "Carica icona", + "desktop.bookmark.dialog.url-label": "URL", + "desktop.bookmark.dialog.url-placeholder": "https://esempio.com", + "desktop.bookmark.dialog.use-favicon": "Usa favicon dall'URL", "desktop.context-menu.change-wallpaper": "Cambia sfondo", "desktop.context-menu.edit-widgets": "Modifica widget", "desktop.context-menu.logout": "Esci", diff --git a/packages/ui/public/locales/ja.json b/packages/ui/public/locales/ja.json index f9a0fec86..231f0786e 100644 --- a/packages/ui/public/locales/ja.json +++ b/packages/ui/public/locales/ja.json @@ -270,6 +270,20 @@ "desktop.app.context.settings": "設定", "desktop.app.context.show-default-credentials": "デフォルトの資格情報を表示", "desktop.app.context.uninstall": "アンインストール", + "desktop.bookmark.add": "ブックマークを追加", + "desktop.bookmark.context.delete": "ブックマークを削除", + "desktop.bookmark.context.edit": "ブックマークを編集", + "desktop.bookmark.dialog.icon-label": "アイコン", + "desktop.bookmark.dialog.icon-placeholder": "アップロードまたはファビコンを使用", + "desktop.bookmark.dialog.name-label": "名前", + "desktop.bookmark.dialog.name-placeholder": "私のウェブサイト", + "desktop.bookmark.dialog.open-in-new-tab": "新しいタブで開く", + "desktop.bookmark.dialog.title-add": "ブックマークを追加", + "desktop.bookmark.dialog.title-edit": "ブックマークを編集", + "desktop.bookmark.dialog.upload-icon": "アイコンをアップロード", + "desktop.bookmark.dialog.url-label": "URL", + "desktop.bookmark.dialog.url-placeholder": "https://example.com", + "desktop.bookmark.dialog.use-favicon": "URLからファビコンを使用", "desktop.context-menu.change-wallpaper": "壁紙を変更", "desktop.context-menu.edit-widgets": "ウィジェットを編集", "desktop.context-menu.logout": "ログアウト", diff --git a/packages/ui/public/locales/ko.json b/packages/ui/public/locales/ko.json index 2ce1ee708..02e0d4bd0 100644 --- a/packages/ui/public/locales/ko.json +++ b/packages/ui/public/locales/ko.json @@ -270,6 +270,20 @@ "desktop.app.context.settings": "설정", "desktop.app.context.show-default-credentials": "기본 자격 증명 보기", "desktop.app.context.uninstall": "제거", + "desktop.bookmark.add": "북마크 추가", + "desktop.bookmark.context.delete": "북마크 삭제", + "desktop.bookmark.context.edit": "북마크 편집", + "desktop.bookmark.dialog.icon-label": "아이콘", + "desktop.bookmark.dialog.icon-placeholder": "업로드 또는 파비콘 사용", + "desktop.bookmark.dialog.name-label": "이름", + "desktop.bookmark.dialog.name-placeholder": "내 웹사이트", + "desktop.bookmark.dialog.open-in-new-tab": "새 탭에서 열기", + "desktop.bookmark.dialog.title-add": "북마크 추가", + "desktop.bookmark.dialog.title-edit": "북마크 편집", + "desktop.bookmark.dialog.upload-icon": "아이콘 업로드", + "desktop.bookmark.dialog.url-label": "URL", + "desktop.bookmark.dialog.url-placeholder": "https://example.com", + "desktop.bookmark.dialog.use-favicon": "URL에서 파비콘 사용", "desktop.context-menu.change-wallpaper": "배경화면 변경", "desktop.context-menu.edit-widgets": "위젯 편집", "desktop.context-menu.logout": "로그아웃", diff --git a/packages/ui/public/locales/nl.json b/packages/ui/public/locales/nl.json index d856990a9..3210c8a80 100644 --- a/packages/ui/public/locales/nl.json +++ b/packages/ui/public/locales/nl.json @@ -270,6 +270,20 @@ "desktop.app.context.settings": "Instellingen", "desktop.app.context.show-default-credentials": "Standaard inloggegevens tonen", "desktop.app.context.uninstall": "Deïnstalleren", + "desktop.bookmark.add": "Bladwijzer toevoegen", + "desktop.bookmark.context.delete": "Bladwijzer verwijderen", + "desktop.bookmark.context.edit": "Bladwijzer bewerken", + "desktop.bookmark.dialog.icon-label": "Pictogram", + "desktop.bookmark.dialog.icon-placeholder": "Uploaden of favicon gebruiken", + "desktop.bookmark.dialog.name-label": "Naam", + "desktop.bookmark.dialog.name-placeholder": "Mijn website", + "desktop.bookmark.dialog.open-in-new-tab": "Openen in nieuw tabblad", + "desktop.bookmark.dialog.title-add": "Bladwijzer toevoegen", + "desktop.bookmark.dialog.title-edit": "Bladwijzer bewerken", + "desktop.bookmark.dialog.upload-icon": "Pictogram uploaden", + "desktop.bookmark.dialog.url-label": "URL", + "desktop.bookmark.dialog.url-placeholder": "https://voorbeeld.nl", + "desktop.bookmark.dialog.use-favicon": "Favicon van URL gebruiken", "desktop.context-menu.change-wallpaper": "Achtergrond wijzigen", "desktop.context-menu.edit-widgets": "Widgets bewerken", "desktop.context-menu.logout": "Uitloggen", diff --git a/packages/ui/public/locales/pt.json b/packages/ui/public/locales/pt.json index b5ec43aa8..5f005e605 100644 --- a/packages/ui/public/locales/pt.json +++ b/packages/ui/public/locales/pt.json @@ -270,6 +270,20 @@ "desktop.app.context.settings": "Configurações", "desktop.app.context.show-default-credentials": "Mostrar credenciais padrão", "desktop.app.context.uninstall": "Desinstalar", + "desktop.bookmark.add": "Adicionar marcador", + "desktop.bookmark.context.delete": "Eliminar marcador", + "desktop.bookmark.context.edit": "Editar marcador", + "desktop.bookmark.dialog.icon-label": "Ícone", + "desktop.bookmark.dialog.icon-placeholder": "Carregar ou usar favicon", + "desktop.bookmark.dialog.name-label": "Nome", + "desktop.bookmark.dialog.name-placeholder": "Meu site", + "desktop.bookmark.dialog.open-in-new-tab": "Abrir em nova aba", + "desktop.bookmark.dialog.title-add": "Adicionar marcador", + "desktop.bookmark.dialog.title-edit": "Editar marcador", + "desktop.bookmark.dialog.upload-icon": "Carregar ícone", + "desktop.bookmark.dialog.url-label": "URL", + "desktop.bookmark.dialog.url-placeholder": "https://exemplo.com", + "desktop.bookmark.dialog.use-favicon": "Usar favicon do URL", "desktop.context-menu.change-wallpaper": "Mudar papel de parede", "desktop.context-menu.edit-widgets": "Editar widgets", "desktop.context-menu.logout": "Sair", diff --git a/packages/ui/public/locales/tr.json b/packages/ui/public/locales/tr.json index 949a32086..dad4728fa 100644 --- a/packages/ui/public/locales/tr.json +++ b/packages/ui/public/locales/tr.json @@ -270,6 +270,20 @@ "desktop.app.context.settings": "Ayarlar", "desktop.app.context.show-default-credentials": "Varsayılan kimlik bilgilerini göster", "desktop.app.context.uninstall": "Kaldır", + "desktop.bookmark.add": "Yer imi ekle", + "desktop.bookmark.context.delete": "Yer imini sil", + "desktop.bookmark.context.edit": "Yer imini düzenle", + "desktop.bookmark.dialog.icon-label": "Simge", + "desktop.bookmark.dialog.icon-placeholder": "Yükle veya favicon kullan", + "desktop.bookmark.dialog.name-label": "İsim", + "desktop.bookmark.dialog.name-placeholder": "Web sitem", + "desktop.bookmark.dialog.open-in-new-tab": "Yeni sekmede aç", + "desktop.bookmark.dialog.title-add": "Yer imi ekle", + "desktop.bookmark.dialog.title-edit": "Yer imini düzenle", + "desktop.bookmark.dialog.upload-icon": "Simge yükle", + "desktop.bookmark.dialog.url-label": "URL", + "desktop.bookmark.dialog.url-placeholder": "https://ornek.com", + "desktop.bookmark.dialog.use-favicon": "URL'den favicon kullan", "desktop.context-menu.change-wallpaper": "Duvar kağıdını değiştir", "desktop.context-menu.edit-widgets": "Widget'ları düzenle", "desktop.context-menu.logout": "Çıkış yap", diff --git a/packages/ui/public/locales/uk.json b/packages/ui/public/locales/uk.json index 6264a8d1a..e09eb9d79 100644 --- a/packages/ui/public/locales/uk.json +++ b/packages/ui/public/locales/uk.json @@ -270,9 +270,23 @@ "desktop.app.context.settings": "Налаштування", "desktop.app.context.show-default-credentials": "Показати облікові дані за замовчуванням", "desktop.app.context.uninstall": "Видалити", + "desktop.bookmark.add": "Додати закладку", + "desktop.bookmark.context.delete": "Видалити закладку", + "desktop.bookmark.context.edit": "Редагувати закладку", + "desktop.bookmark.dialog.icon-label": "Іконка", + "desktop.bookmark.dialog.icon-placeholder": "Завантажити або використати favicon", + "desktop.bookmark.dialog.name-label": "Назва", + "desktop.bookmark.dialog.name-placeholder": "Мій сайт", + "desktop.bookmark.dialog.open-in-new-tab": "Відкрити в новій вкладці", + "desktop.bookmark.dialog.title-add": "Додати закладку", + "desktop.bookmark.dialog.title-edit": "Редагувати закладку", + "desktop.bookmark.dialog.upload-icon": "Завантажити іконку", + "desktop.bookmark.dialog.url-label": "URL", + "desktop.bookmark.dialog.url-placeholder": "https://приклад.com", + "desktop.bookmark.dialog.use-favicon": "Використати favicon з URL", "desktop.context-menu.change-wallpaper": "Змінити фон", "desktop.context-menu.edit-widgets": "Редагувати віджети", - "desktop.context-menu.logout": "Вийти", + "desktop.context-menu.logout": "Війти", "desktop.greeting.afternoon": "Доброго дня, {{name}}", "desktop.greeting.evening": "Добрий вечір, {{name}}", "desktop.greeting.morning": "Доброго ранку, {{name}}", diff --git a/packages/ui/src/modules/desktop/bookmark-dialog.tsx b/packages/ui/src/modules/desktop/bookmark-dialog.tsx new file mode 100644 index 000000000..72faed2df --- /dev/null +++ b/packages/ui/src/modules/desktop/bookmark-dialog.tsx @@ -0,0 +1,312 @@ +import React, {useEffect, useState} from 'react' +import {useForm} from 'react-hook-form' + +import {FadeInImg} from '@/components/ui/fade-in-img' +import {useQueryParams} from '@/hooks/use-query-params' +import {Button} from '@/shadcn-components/ui/button' +import {Checkbox} from '@/shadcn-components/ui/checkbox' +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogPortal, + DialogTitle, +} from '@/shadcn-components/ui/dialog' +import {Input} from '@/shadcn-components/ui/input' +import {Labeled} from '@/shadcn-components/ui/input' +import {trpcReact} from '@/trpc/trpc' +import {useDialogOpenProps} from '@/utils/dialog' +import {t} from '@/utils/i18n' + +import {useIconBackground} from './use-icon-background' + +type BookmarkFormData = { + name: string + url: string + openInNewTab: boolean + customIcon?: string +} + +export type Bookmark = { + id: string + name: string + url: string + openInNewTab: boolean + icon?: string +} + +export function BookmarkDialog() { + const dialogKey = 'add-bookmark' + const {open, onOpenChange} = useDialogOpenProps(dialogKey) + const {params} = useQueryParams() + const utils = trpcReact.useUtils() + const bookmarksQuery = trpcReact.user.bookmarks.useQuery() + const [editingBookmark, setEditingBookmark] = useState(null) + const [hasManualName, setHasManualName] = useState(false) + const [hasManualIcon, setHasManualIcon] = useState(false) + + const addBookmarkMut = trpcReact.user.addBookmark.useMutation({ + onSuccess: () => { + utils.user.bookmarks.invalidate() + utils.user.get.invalidate() + onOpenChange(false) + reset() + }, + }) + + const updateBookmarkMut = trpcReact.user.updateBookmark.useMutation({ + onSuccess: () => { + utils.user.bookmarks.invalidate() + utils.user.get.invalidate() + onOpenChange(false) + reset() + setEditingBookmark(null) + }, + }) + + const {register, handleSubmit, reset, setValue, watch} = useForm({ + defaultValues: { + name: '', + url: '', + openInNewTab: false, + customIcon: '', + }, + }) + + const url = watch('url') + const customIcon = watch('customIcon') + const previewBackgroundColor = useIconBackground(customIcon) + + const updateFavicon = async (urlValue: string) => { + if (urlValue) { + try { + // Auto-add https:// for parsing if no protocol + let fullUrl = urlValue.trim() + if (!fullUrl.match(/^https?:\/\//i)) { + fullUrl = 'https://' + fullUrl + } + const urlObj = new URL(fullUrl) + const domain = urlObj.hostname + + // Fetch favicon through backend proxy to avoid CORS + const faviconDataUrl = await utils.user.getFavicon.fetch({domain}) + + // Load the image and check if it's valid (not a 404 placeholder) + // Only update if user hasn't uploaded a custom icon + if (!hasManualIcon) { + const img = new Image() + img.onload = () => { + // Google's 404 placeholder is 16x16, filter those out + if (img.naturalWidth > 16 && img.naturalHeight > 16) { + setValue('customIcon', faviconDataUrl) + } + } + img.onerror = () => { + // Failed to load, do nothing + } + img.src = faviconDataUrl + } + + // Fetch page title and update name (only if user hasn't manually entered a name) + if (!hasManualName) { + try { + const title = await utils.user.getPageTitle.fetch({url: fullUrl}) + // Don't set if title is "Error" or similar error messages + if (title && title !== 'Error' && !title.toLowerCase().includes('error')) { + setValue('name', title) + } + } catch { + // Failed to fetch title, ignore + } + } + } catch { + // Invalid URL, ignore + } + } + } + + const handleUrlKeyPress = (e: React.KeyboardEvent) => { + const target = e.target as HTMLInputElement + // Small delay to ensure the input value is updated + setTimeout(() => { + updateFavicon(target.value) + }, 10) + } + + // Handle loading bookmark for edit + useEffect(() => { + if (open) { + // Check if we're editing (params are prefixed with dialog key by linkToDialog) + const editId = params.get('add-bookmark-id') + if (editId && bookmarksQuery.data) { + // Find the bookmark to edit + const bookmark = bookmarksQuery.data.find((b: Bookmark) => b.id === editId) + if (bookmark) { + setEditingBookmark(bookmark) + // Use reset with values to properly set form state + reset({ + name: bookmark.name, + url: bookmark.url, + openInNewTab: bookmark.openInNewTab, + customIcon: bookmark.icon || '', + }) + // When editing, consider existing values as manual to prevent override + setHasManualName(!!bookmark.name) + setHasManualIcon(!!bookmark.icon) + } + } else if (!editId) { + // Not editing, reset form for new bookmark + reset({ + name: '', + url: '', + openInNewTab: false, + customIcon: '', + }) + setEditingBookmark(null) + setHasManualName(false) + setHasManualIcon(false) + } + } else { + reset() + setEditingBookmark(null) + setHasManualName(false) + setHasManualIcon(false) + } + }, [open, params, bookmarksQuery.data, reset]) + + const onSubmit = (data: BookmarkFormData) => { + // Auto-add https:// if no protocol specified + let url = data.url.trim() + if (!url.match(/^https?:\/\//i)) { + url = 'https://' + url + } + + const bookmark = { + id: editingBookmark?.id || `b${Date.now().toString(36)}`, + name: data.name, + url: url, + openInNewTab: data.openInNewTab, + icon: data.customIcon, + } + + if (editingBookmark) { + updateBookmarkMut.mutate(bookmark) + } else { + addBookmarkMut.mutate(bookmark) + } + } + + const handleIconUpload = (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (file && file.type.startsWith('image/')) { + const reader = new FileReader() + reader.onload = () => { + setValue('customIcon', reader.result as string) + setHasManualIcon(true) + } + reader.readAsDataURL(file) + } + // Reset the input so the same file can be selected again + e.target.value = '' + } + + const iconInputRef = React.useRef(null) + + const handleIconClick = () => { + iconInputRef.current?.click() + } + + const title = editingBookmark ? t('desktop.bookmark.dialog.title-edit') : t('desktop.bookmark.dialog.title-add') + + return ( + + + +
+
+ + {title} + +
+
+ + + + + { + if (e.target.value) { + setHasManualName(true) + } + }, + })} + placeholder={t('desktop.bookmark.dialog.name-placeholder')} + /> + +
+ setValue('openInNewTab', !!checked)} + /> + +
+
+
+ + +
+
+ + + + +
+
+
+
+
+ ) +} diff --git a/packages/ui/src/modules/desktop/bookmark-icon.tsx b/packages/ui/src/modules/desktop/bookmark-icon.tsx new file mode 100644 index 000000000..71bf01954 --- /dev/null +++ b/packages/ui/src/modules/desktop/bookmark-icon.tsx @@ -0,0 +1,112 @@ +import {motion} from 'framer-motion' +import {useState} from 'react' +import {RiExternalLinkLine} from 'react-icons/ri' +import {Link} from 'react-router-dom' + +import {FadeInImg} from '@/components/ui/fade-in-img' +import {ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger} from '@/shadcn-components/ui/context-menu' +import {contextMenuClasses} from '@/shadcn-components/ui/shared/menu' +import {cn} from '@/shadcn-lib/utils' +import {trpcReact} from '@/trpc/trpc' +import {useLinkToDialog} from '@/utils/dialog' +import {t} from '@/utils/i18n' + +import {APP_ICON_PLACEHOLDER_SRC} from './app-icon' +import {Bookmark} from './bookmark-dialog' +import {useIconBackground} from './use-icon-background' + +export function BookmarkIcon({bookmark}: {bookmark: Bookmark}) { + const [iconSrc, setIconSrc] = useState(bookmark.icon || APP_ICON_PLACEHOLDER_SRC) + const utils = trpcReact.useUtils() + const linkToDialog = useLinkToDialog() + const backgroundColor = useIconBackground(iconSrc) + + const deleteBookmarkMut = trpcReact.user.deleteBookmark.useMutation({ + onSuccess: () => { + utils.user.bookmarks.invalidate() + utils.user.get.invalidate() + }, + }) + + const handleClick = () => { + if (bookmark.openInNewTab) { + window.open(bookmark.url, '_blank', 'noopener,noreferrer') + } else { + window.location.href = bookmark.url + } + } + + const handleDelete = () => { + if (confirm(t('desktop.bookmark.context.delete') + '?')) { + deleteBookmarkMut.mutate({id: bookmark.id}) + } + } + + return ( + + + +
+ {iconSrc && ( + <> + {/* Background fill for images with uniform edges */} + {backgroundColor &&
} + setIconSrc(APP_ICON_PLACEHOLDER_SRC)} + className='relative z-10 h-full w-full animate-in fade-in duration-500' + draggable={false} + /> + + )} + {/* External link indicator */} +
+ +
+
+
+
+ {bookmark.name} +
+
+ + + + + + {t('desktop.bookmark.context.edit')} + + + + {t('desktop.bookmark.context.delete')} + + + + ) +} diff --git a/packages/ui/src/modules/desktop/desktop-content.tsx b/packages/ui/src/modules/desktop/desktop-content.tsx index 6fabca4e0..7aaee0521 100644 --- a/packages/ui/src/modules/desktop/desktop-content.tsx +++ b/packages/ui/src/modules/desktop/desktop-content.tsx @@ -10,6 +10,7 @@ import {trpcReact} from '@/trpc/trpc' import {AppGrid} from './app-grid/app-grid' import {AppIconConnected, AppLabel} from './app-icon' +import {BookmarkIcon} from './bookmark-icon' import {Search} from './desktop-misc' import {DockSpacer} from './dock' import {Header} from './header' @@ -22,6 +23,8 @@ export function DesktopContent({onSearchClick}: {onSearchClick?: () => void}) { const {userApps, isLoading} = useApps() const widgets = useWidgets() + const bookmarksQuery = trpcReact.user.bookmarks.useQuery() + const bookmarks = bookmarksQuery.data || [] if (isLoading || widgets.isLoading) return null if (!userApps) return null @@ -103,32 +106,59 @@ export function DesktopContent({onSearchClick}: {onSearchClick?: () => void}) { ))} - apps={userApps.map((app, i) => ( - - - - ))} + apps={[ + ...userApps.map((app, i) => ( + + + + )), + ...bookmarks.map((bookmark: any, i: number) => ( + + + + )), + ]} /> diff --git a/packages/ui/src/modules/desktop/desktop-context-menu.tsx b/packages/ui/src/modules/desktop/desktop-context-menu.tsx index 4d6f2c7d1..03d9dc335 100644 --- a/packages/ui/src/modules/desktop/desktop-context-menu.tsx +++ b/packages/ui/src/modules/desktop/desktop-context-menu.tsx @@ -25,6 +25,9 @@ export function DesktopContextMenu({children}: {children: React.ReactNode}) { {t('desktop.context-menu.edit-widgets')} + + {t('desktop.bookmark.add')} + { // get bounding box diff --git a/packages/ui/src/modules/desktop/dock.tsx b/packages/ui/src/modules/desktop/dock.tsx index bcfda6d47..a89a7b5b1 100644 --- a/packages/ui/src/modules/desktop/dock.tsx +++ b/packages/ui/src/modules/desktop/dock.tsx @@ -10,6 +10,7 @@ import {systemAppsKeyed} from '@/providers/apps' import {cn} from '@/shadcn-lib/utils' import {tw} from '@/utils/tw' +import {BookmarkDialog} from './bookmark-dialog' import {DockItem} from './dock-item' import {LogoutDialog} from './logout-dialog' @@ -138,13 +139,14 @@ export function Dock() { bg={systemAppsKeyed['UMBREL_widgets'].icon} mouseX={mouseX} /> - - - - - - - ) + + + + + + + +) } export function DockPreview() { diff --git a/packages/ui/src/modules/desktop/use-icon-background.ts b/packages/ui/src/modules/desktop/use-icon-background.ts new file mode 100644 index 000000000..b2f350262 --- /dev/null +++ b/packages/ui/src/modules/desktop/use-icon-background.ts @@ -0,0 +1,90 @@ +import {useEffect, useState} from 'react' + +export function useIconBackground(iconSrc: string) { + const [backgroundColor, setBackgroundColor] = useState(null) + + useEffect(() => { + if (!iconSrc) { + setBackgroundColor(null) + return + } + + const img = new Image() + img.crossOrigin = 'anonymous' + + img.onload = () => { + try { + const canvas = document.createElement('canvas') + const ctx = canvas.getContext('2d') + if (!ctx) return + + canvas.width = img.width + canvas.height = img.height + ctx.drawImage(img, 0, 0) + + // Sample pixels from the edges + const edgePixels: {r: number; g: number; b: number; a: number}[] = [] + + // Sample top edge + for (let x = 0; x < img.width; x += Math.max(1, Math.floor(img.width / 20))) { + const pixel = ctx.getImageData(x, 0, 1, 1).data + edgePixels.push({r: pixel[0], g: pixel[1], b: pixel[2], a: pixel[3]}) + } + + // Sample bottom edge + for (let x = 0; x < img.width; x += Math.max(1, Math.floor(img.width / 20))) { + const pixel = ctx.getImageData(x, img.height - 1, 1, 1).data + edgePixels.push({r: pixel[0], g: pixel[1], b: pixel[2], a: pixel[3]}) + } + + // Sample left edge + for (let y = 0; y < img.height; y += Math.max(1, Math.floor(img.height / 20))) { + const pixel = ctx.getImageData(0, y, 1, 1).data + edgePixels.push({r: pixel[0], g: pixel[1], b: pixel[2], a: pixel[3]}) + } + + // Sample right edge + for (let y = 0; y < img.height; y += Math.max(1, Math.floor(img.height / 20))) { + const pixel = ctx.getImageData(img.width - 1, y, 1, 1).data + edgePixels.push({r: pixel[0], g: pixel[1], b: pixel[2], a: pixel[3]}) + } + + // Check if all edges are the same color (within tolerance) + const firstPixel = edgePixels[0] + const tolerance = 10 // Allow small variations + + const allSame = edgePixels.every( + (p) => + Math.abs(p.r - firstPixel.r) <= tolerance && + Math.abs(p.g - firstPixel.g) <= tolerance && + Math.abs(p.b - firstPixel.b) <= tolerance && + Math.abs(p.a - firstPixel.a) <= tolerance, + ) + + if (allSame) { + // If transparent edges, use white + if (firstPixel.a < 128) { + setBackgroundColor('rgb(255, 255, 255)') + } else { + // Use the detected edge color + setBackgroundColor(`rgb(${firstPixel.r}, ${firstPixel.g}, ${firstPixel.b})`) + } + } else { + // Edges are different colors, don't add background + setBackgroundColor(null) + } + } catch (error) { + // CORS or other error, don't add background + setBackgroundColor(null) + } + } + + img.onerror = () => { + setBackgroundColor(null) + } + + img.src = iconSrc + }, [iconSrc]) + + return backgroundColor +} diff --git a/packages/umbreld/source/modules/server/index.ts b/packages/umbreld/source/modules/server/index.ts index 62fcaebec..db95aff5d 100644 --- a/packages/umbreld/source/modules/server/index.ts +++ b/packages/umbreld/source/modules/server/index.ts @@ -129,7 +129,8 @@ class Server { scriptSrc: this.umbreld.developmentMode ? ["'self'", "'unsafe-inline'"] : null, // Allow 3rd party app images (remove this if we serve them locally in the future) // Also allow blob: URLs for images being uploaded in Files (since their thumbnails don't exist yet) - imgSrc: ['*', 'blob:'], + // Also allow data: URLs for base64-encoded images (e.g., bookmark favicons) + imgSrc: ['*', 'blob:', 'data:'], // Allow fetching data from our apps API (e.g., for Discover page in App Store) connectSrc: ["'self'", 'https://apps.umbrel.com'], // Allow plain text access over the local network diff --git a/packages/umbreld/source/modules/user/routes.ts b/packages/umbreld/source/modules/user/routes.ts index 86500cbcf..aaab5089f 100644 --- a/packages/umbreld/source/modules/user/routes.ts +++ b/packages/umbreld/source/modules/user/routes.ts @@ -191,6 +191,7 @@ export default router({ wallpaper: user.wallpaper, language: user.language, temperatureUnit: user.temperatureUnit, + bookmarks: user.bookmarks || [], } }), @@ -228,4 +229,128 @@ export default router({ const user = await ctx.user.get() return user?.language ?? null }), + + // Get user bookmarks + bookmarks: privateProcedure.query(async ({ctx}) => { + return ctx.user.getBookmarks() + }), + + // Add a bookmark + addBookmark: privateProcedure + .input( + z.object({ + id: z.string(), + name: z.string(), + url: z.string().url(), + openInNewTab: z.boolean(), + icon: z.string().optional(), + }), + ) + .mutation(async ({ctx, input}) => { + const bookmarks = await ctx.user.getBookmarks() + bookmarks.push(input) + await ctx.user.setBookmarks(bookmarks) + return true + }), + + // Update a bookmark + updateBookmark: privateProcedure + .input( + z.object({ + id: z.string(), + name: z.string(), + url: z.string().url(), + openInNewTab: z.boolean(), + icon: z.string().optional(), + }), + ) + .mutation(async ({ctx, input}) => { + const bookmarks = await ctx.user.getBookmarks() + const index = bookmarks.findIndex((b: any) => b.id === input.id) + if (index === -1) { + throw new TRPCError({code: 'NOT_FOUND', message: 'Bookmark not found'}) + } + bookmarks[index] = input + await ctx.user.setBookmarks(bookmarks) + return true + }), + + // Delete a bookmark + deleteBookmark: privateProcedure.input(z.object({id: z.string()})).mutation(async ({ctx, input}) => { + const bookmarks = await ctx.user.getBookmarks() + const filtered = bookmarks.filter((b: any) => b.id !== input.id) + await ctx.user.setBookmarks(filtered) + return true + }), + + // Proxy favicon request to avoid CORS issues + getFavicon: privateProcedure.input(z.object({domain: z.string()})).query(async ({input}) => { + try { + const url = `https://www.google.com/s2/favicons?domain=${encodeURIComponent(input.domain)}&sz=128` + const response = await fetch(url) + const buffer = await response.arrayBuffer() + const base64 = Buffer.from(buffer).toString('base64') + const contentType = response.headers.get('content-type') || 'image/png' + return `data:${contentType};base64,${base64}` + } catch (error) { + throw new TRPCError({code: 'INTERNAL_SERVER_ERROR', message: 'Failed to fetch favicon'}) + } + }), + + // Fetch page title from URL + getPageTitle: privateProcedure.input(z.object({url: z.string().url()})).query(async ({input}) => { + try { + const url = new URL(input.url) + + // Prevent fetching local/internal URLs + const hostname = url.hostname.toLowerCase() + if ( + hostname === 'localhost' || + hostname === '127.0.0.1' || + hostname === '::1' || + hostname.endsWith('.local') || + hostname.startsWith('192.168.') || + hostname.startsWith('10.') || + hostname.startsWith('172.16.') || + hostname.startsWith('172.17.') || + hostname.startsWith('172.18.') || + hostname.startsWith('172.19.') || + hostname.startsWith('172.20.') || + hostname.startsWith('172.21.') || + hostname.startsWith('172.22.') || + hostname.startsWith('172.23.') || + hostname.startsWith('172.24.') || + hostname.startsWith('172.25.') || + hostname.startsWith('172.26.') || + hostname.startsWith('172.27.') || + hostname.startsWith('172.28.') || + hostname.startsWith('172.29.') || + hostname.startsWith('172.30.') || + hostname.startsWith('172.31.') + ) { + return null + } + + const response = await fetch(input.url, { + method: 'GET', + redirect: 'follow', + headers: { + 'User-Agent': 'Mozilla/5.0 (compatible; UmbrelOS/1.0)', + }, + }) + const html = await response.text() + + // Extract title from HTML using regex + const titleMatch = html.match(/]*>([^<]+)<\/title>/i) + if (titleMatch && titleMatch[1]) { + // Decode HTML entities and trim + return titleMatch[1].trim() + } + + return null + } catch (error) { + // Return null instead of throwing to handle errors gracefully + return null + } + }), }) diff --git a/packages/umbreld/source/modules/user/user.ts b/packages/umbreld/source/modules/user/user.ts index 72fde800e..6015bf52b 100644 --- a/packages/umbreld/source/modules/user/user.ts +++ b/packages/umbreld/source/modules/user/user.ts @@ -149,4 +149,14 @@ export default class User { async setTemperatureUnit(temperatureUnit: string) { return this.#store.set('user.temperatureUnit', temperatureUnit) } + + // Get user bookmarks + async getBookmarks() { + return (await this.#store.get('user.bookmarks')) || [] + } + + // Set user bookmarks + async setBookmarks(bookmarks: any[]) { + return this.#store.set('user.bookmarks', bookmarks) + } }