@@ -1,4 +1,4 @@
import { useMemo , useRef , useState } from "react" ;
import { useMemo , useRef , useState , type PointerEvent as ReactPointerEvent } from "react" ;
import { Link } from "@tanstack/react-router" ;
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" ;
import {
@@ -7,22 +7,31 @@ import {
faXmark ,
faClockRotateLeft ,
faBookmark ,
faPen ,
} from "@fortawesome/free-solid-svg-icons" ;
import type { Category , Service , Bookmark , SearchResult } from "@launchpad/shared" ;
import { ResultsList , Button } from "@launchpad/ui" ;
import { useHomeLayout , type HomeSlot , type ItemRef } from "../hooks/useHomeLayout.js" ;
/**
* Android-Startbildschirm-artiges alternatives Design für die mobile Ansicht
* (siehe Einstellungen -> "Design"). Bewusst als eigenständige Komponente
* statt Umbau der bestehenden HomePage: beide Designs bleiben unabhängig
* wart- und weiterentwickelbar, HomePage.tsx entscheidet nur, welche der
* beiden gerendert wird (siehe dort).
* (siehe Einstellungen -> "Design"). Zeigt AUSSCHLIESSLICH als Favorit
* markierte Dienste/Lesezeichen (siehe Bugreport round65: vorher wurden auch
* alle Kategorien automatisch als Ordner gezeigt - das war zu viel/nicht
* kuratiert genug). Die Anordnung (Reihenfolge, eigene Ordner, Aufteilung
* Hauptbereich/Dock) legt der Nutzer selbst per Ziehen fest, genau wie auf
* einem echten Android-Handy - siehe useHomeLayout.ts für die
* Datenstruktur/Persistierung und die Drag-Logik weiter unten in dieser Datei.
*
* Signatur-Element: der "Wallpaper"-Hintergrund besteht NICHT aus einem
* generisc hen Farbverlauf, sondern aus den tatsächlichen, in den eigenen
* Kategorien hinterlegten Farben (Admin -> Kategorien) - jede Installation
* bekommt dadurch ein optisch anderes, zum eigenen Homelab passendes
* Hintergrundbild, ganz ohne eigene Bilder hochladen zu müssen.
* WICHTIG zur Interaktion: klassisches HTML5-Drag&Drop (draggable=true,
* onDragStart/onDrop, sie he FavoritesBar.tsx an anderer Stelle im Projekt)
* feuert auf Touchscreens NICHT zuverlässig - für eine mobile Oberfläche
* daher bewusst mit der Pointer-Events-API nachgebaut (vereinheitlicht
* Maus/Touch/Stift), inklusive Lang-Drücken zum Start (350ms), damit
* normales Scrollen nicht versehentlich als Ziehen interpretiert wird.
*
* Signatur-Element: der Wallpaper-Hintergrund besteht aus den tatsächlichen,
* in den eigenen Kategorien hinterlegten Farben (Admin -> Kategorien).
*/
const FALLBACK_PALETTE = [ "#6366f1" , "#a855f7" , "#06b6d4" , "#f97316" ] ;
@@ -32,32 +41,97 @@ const BLOB_POSITIONS = [
{ top : "70%" , left : "-20%" } ,
{ top : "-15%" , left : "55%" } ,
] ;
const LONG_PRESS_MS = 350 ;
const MOVE_CANCEL_PX = 10 ;
function resolveIconSrc ( src : string ) : string {
if ( src . startsWith ( "data:" ) ) return src ;
return ` /api/favicon-proxy?url= ${ encodeURIComponent ( src ) } ` ;
}
interface TileIconProps {
favicon? : string | null ;
label : string ;
color? : string | null ;
size ? : "normal" | "sma ll" ;
interface ResolvedItem {
id : string ;
type : "service" | "bookmark" ;
displayName : string ;
favicon : string | nu ll;
url : string ;
category : string | null ;
}
/** Ein einzelnes "App-Icon" im Android-Stil: farbiges abgerundetes Quadrat (per Kategorie-Farbe getönt), Favicon mittig. */
function TileIcon ( { favicon , label , color , size = "normal" } : TileIconProps ) {
function slotId ( ) : string {
return ` slot- ${ Math . random ( ) . toString ( 36 ) . slice ( 2 , 10 ) } - ${ Date . now ( ) . toString ( 36 ) } ` ;
}
/**
* Reine Funktion, die aus dem Ist-Zustand (Layout) + der abgeschlossenen
* Ziehgeste das NEUE Layout berechnet - bewusst ohne React-State-Zugriff,
* damit sie unabhängig testbar/nachvollziehbar bleibt.
*
* Regeln (angelehnt an Android):
* - Item auf Item gezogen -> neuer Ordner mit beiden.
* - Item auf Ordner gezogen -> tritt dem Ordner bei.
* - Ordner auf irgendwas gezogen (oder Item auf leeren Bereich) -> reine
* Neusortierung, kein Verschmelzen (keine verschachtelten Ordner).
* - Dock hat eine feste Obergrenze (siehe maxDockSlots) - ein Ablegen dort,
* das die Grenze sprengen würde, wird verworfen (Layout bleibt
* unverändert, wie ein Android-Dock, das "voll" ist).
*/
function applyDragResult (
layout : { grid : HomeSlot [ ] ; dock : HomeSlot [ ] } ,
dragged : HomeSlot ,
hover : { area : "grid" | "dock" ; slotId : string } | null ,
maxDockSlots : number
) : { grid : HomeSlot [ ] ; dock : HomeSlot [ ] } | null {
if ( ! hover ) return null ;
const grid = layout . grid . filter ( ( s ) = > s . id !== dragged . id ) ;
const dock = layout . dock . filter ( ( s ) = > s . id !== dragged . id ) ;
const targetArr = hover . area === "grid" ? grid : dock ;
if ( hover . slotId === "__end__" ) {
if ( hover . area === "dock" && dock . length >= maxDockSlots ) return null ;
targetArr . push ( dragged ) ;
return { grid , dock } ;
}
const targetIndex = targetArr . findIndex ( ( s ) = > s . id === hover . slotId ) ;
if ( targetIndex === - 1 ) return null ;
const targetSlot = targetArr [ targetIndex ] ;
if ( dragged . kind === "item" && targetSlot . kind === "item" ) {
targetArr . splice ( targetIndex , 1 , {
id : slotId ( ) ,
kind : "folder" ,
name : "Neuer Ordner" ,
items : [ targetSlot . ref , dragged . ref ] ,
} ) ;
} else if ( dragged . kind === "item" && targetSlot . kind === "folder" ) {
targetArr . splice ( targetIndex , 1 , { . . . targetSlot , items : [ . . . targetSlot . items , dragged . ref ] } ) ;
} else {
if ( hover . area === "dock" && dock . length >= maxDockSlots ) return null ;
targetArr . splice ( targetIndex , 0 , dragged ) ;
}
return { grid , dock } ;
}
interface TileVisualProps {
favicon? : string | null ;
label : string ;
size ? : "normal" | "small" ;
dimmed? : boolean ;
}
function TileIcon ( { favicon , label , size = "normal" , dimmed } : TileVisualProps ) {
const [ failed , setFailed ] = useState ( false ) ;
const dimension = size === "small" ? "h-9 w-9" : "h-14 w-14" ;
const rounding = size === "small" ? "rounded-xl" : "rounded-[20px]" ;
const backdrop = color ? ` ${ color } 33 ` : undefined ;
return (
< span
className = { ` flex ${ dimension } ${ rounding } shrink-0 items-center justify-center
shadow-[0_2px_10px_rgba(0,0,0,0.18)] ring-1 ring-white/40 backdrop-blur-sm
dark:ring-white/10 ` }
style = { { background : backdrop ? ? "rgba(255,255,255,0.75)" } }
className = { ` flex ${ dimension } ${ rounding } shrink-0 items-center justify-center bg-white/75
shadow-[0_2px_10px_rgba(0,0,0,0.18)] ring-1 ring-white/40 backdrop-blur-sm transition-opacity
dark:bg-white/10 dark:ring-white/10 ${ dimmed ? "opacity-30" : "" } ` }
>
{ favicon && ! failed ? (
< img
@@ -67,7 +141,7 @@ function TileIcon({ favicon, label, color, size = "normal" }: TileIconProps) {
onError = { ( ) = > setFailed ( true ) }
/ >
) : (
< span className = { ` font-semibold text-black/70 ${ size === "small" ? "text-xs" : "text-lg" } ` } >
< span className = { ` font-semibold text-black/70 dark:text-white/70 ${ size === "small" ? "text-xs" : "text-lg" } ` } >
{ label . charAt ( 0 ) . toUpperCase ( ) }
< / span >
) }
@@ -75,19 +149,12 @@ function TileIcon({ favicon, label, color, size = "normal" }: TileIconProps) {
) ;
}
/** Ordner-Kachel: Mini-2x2-Vorschau der ersten 4 enthaltenen Icons statt eines einzelnen Favicons. */
function FolderTile ( {
icons ,
color ,
} : {
icons : Array < { favicon : string | null ; label : string } > ;
color : string | null ;
} ) {
function FolderTile ( { icons , dimmed } : { icons : Array < { favicon : string | null ; label : string } > ; dimmed? : boolean } ) {
return (
< span
className = " grid h-14 w-14 shrink-0 grid-cols-2 grid-rows-2 gap-1 rounded-[20px] p-1.5
shadow-[0_2px_10px_rgba(0,0,0,0.18)] ring-1 ring-white/40 backdrop-blur-sm dark:ring-white/10"
style = { { background : color ? ` ${ color } 33 ` : "rgba(255,255,255,0.6)" } }
className = { ` grid h-14 w-14 shrink-0 grid-cols-2 grid-rows-2 gap-1 rounded-[20px] bg-white/60 p-1.5
shadow-[0_2px_10px_rgba(0,0,0,0.18)] ring-1 ring-white/40 backdrop-blur-sm transition-opacity
dark:bg-white/10 dark:ring-white/10 ${ dimmed ? "opacity-30" : "" } ` }
>
{ Array . from ( { length : 4 } ) . map ( ( _ , i ) = > {
const item = icons [ i ] ;
@@ -123,7 +190,6 @@ export interface MobileAndroidHomeProps {
bookmarks : Bookmark [ ] ;
categories : Category [ ] ;
categoryColors : Record < string , string > ;
folderMode : "folders" | "scroll" ;
allSearchItems : SearchResult [ ] ;
recentVisits : MinimalVisitItem [ ] ;
readLaterItems : Array < { id : string ; url : string ; displayName : string ; favicon : string | null } > ;
@@ -138,7 +204,6 @@ export function MobileAndroidHome({
bookmarks ,
categories ,
categoryColors ,
folderMode ,
allSearchItems ,
recentVisits ,
readLaterItems ,
@@ -150,56 +215,142 @@ export function MobileAndroidHome({
const [ searchOpen , setSearchOpen ] = useState ( false ) ;
const [ query , setQuery ] = useState ( "" ) ;
const [ selectedIndex , setSelectedIndex ] = useState ( 0 ) ;
const [ openFolder , setOpenFolder ] = useState < string | null > ( null ) ;
const [ openFolderId , setOpenFolderId ] = useState < string | null > ( null ) ;
const [ readLaterUrl , setReadLaterUrl ] = useState ( "" ) ;
const [ renamingFolder , setRenamingFolder ] = useState ( false ) ;
const [ folderNameDraft , setFolderNameDraft ] = useState ( "" ) ;
const searchInputRef = useRef < HTMLInputElement > ( null ) ;
const palette = useMemo ( ( ) = > {
const fromCategories = ( categories ? ? [ ] )
. map ( ( c ) = > c . color )
. filter ( ( c ) : c is string = > Boolean ( c ) ) ;
const fromCategories = ( categories ? ? [ ] ) . map ( ( c ) = > c . color ) . filter ( ( c ) : c is string = > Boolean ( c ) ) ;
const unique = Array . from ( new Set ( fromCategories ) ) ;
return unique . length > 0 ? unique . slice ( 0 , 4 ) : FALLBACK_PALETTE ;
} , [ categories ] ) ;
const visibleS ervices = useMemo ( ( ) = > services . filter ( ( s ) = > s . visible ) , [ services ] ) ;
const s erviceById = useMemo ( ( ) = > new Map ( services . map ( ( s ) = > [ s . id , s ] ) ) , [ services ] ) ;
const bookmarkById = useMemo ( ( ) = > new Map ( bookmarks . map ( ( b ) = > [ b . id , b ] ) ) , [ bookmarks ] ) ;
const grouped = useMemo ( ( ) = > {
const byCategory = new Map < string , Array < Service | Bookmark > > ( ) ;
const ungrouped : Array < Service | Bookmark > = [ ] ;
for ( const item of [ . . . visibleServices , . . . bookmarks ] ) {
if ( item . category ) {
const list = byCategory . get ( item . category ) ;
if ( list ) list . push ( item ) ;
else byCategory . set ( item . category , [ item ] ) ;
} else {
ungrouped . push ( item ) ;
}
const resolveRef = ( ref : ItemRef ) : ResolvedItem | null = > {
if ( ref . type === "service" ) {
const s = serviceById . get ( ref . id ) ;
if ( ! s || ! s . visible ) return null ;
return { id : s.id , type : "service" , displayName : s.displayName , favicon : s.favicon , url : s.url , category : s.category } ;
}
return { byCategory , ungrouped } ;
} , [ visibleServices , bookmarks ] ) ;
const b = bookmarkById . get ( ref . id ) ;
if ( ! b ) return null ;
return { id : b.id , type : "bookmark" , displayName : b.displayName , favicon : b.favicon , url : b.url , category : b.category } ;
} ;
const favorites = useMemo (
( ) = > [ . . . visibleServices , . . . bookmarks ] . filter ( ( i ) = > i . favorite ) . slice ( 0 , 6 ) ,
[ visibleServices , bookmarks ]
const favoriteRefs : ItemRef [ ] = useMemo (
( ) = > [
. . . services . filter ( ( s ) = > s . visible && s . favorite ) . map ( ( s ) = > ( { id : s.id , type : "service" as const } ) ) ,
. . . bookmarks . filter ( ( b ) = > b . favorite ) . map ( ( b ) = > ( { id : b.id , type : "bookmark" as const } ) ) ,
] ,
[ services , bookmarks ]
) ;
const itemToOpenTarget = ( item : Service | Bookmark ) : OpenTarget = > ( {
url : item.url ,
id : item.id ,
kind : "deviceId" in item ? "service" : "bookmark" ,
} ) ;
const { layout , saveLayout , maxDockSlots } = useHomeLayout ( favoriteRefs ) ;
// --- Ziehen (Pointer Events, siehe Kommentar oben an der Datei) ---
const [ drag , setDrag ] = useState < {
slot : HomeSlot ;
pointerId : number ;
x : number ;
y : number ;
offsetX : number ;
offsetY : number ;
} | null > ( null ) ;
const [ hover , setHover ] = useState < { area : "grid" | "dock" ; slotId : string } | null > ( null ) ;
const longPressTimer = useRef < number | null > ( null ) ;
const pressStart = useRef < { x : number ; y : number } | null > ( null ) ;
const suppressNextClick = useRef ( false ) ;
function cancelPendingPress() {
if ( longPressTimer . current !== null ) {
window . clearTimeout ( longPressTimer . current ) ;
longPressTimer . current = null ;
}
pressStart . current = null ;
}
function handlePointerDown ( e : ReactPointerEvent < HTMLButtonElement > , slot : HomeSlot ) {
if ( e . pointerType === "mouse" && e . button !== 0 ) return ;
pressStart . current = { x : e.clientX , y : e.clientY } ;
const target = e . currentTarget ;
const pointerId = e . pointerId ;
const clientX = e . clientX ;
const clientY = e . clientY ;
longPressTimer . current = window . setTimeout ( ( ) = > {
target . setPointerCapture ( pointerId ) ;
const rect = target . getBoundingClientRect ( ) ;
setDrag ( {
slot ,
pointerId ,
x : clientX ,
y : clientY ,
offsetX : clientX - rect . left ,
offsetY : clientY - rect . top ,
} ) ;
} , LONG_PRESS_MS ) ;
}
function handlePointerMove ( e : ReactPointerEvent < HTMLButtonElement > ) {
if ( ! drag ) {
if ( pressStart . current && longPressTimer . current !== null ) {
const dx = e . clientX - pressStart . current . x ;
const dy = e . clientY - pressStart . current . y ;
if ( Math . hypot ( dx , dy ) > MOVE_CANCEL_PX ) cancelPendingPress ( ) ;
}
return ;
}
if ( e . pointerId !== drag . pointerId ) return ;
setDrag ( ( d ) = > ( d ? { . . . d , x : e.clientX , y : e.clientY } : d ) ) ;
const el = document . elementFromPoint ( e . clientX , e . clientY ) ;
const slotEl = el ? . closest ( "[data-slot-id]" ) as HTMLElement | null ;
const areaEl = el ? . closest ( "[data-drop-area]" ) as HTMLElement | null ;
if ( slotEl && slotEl . dataset . slotId && slotEl . dataset . slotId !== drag . slot . id ) {
setHover ( { area : ( slotEl . dataset . area as "grid" | "dock" ) ? ? "grid" , slotId : slotEl.dataset.slotId } ) ;
} else if ( areaEl ? . dataset . dropArea ) {
setHover ( { area : areaEl.dataset.dropArea as "grid" | "dock" , slotId : "__end__" } ) ;
} else {
setHover ( null ) ;
}
}
function handlePointerUp ( e : ReactPointerEvent < HTMLButtonElement > ) {
cancelPendingPress ( ) ;
if ( ! drag || e . pointerId !== drag . pointerId ) return ;
const next = applyDragResult ( layout , drag . slot , hover , maxDockSlots ) ;
if ( next ) {
saveLayout ( next ) ;
suppressNextClick . current = true ;
}
setDrag ( null ) ;
setHover ( null ) ;
}
function handlePointerCancel() {
cancelPendingPress ( ) ;
setDrag ( null ) ;
setHover ( null ) ;
}
function handleTileClick ( action : ( ) = > void ) {
if ( suppressNextClick . current ) {
suppressNextClick . current = false ;
return ;
}
if ( drag ) return ;
action ( ) ;
}
const searchResults = useMemo ( ( ) = > {
if ( query . trim ( ) . length === 0 ) return [ ] ;
const q = query . toLowerCase ( ) ;
return allSearchItems
. filter ( ( i ) = > i . kind !== "device" )
. filter (
( i ) = >
i . displayName . toLowerCase ( ) . includes ( q ) ||
i . alias ? . some ( ( a ) = > a . toLowerCase ( ) . includes ( q ) )
)
. filter ( ( i ) = > i . displayName . toLowerCase ( ) . includes ( q ) || i . alias ? . some ( ( a ) = > a . toLowerCase ( ) . includes ( q ) ) )
. slice ( 0 , 30 ) ;
} , [ query , allSearchItems ] ) ;
@@ -208,32 +359,92 @@ export function MobileAndroidHome({
setQuery ( "" ) ;
} ;
const toMinimalItem = ( item : Service | Bookmark ) : MinimalVisitItem = > ( {
id : item.id ,
url : item.url ,
displayName : item.displayName ,
favicon : item.favicon ,
kind : "deviceId" in item ? "service" : "bookmark" ,
} ) ;
const openFolder =
openFolderId && openFolderId !== "__recent__" && openFolderId !== "__readlater__"
? ( [ . . . layout . grid , . . . layout . dock ] . find ( ( s ) = > s . id === openFolderId && s . kind === "folder" ) as
| Extract < HomeSlot , { kind : "folder" } >
| undefined )
: null ;
const openFolderData =
openFolder === "__recent__"
? { title : "Zuletzt besucht" , items : recentVisits }
: openFolder === "__readlater__"
? { title : "Später lesen" , items : null }
: openFolder
? { title : openFolder , items : ( grouped . byCategory . get ( openFolder ) ? ? [ ] ) . map ( toMinimalItem ) }
: null ;
function removeFromFolder ( folder : Extract < HomeSlot , { kind : "folder" } > , ref : ItemRef ) {
const items = folder . items . filter ( ( i ) = > ! ( i . id === ref . id && i . type === ref . type ) ) ;
const patch = ( slots : HomeSlot [ ] ) = >
slots
. map ( ( s ) = > ( s . id === folder . id ? ( items . length > 0 ? { . . . s , items } : null ) : s ) )
. filter ( ( s ) : s is HomeSlot = > s !== null ) ;
saveLayout ( { grid : patch ( layout . grid ) , dock : patch ( layout . dock ) } ) ;
if ( items . length === 0 ) setOpenFolderId ( null ) ;
}
function renameFolder ( folder : Extract < HomeSlot , { kind : "folder" } > , name : string ) {
const patch = ( slots : HomeSlot [ ] ) = > slots . map ( ( s ) = > ( s . id === folder . id ? { . . . s , name } : s ) ) ;
saveLayout ( { grid : patch ( layout . grid ) , dock : patch ( layout . dock ) } ) ;
}
function renderTile ( slot : HomeSlot , area : "grid" | "dock" , size : "normal" | "small" = "normal" ) {
const isDragging = drag ? . slot . id === slot . id ;
const isHoverTarget = hover ? . slotId === slot . id ;
if ( slot . kind === "folder" ) {
const icons = slot . items
. map ( ( ref ) = > resolveRef ( ref ) )
. filter ( ( i ) : i is ResolvedItem = > i !== null )
. slice ( 0 , 4 )
. map ( ( i ) = > ( { favicon : i.favicon , label : i.displayName } ) ) ;
return (
< button
key = { slot . id }
type = "button"
data-slot-id = { slot . id }
data-area = { area }
onPointerDown = { ( e ) = > handlePointerDown ( e , slot ) }
onPointerMove = { handlePointerMove }
onPointerUp = { handlePointerUp }
onPointerCancel = { handlePointerCancel }
onClick = { ( ) = > handleTileClick ( ( ) = > setOpenFolderId ( slot . id ) ) }
className = { ` flex flex-col items-center gap-1.5 ${ isHoverTarget ? "scale-110" : "" } transition-transform ` }
>
< FolderTile icons = { icons } dimmed = { isDragging } / >
< span className = "line-clamp-2 w-full text-center text-[11px] leading-tight text-black/70 dark:text-white/70" >
{ slot . name }
< / span >
< / button >
) ;
}
const item = resolveRef ( slot . ref ) ;
if ( ! item ) return null ;
return (
< button
key = { slot . id }
type = "button"
data-slot-id = { slot . id }
data-area = { area }
onPointerDown = { ( e ) = > handlePointerDown ( e , slot ) }
onPointerMove = { handlePointerMove }
onPointerUp = { handlePointerUp }
onPointerCancel = { handlePointerCancel }
onClick = { ( ) = > handleTileClick ( ( ) = > onOpen ( { url : item.url , id : item.id , kind : item.type } ) ) }
className = { ` flex flex-col items-center gap-1.5 ${ isHoverTarget ? "scale-110" : "" } transition-transform ` }
>
< TileIcon favicon = { item . favicon } label = { item . displayName } dimmed = { isDragging } / >
{ size === "normal" ? (
< span className = "line-clamp-2 w-full text-center text-[11px] leading-tight text-black/70 dark:text-white/70" >
{ item . displayName }
< / span >
) : null }
< / button >
) ;
}
return (
< div className = "relative min-h-dvh overflow-hidden bg-neutral-100 dark:bg-neutral-950" >
{ /* Wallpaper: aus den eigenen Kategorie-Farben gebaut, siehe Kommentar oben an der Datei. */ }
{ /* Wallpaper */ }
< div className = "pointer-events-none fixed inset-0 overflow-hidden" >
{ palette . map ( ( color , i ) = > (
< div
key = { i }
className = "absolute h-[60vmax] w-[60vmax] rounded-full opacity-25
motion-safe:animate-drift dark:opacity-30"
className = "absolute h-[60vmax] w-[60vmax] rounded-full opacity-25 motion-safe:animate-drift dark:opacity-30"
style = { {
background : color ,
filter : "blur(60px)" ,
@@ -246,7 +457,7 @@ export function MobileAndroidHome({
< div className = "absolute inset-0 bg-white/50 dark:bg-black/55" / >
< / div >
{ /* Suchleiste: fest oben, immer sichtbar (auch beim Scrollen) - explizit gewünscht. */ }
{ /* Suchleiste: fest oben */ }
< div className = "sticky top-0 z-20 flex items-center gap-2 px-4 pb-3 pt-[calc(env(safe-area-inset-top)+0.75rem)]" >
< button
type = "button"
@@ -272,154 +483,105 @@ export function MobileAndroidHome({
< / div >
{ /* Haupt-Raster */ }
< div className = "px-4 pb-32 pt-2" >
{ favorites . length === 0 && grouped . ungrouped . length === 0 && grouped . byCategory . size === 0 ? (
< p className = "mt-10 text-center text-sm text-black/40 dark:text-white/40" >
Noch nichts angelegt . Füge Dienste oder Lesezeichen im Adminbereich hinzu .
< / p >
) : (
< div className = "grid grid-cols-4 gap-x-3 gap-y-5" >
{ /* Zuletzt besucht / Später lesen als feste "Ordner" ganz vorne */ }
{ recentVisits . length > 0 ? (
< button
type = "button"
onClick = { ( ) = > setOpenFolder ( "__recent__" ) }
className = "flex flex-col items-center gap-1.5"
>
< span
className = "flex h-14 w-14 items-center justify-center rounded-[20px] bg-white/60
text-black/50 shadow-[0_2px_10px_rgba(0,0,0,0.18)] ring-1 ring-white/40
backdrop-blur-sm dark:bg-white/10 dark:text-white/50 dark:ring-white/10"
>
< FontAwesomeIcon icon = { faClockRotateLeft } / >
< / span >
< span className = "line-clamp-2 w-full text-center text-[11px] leading-tight text-black/70 dark:text-white/70" >
Zuletzt besucht
< / span >
< / button >
) : null }
< div data-drop-area = "grid" className= "min-h-[40vh] px-4 pb-32 pt-2" >
< div className = "grid grid-cols-4 gap-x-3 gap-y-5" >
{ recentVisits . length > 0 ? (
< button
type = "button"
onClick = { ( ) = > setOpenFolder ( "__readlater __" ) }
onClick = { ( ) = > setOpenFolderId ( "__recent __" ) }
className = "flex flex-col items-center gap-1.5"
>
< span
className = "flex h-14 w-14 items-center justify-center rounded-[20px] bg-white/60
text-black/50 shadow-[0_2px_10px_rgba(0,0,0,0.18)] ring-1 ring-white/40
backdrop-blur-sm dark:bg-white/10 dark:text-white/50 dark:ring-white/10"
className = "flex h-14 w-14 items-center justify-center rounded-[20px] bg-white/60 text-black/50
shadow-[0_2px_10px_rgba(0,0,0,0.18)] ring-1 ring-white/40 backdrop-blur-sm
dark:bg-white/10 dark:text-white/50 dark:ring-white/10"
>
< FontAwesomeIcon icon = { faBookmark } / >
< FontAwesomeIcon icon = { faClockRotateLeft } / >
< / span >
< span className = "line-clamp-2 w-full text-center text-[11px] leading-tight text-black/70 dark:text-white/70" >
Später l esen
Zuletzt b esucht
< / span >
< / button >
) : null }
< button type = "button" onClick = { ( ) = > setOpenFolderId ( "__readlater__" ) } className = "flex flex-col items-center gap-1.5" >
< span
className = "flex h-14 w-14 items-center justify-center rounded-[20px] bg-white/60 text-black/50
shadow-[0_2px_10px_rgba(0,0,0,0.18)] ring-1 ring-white/40 backdrop-blur-sm
dark:bg-white/10 dark:text-white/50 dark:ring-white/10"
>
< FontAwesomeIcon icon = { faBookmark } / >
< / span >
< span className = "line-clamp-2 w-full text-center text-[11px] leading-tight text-black/70 dark:text-white/70" >
Später lesen
< / span >
< / button >
{ folderMode === "folders" ? (
< >
{ /* Kategorien als antippbare Ordner */ }
{ Array . from ( grouped . byCategory . entries ( ) ) . map ( ( [ name , items ] ) = > (
< button
key = { name }
type = "button"
onClick = { ( ) = > setOpenFolder ( name ) }
className = "flex flex-col items-center gap-1.5"
>
< FolderTile
icons = { items . slice ( 0 , 4 ) . map ( ( i ) = > ( { favicon : i.favicon , label : i.displayName } ) ) }
color = { categoryColors [ name ] ? ? null }
/ >
< span className = "line-clamp-2 w-full text-center text-[11px] leading-tight text-black/70 dark:text-white/70" >
{ name }
< / span >
< / button >
) ) }
{ /* Dienste/Lesezeichen ohne Kategorie direkt als Icon */ }
{ grouped . ungrouped . map ( ( item ) = > (
< button
key = { item . id }
type = "button"
onClick = { ( ) = > onOpen ( itemToOpenTarget ( item ) ) }
className = "flex flex-col items-center gap-1.5"
>
< TileIcon favicon = { item . favicon } label = { item . displayName } color = { null } / >
< span className = "line-clamp-2 w-full text-center text-[11px] leading-tight text-black/70 dark:text-white/70" >
{ item . displayName }
< / span >
< / button >
) ) }
< / >
) : null }
< / div >
) }
{ layout . grid . map ( ( slot ) = > renderTile ( slot , "grid" ) ) }
< / div >
{ /* Scroll-Modus: alles auf einer Seite, nach Kategorie gruppiert mit Überschrift statt Ordnern. */ }
{ folderMod e === "scroll" ? (
< div className = "mt-6 flex flex-col gap-6" >
{ Array . from ( grouped . byCategory . entries ( ) ) . map ( ( [ name , items ] ) = > (
< div key = { name } >
< div className = "mb-2.5 flex items-center gap-2" >
< span
className = "h-2 w-2 rounded-full"
style = { { background : categoryColors [ name ] ? ? "#a3a3a3" } }
/ >
< span className = "text-xs font-medium uppercase tracking-wide text-black/45 dark:text-white/45" >
{ name }
< / span >
< / div >
< div className = "grid grid-cols-4 gap-x-3 gap-y-5" >
{ items . map ( ( item ) = > (
< button
key = { item . id }
type = "button"
onClick = { ( ) = > onOpen ( itemToOpenTarget ( item ) ) }
className = "flex flex-col items-center gap-1.5"
>
< TileIcon
favicon = { item . favicon }
label = { item . displayName }
color = { categoryColors [ name ] ? ? null }
/ >
< span className = "line-clamp-2 w-full text-center text-[11px] leading-tight text-black/70 dark:text-white/70" >
{ item . displayName }
< / span >
< / button >
) ) }
< / div >
< / div >
) ) }
< / div >
{ layout . grid . length === 0 ? (
< p classNam e ="mt-8 text-center text-sm text-black/40 dark:text-white/40" >
Markiere Dienste / Lesezeichen im Adminbereich als Favorit – sie erscheinen dann hier .
< / p >
) : null }
< / div >
{ /* Dock: angeheftete Favoriten, immer sichtbar - klassisches Android-Verhalten. */ }
{ favorites . length > 0 ? (
< div className = "fixed inset-x-0 bottom-0 z-20 flex justify-center px-4 pb-[calc(env(safe-area-inset-bottom)+0.75rem)]" >
< div
className = "flex items-center gap-3 rounded-[28px] border border-white/40 bg-white/70 px-4
py-3 shadow-xl backdrop-blur-xl dark:border-white/10 dark:bg-neutral-900/70"
>
{ favorites . map ( ( item ) = > (
< button key = { item . id } type = "button" onClick = { ( ) = > onOpen ( itemToOpenTarget ( item ) ) } title = { item . displayName } >
< TileIcon favicon = { item . favicon } label = { item . displayName } color = { null } size = "small" / >
< / butto n>
) ) }
< / div >
{ /* Dock */ }
< div
data-drop-area = "dock"
className = "fixed inset-x-0 bottom-0 z-20 flex justify-center px-4 pb-[calc(env(safe-area-inset-bottom)+0.75rem)]"
>
< div
className = "flex min-h-[4.5rem] min-w-[5rem] items-center gap-3 rounded-[28px] border border-white/40
bg-white/70 px-4 py-3 shadow-xl backdrop-blur-xl dark:border-white/10 dark:bg-neutral-900/70"
>
{ layout . dock . length === 0 ? (
< span className = "px-2 text-xs text-black/30 dark:text-white/30" > Icon hierher ziehen zum Anheften < / spa n>
) : (
layout . dock . map ( ( slot ) = > renderTile ( slot , "dock" , "small" ) )
) }
< / div >
< / div >
{ /* Verbindungsstatus */ }
< div
title = { isOnline ? "Backend verbunden" : "Backend nicht erreichbar" }
className = { ` fixed left-4 top-[calc(env(safe-area-inset-top)+0.1rem)] z-10 h-2 w-2 rounded-full ${
isOnline ? "bg-emerald-500" : "bg-red-500"
} ` }
/ >
{ /* Schwebendes Icon während des Ziehens */ }
{ drag ? (
< div
className = "pointer-events-none fixed z-50 opacity-90"
style = { { left : drag.x - drag . offsetX , top : drag.y - drag . offsetY } }
>
{ drag . slot . kind === "folder" ? (
< FolderTile
icons = { drag . slot . items
. map ( ( r ) = > resolveRef ( r ) )
. filter ( ( i ) : i is ResolvedItem = > i !== null )
. slice ( 0 , 4 )
. map ( ( i ) = > ( { favicon : i.favicon , label : i.displayName } ) ) }
/ >
) : (
( ( ) = > {
const item = resolveRef ( drag . slot . ref ) ;
return item ? < TileIcon favicon = { item . favicon } label = { item . displayName } / > : null ;
} ) ( )
) }
< / div >
) : null }
{ /* Verbindungsstatus: dezenter Punkt statt eigener Fußzeile - dieses Design hat bewusst keinen Platz für eine lange Statuszeile. */ }
< div
title = { isOnline ? "Backend verbunden" : "Backend nicht erreichbar" }
className = { ` fixed left-4 top-[calc(env(safe-area-inset-top)+0.1rem)] z-10 h-2 w-2 rounded-full
${ isOnline ? "bg-emerald-500" : "bg-red-500" } ` }
/ >
{ /* Ordner-Übersicht als Sheet */ }
{ openFolderData ? (
{ /* Ordner-Sheet: Zuletzt besucht / Später lesen / eigener Ordner */ }
{ openFolderId ? (
< div
className = "fixed inset-0 z-30 flex items-end justify-center bg-black/40 backdrop-blur-sm sm:items-center"
onClick = { ( ) = > setOpenFolder ( null ) }
onClick = { ( ) = > {
setOpenFolderId ( null ) ;
setRenamingFolder ( false ) ;
} }
>
< div
onClick = { ( e ) = > e . stopPropagation ( ) }
@@ -427,20 +589,55 @@ export function MobileAndroidHome({
bg-white/90 p-5 shadow-2xl backdrop-blur-xl dark:border-white/10 dark:bg-neutral-900/90
sm:rounded-3xl"
>
< div className = "mb-4 flex items-center justify-between" >
< h2 className = "text-base font-semibold text-black dark:text-white" > { openFolderData . title } < / h2 >
< div className = "mb-4 flex items-center justify-between gap-2 " >
{ openFolder && renamingFolder ? (
< input
autoFocus
value = { folderNameDraft }
onChange = { ( e ) = > setFolderNameDraft ( e . target . value ) }
onBlur = { ( ) = > {
setRenamingFolder ( false ) ;
if ( folderNameDraft . trim ( ) ) renameFolder ( openFolder , folderNameDraft . trim ( ) ) ;
} }
onKeyDown = { ( e ) = > {
if ( e . key === "Enter" ) ( e . target as HTMLInputElement ) . blur ( ) ;
} }
className = "flex-1 rounded-lg border border-black/10 bg-white px-2 py-1 text-base font-semibold
text-black outline-none dark:border-white/10 dark:bg-white/10 dark:text-white"
/ >
) : (
< h2 className = "flex items-center gap-2 text-base font-semibold text-black dark:text-white" >
{ openFolderId === "__recent__" ? "Zuletzt besucht" : openFolderId === "__readlater__" ? "Später lesen" : openFolder ? . name }
{ openFolder ? (
< button
type = "button"
onClick = { ( ) = > {
setFolderNameDraft ( openFolder . name ) ;
setRenamingFolder ( true ) ;
} }
aria-label = "Ordner umbenennen"
className = "text-black/30 hover:text-black/60 dark:text-white/30 dark:hover:text-white/60"
>
< FontAwesomeIcon icon = { faPen } className = "text-xs" / >
< / button >
) : null }
< / h2 >
) }
< button
type = "button"
onClick = { ( ) = > setOpenFolder ( null ) }
onClick = { ( ) = > {
setOpenFolderId ( null ) ;
setRenamingFolder ( false ) ;
} }
aria-label = "Schließen"
className = "flex h-8 w-8 items-center justify-center rounded-full text-black/50
className = "flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-black/50
hover:bg-black/5 dark:text-white/50 dark:hover:bg-white/10"
>
< FontAwesomeIcon icon = { faXmark } / >
< / button >
< / div >
{ openFolder === "__readlater__" ? (
{ openFolderId === "__readlater__" ? (
< div className = "flex flex-col gap-4" >
< form
onSubmit = { ( e ) = > {
@@ -455,9 +652,9 @@ export function MobileAndroidHome({
value = { readLaterUrl }
onChange = { ( e ) = > setReadLaterUrl ( e . target . value ) }
placeholder = "Link einfügen …"
className = "flex-1 rounded-xl border border-black/10 bg-white/70 px-3 py-2 text-sm
text-black outline-none placeholder:text-black/30 focus:border-indigo-400/60
dark:border-white/10 dark:bg-white/5 dark:text-white dark:placeholder:text-white/30"
className = "flex-1 rounded-xl border border-black/10 bg-white/70 px-3 py-2 text-sm text-black
outline-none placeholder:text-black/30 focus:border-indigo-400/60 dark:border-white/10
dark:bg-white/5 dark:text-white dark:placeholder:text-white/30"
/ >
< Button type = "submit" variant = "secondary" size = "sm" >
Merken
@@ -472,7 +669,7 @@ export function MobileAndroidHome({
onClick = { ( ) = > onOpen ( { url : item.url , id : item.id , kind : "bookmark" } ) }
className = "flex flex-col items-center gap-1.5"
>
< TileIcon favicon = { item . favicon } label = { item . displayName } color = { null } / >
< TileIcon favicon = { item . favicon } label = { item . displayName } / >
< span className = "line-clamp-2 w-full text-center text-[11px] leading-tight text-black/70 dark:text-white/70" >
{ item . displayName }
< / span >
@@ -483,30 +680,53 @@ export function MobileAndroidHome({
< p className = "text-sm text-black/40 dark:text-white/40" > Noch nichts gemerkt . < / p >
) }
< / div >
) : (
) : openFolderId === "__recent__" ? (
< div className = "grid grid-cols-4 gap-x-3 gap-y-5" >
{ ( openFolderData . items ? ? [ ] ) . map ( ( item ) = > (
{ recentVisits . map ( ( item ) = > (
< button
key = { item . id }
type = "button"
onClick = { ( ) = > {
onOpen ( { url : item.url , id : item.id , kind : item.kind ? ? "service" } ) ;
setOpenFolder ( null ) ;
} }
onClick = { ( ) = > onOpen ( { url : item.url , id : item.id , kind : item.kind ? ? "service" } ) }
className = "flex flex-col items-center gap-1.5"
>
< TileIcon
favicon = { item . favicon }
label = { item . displayName }
color = { openFolder && openFolder !== "__recent__" ? ( categoryColors [ openFolder ] ? ? null ) : null }
/ >
< TileIcon favicon = { item . favicon } label = { item . displayName } / >
< span className = "line-clamp-2 w-full text-center text-[11px] leading-tight text-black/70 dark:text-white/70" >
{ item . displayName }
< / span >
< / button >
) ) }
< / div >
) }
) : openFolder ? (
< div className = "grid grid-cols-4 gap-x-3 gap-y-5" >
{ openFolder . items . map ( ( ref ) = > {
const item = resolveRef ( ref ) ;
if ( ! item ) return null ;
return (
< div key = { ` ${ ref . type } : ${ ref . id } ` } className = "relative flex flex-col items-center gap-1.5" >
< button
type = "button"
onClick = { ( ) = > removeFromFolder ( openFolder , ref ) }
aria-label = { ` ${ item . displayName } aus Ordner entfernen ` }
className = "absolute -right-1 -top-1 z-10 flex h-5 w-5 items-center justify-center
rounded-full bg-black/60 text-[10px] text-white shadow hover:bg-black/80"
>
< FontAwesomeIcon icon = { faXmark } / >
< / button >
< button
type = "button"
onClick = { ( ) = > onOpen ( { url : item.url , id : item.id , kind : item.type } ) }
className = "flex flex-col items-center gap-1.5"
>
< TileIcon favicon = { item . favicon } label = { item . displayName } / >
< span className = "line-clamp-2 w-full text-center text-[11px] leading-tight text-black/70 dark:text-white/70" >
{ item . displayName }
< / span >
< / button >
< / div >
) ;
} ) }
< / div >
) : null }
< / div >
< / div >
) : null }
@@ -515,10 +735,7 @@ export function MobileAndroidHome({
{ searchOpen ? (
< div className = "fixed inset-0 z-40 flex flex-col bg-white/95 backdrop-blur-xl dark:bg-neutral-950/95" >
< div className = "flex items-center gap-2 px-4 pb-3 pt-[calc(env(safe-area-inset-top)+0.75rem)]" >
< div
className = "flex flex-1 items-center gap-3 rounded-full border border-black/10 bg-white
px-4 py-3 shadow-sm dark:border-white/10 dark:bg-neutral-900"
>
< div className = "flex flex-1 items-center gap-3 rounded-full border border-black/10 bg-white px-4 py-3 shadow-sm dark:border-white/10 dark:bg-neutral-900" >
< FontAwesomeIcon icon = { faMagnifyingGlass } className = "text-black/40 dark:text-white/40" / >
< input
ref = { searchInputRef }