feat: thumbnails added
This commit is contained in:
@@ -0,0 +1,226 @@
|
|||||||
|
# Thumbnail v1 Design
|
||||||
|
|
||||||
|
## 1. Doel
|
||||||
|
Thumbnails voegen vooral waarde toe in directories met veel afbeeldingsbestanden: de gebruiker kan sneller visueel herkennen welk bestand relevant is zonder elk bestand afzonderlijk te openen. Binnen de huidige dual-pane workflow moet dit ondersteunend blijven en niet omslaan naar een galerij-UI. De lijst blijft primair een file-managerlijst.
|
||||||
|
|
||||||
|
Een aan/uit instelling is nodig omdat thumbnails extra I/O, extra requests en visuele drukte toevoegen. Sommige gebruikers willen maximale performance en een compactere lijst, vooral op grote directories of netwerkvolumes.
|
||||||
|
|
||||||
|
Belangrijke afbakening voor deze stap:
|
||||||
|
- Thumbnail v1 gaat alleen over:
|
||||||
|
- thumbnails voor image files
|
||||||
|
- een vaste mediaslot links van de naam
|
||||||
|
- iconen/placeholder als er geen thumbnail is
|
||||||
|
- een persistente setting in `Settings > General`
|
||||||
|
- het voorlopig behouden van het bestaande selectievakje
|
||||||
|
|
||||||
|
## 2. Scope
|
||||||
|
Aanbevolen veilige v1-scope:
|
||||||
|
- Wel:
|
||||||
|
- image thumbnails voor `jpg`, `jpeg`, `png`, `webp`
|
||||||
|
- Niet in v1:
|
||||||
|
- video thumbnails
|
||||||
|
- pdf thumbnails
|
||||||
|
- generieke document thumbnails
|
||||||
|
- server-side media processing pipeline
|
||||||
|
- embedded metadata-based speciale rendering
|
||||||
|
- wijziging van selectiegedrag
|
||||||
|
- verwijderen van het selectievakje
|
||||||
|
|
||||||
|
Aanbeveling: beperk v1 strikt tot browser-native renderbare image files. Dat houdt de backend klein, voorkomt extra dependencies en minimaliseert regressierisico.
|
||||||
|
|
||||||
|
## 3. UI-gedrag
|
||||||
|
### Thumbnail / icoon positie
|
||||||
|
Thumbnails of iconen komen altijd links van de bestandsnaam, in een vaste visuele slot binnen de naamkolom.
|
||||||
|
|
||||||
|
### Vaste uitlijning
|
||||||
|
Elke rij krijgt altijd dezelfde linker mediaslot:
|
||||||
|
- thumbnail als beschikbaar en thumbnails ingeschakeld zijn
|
||||||
|
- anders een passend icoon of placeholder
|
||||||
|
|
||||||
|
Dit geldt voor alle rijen, zodat naamkolom en tekstuitlijning stabiel blijven.
|
||||||
|
|
||||||
|
Aanbevolen gedrag:
|
||||||
|
- vaste slotbreedte, bijvoorbeeld 20 tot 28 px voor compacte v1
|
||||||
|
- thumbnails klein en uniform
|
||||||
|
- directories gebruiken een folder-icoon
|
||||||
|
- non-image files gebruiken een file-icoon
|
||||||
|
- image files zonder thumbnail of met thumbnails uit: ook file-icoon of neutrale image-placeholder
|
||||||
|
|
||||||
|
### Gedrag als thumbnails uit staan
|
||||||
|
Als `Show thumbnails` uit staat:
|
||||||
|
- directories tonen een folder-icoon
|
||||||
|
- files tonen een file-icoon
|
||||||
|
- de mediaslot links blijft bestaan
|
||||||
|
- de lijstuitlijning blijft identiek aan de toestand met thumbnails aan
|
||||||
|
|
||||||
|
Dit voorkomt visuele sprongen tussen beide modi.
|
||||||
|
|
||||||
|
## 4. Settings-integratie
|
||||||
|
De instelling komt in:
|
||||||
|
- `F1` -> `Settings` -> `General`
|
||||||
|
- settingnaam: `Show thumbnails`
|
||||||
|
|
||||||
|
Belangrijk:
|
||||||
|
- niet opslaan in browser `localStorage`
|
||||||
|
- wel persistent opslaan via backend in SQLite
|
||||||
|
- frontend leest de instelling bij app-start of bij openen van de settingsmodal
|
||||||
|
- wijziging wordt via backend opgeslagen en daarna direct toegepast in de UI
|
||||||
|
|
||||||
|
Aanbevolen model:
|
||||||
|
- aparte `settings` tabel in SQLite met key/value opslag
|
||||||
|
- minimaal sleutelgebruik in v1: `show_thumbnails`
|
||||||
|
|
||||||
|
## 5. Backend-impact
|
||||||
|
Aanbevolen minimale backenduitbreiding:
|
||||||
|
- aparte `settings` tabel
|
||||||
|
- eenvoudige read/write API, bijvoorbeeld:
|
||||||
|
- `GET /api/settings`
|
||||||
|
- `POST /api/settings`
|
||||||
|
- apart read-only thumbnail-endpoint, bijvoorbeeld:
|
||||||
|
- `GET /api/files/thumbnail?path=...`
|
||||||
|
|
||||||
|
Waarom apart endpoint:
|
||||||
|
- browse-responses blijven klein
|
||||||
|
- thumbnail-fetches kunnen lazy gebeuren
|
||||||
|
- bestaande `path_guard` en whitelist-validatie blijven leidend
|
||||||
|
|
||||||
|
## 6. Thumbnail-bron
|
||||||
|
Aanbevolen v1-richting:
|
||||||
|
- aparte read-only thumbnail/image route
|
||||||
|
- geen inline base64 in browse-response
|
||||||
|
- geen volledige browse-response verrijken met binaire data
|
||||||
|
|
||||||
|
Veilige v1-aanpak:
|
||||||
|
- thumbnails alleen voor ondersteunde image files
|
||||||
|
- kleine preview in vaste slot
|
||||||
|
- als server-side downscale zonder dependency niet goed haalbaar is, dan liever een eenvoudige image-serving route gecombineerd met kleine frontendweergave en lazy loading
|
||||||
|
|
||||||
|
Geen aparte disk-cache in v1.
|
||||||
|
|
||||||
|
## 7. Lijstlayout en selectie-impact
|
||||||
|
### Mediaslot / icoon-slot
|
||||||
|
De naamkolom krijgt links een compacte vaste structuur:
|
||||||
|
- selectievakje
|
||||||
|
- mediaslot (thumbnail of icoon)
|
||||||
|
- naamtekst
|
||||||
|
|
||||||
|
Dat houdt de UI voorspelbaar en ondersteunt zowel thumbnail- als niet-thumbnailweergave.
|
||||||
|
|
||||||
|
### Selectie-UX
|
||||||
|
De bestaande selectie-UX blijft leidend:
|
||||||
|
- row highlight voor selectie
|
||||||
|
- current row styling
|
||||||
|
- active pane styling
|
||||||
|
- checkbox-toggle
|
||||||
|
- `Cmd/Ctrl+klik`
|
||||||
|
- `Shift+Arrow`
|
||||||
|
- wildcard-selectie
|
||||||
|
- keyboardnavigatie
|
||||||
|
|
||||||
|
### Checkbox behouden of verwijderen?
|
||||||
|
Voor Thumbnail v1 is de keuze expliciet:
|
||||||
|
- **checkbox blijft voorlopig bestaan**
|
||||||
|
|
||||||
|
Dit is een tijdelijke concessie voor regressiebeheersing, niet de definitieve eindrichting.
|
||||||
|
|
||||||
|
Afweging:
|
||||||
|
- Screen real estate:
|
||||||
|
- ja, checkbox + thumbnail-slot kost ruimte
|
||||||
|
- maar de mediaslot kan compact blijven en de checkbox is al onderdeel van de huidige interactie
|
||||||
|
- Regressierisico:
|
||||||
|
- verwijderen van de checkbox verandert zichtbaar en functioneel selectiegedrag
|
||||||
|
- dat raakt multi-select en discoverability
|
||||||
|
- Bestaande multi-select flows:
|
||||||
|
- checkbox is nog steeds een directe, expliciete multi-select affordance
|
||||||
|
- Keyboardgebruik:
|
||||||
|
- keyboard blijft werken zonder checkbox, maar checkbox ondersteunt muisgebruikers duidelijk
|
||||||
|
- Wildcard-selectie / Cmd/Ctrl+klik / Shift+Arrow:
|
||||||
|
- die blijven belangrijk, maar vervangen de checkbox niet volledig als expliciete UI affordance
|
||||||
|
|
||||||
|
Expliciete afbakening:
|
||||||
|
- checkbox-verwijdering is **niet** in scope voor Thumbnail v1
|
||||||
|
- checkbox-verwijdering wordt **niet** stilzwijgend meegenomen in deze stap
|
||||||
|
- checkbox-verwijdering vereist een aparte latere UX-slice met eigen regressie-evaluatie
|
||||||
|
|
||||||
|
## 8. Performance en risico
|
||||||
|
Belangrijkste risico's:
|
||||||
|
- directories met veel afbeeldingen genereren veel requests
|
||||||
|
- grote originele afbeeldingen kunnen de lijst vertragen
|
||||||
|
- netwerkmounts geven extra latency
|
||||||
|
- checkbox + mediaslot + naam kan horizontale ruimte krapper maken
|
||||||
|
|
||||||
|
Aanbevolen mitigaties in v1:
|
||||||
|
- thumbnails alleen voor ondersteunde image files
|
||||||
|
- lazy loading aan frontendzijde
|
||||||
|
- beperkt aantal gelijktijdige thumbnail-requests
|
||||||
|
- kleine vaste slotgrootte
|
||||||
|
- geen server-side cachelaag in v1
|
||||||
|
|
||||||
|
## 9. Regressierisico
|
||||||
|
Belangrijkste regressierisico's:
|
||||||
|
- bestandslijst wordt te druk
|
||||||
|
- naamkolom wordt te smal
|
||||||
|
- selectie/current row styling wordt visueel minder duidelijk
|
||||||
|
- browse-performance daalt in grote directories
|
||||||
|
- checkbox-verwijdering zou onbedoeld mee kunnen liften op de thumbnailstap
|
||||||
|
|
||||||
|
Beheersmaatregelen:
|
||||||
|
- thumbnails klein houden
|
||||||
|
- checkbox behouden in v1
|
||||||
|
- vaste mediaslot gebruiken
|
||||||
|
- selectie/current row prioriteit geven boven decoratie
|
||||||
|
- geen wijziging aan klikgedrag, keyboardflow of selection model
|
||||||
|
|
||||||
|
## 10. Teststrategie
|
||||||
|
Backend golden tests:
|
||||||
|
- settings default response
|
||||||
|
- settings update persistence
|
||||||
|
- thumbnail endpoint success voor ondersteund imagebestand
|
||||||
|
- thumbnail endpoint not found
|
||||||
|
- traversal blocked
|
||||||
|
- invalid root alias
|
||||||
|
- non-image blocked of nette unsupported fout
|
||||||
|
|
||||||
|
UI smoke/regressietests:
|
||||||
|
- `Settings > General` bevat `Show thumbnails`
|
||||||
|
- instelling wordt opgehaald via backend, niet via localStorage
|
||||||
|
- mediaslot bestaat ook als thumbnails uit staan
|
||||||
|
- directories tonen folder-icoon zonder thumbnails
|
||||||
|
- files tonen file-icoon zonder thumbnails
|
||||||
|
- lijst blijft renderen met checkbox + mediaslot + naam
|
||||||
|
- selectie/current row blijven duidelijk
|
||||||
|
|
||||||
|
Handmatige validatie:
|
||||||
|
- toggle aan/uit werkt direct
|
||||||
|
- instelling blijft behouden na reload/herstart
|
||||||
|
- grote directory blijft bruikbaar
|
||||||
|
- image rows tonen thumbnail links van naam
|
||||||
|
- non-image rows en directories blijven netjes uitgelijnd
|
||||||
|
- checkbox en selectiegedrag blijven werken
|
||||||
|
|
||||||
|
## 11. Aanbeveling
|
||||||
|
Aanbevolen v1-richting met laag regressierisico:
|
||||||
|
- scope:
|
||||||
|
- alleen `jpg/jpeg/png/webp`
|
||||||
|
- default instelling:
|
||||||
|
- `off`
|
||||||
|
- opslagmodel:
|
||||||
|
- SQLite `settings` tabel met `show_thumbnails`
|
||||||
|
- UI:
|
||||||
|
- vaste mediaslot links van de naam
|
||||||
|
- thumbnail waar beschikbaar
|
||||||
|
- anders icoon/placeholder
|
||||||
|
- checkbox blijft voorlopig bestaan
|
||||||
|
- backend:
|
||||||
|
- eenvoudige settings read/write API
|
||||||
|
- apart read-only thumbnail-endpoint met bestaande path/securityvalidatie
|
||||||
|
- performance:
|
||||||
|
- lazy loading
|
||||||
|
- geen disk-cache of zware thumbnailpipeline in v1
|
||||||
|
|
||||||
|
Expliciete positionering:
|
||||||
|
- de huidige keuze om checkbox te behouden is **tijdelijk** en **regressiegedreven**
|
||||||
|
- de gewenste compactere eindrichting zonder checkbox kan later apart ontworpen worden
|
||||||
|
- die stap hoort niet bij Thumbnail v1 en moet als aparte UX-slice worden behandeld
|
||||||
|
|
||||||
|
Dit levert een kleine, veilige eerste stap op: thumbnails als optionele verrijking van de bestaande lijst, met stabiele uitlijning, minimale visuele verstoring en zonder selectie-regressies mee te slepen in dezelfde change.
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
# Thumbnail v1 Design
|
||||||
|
|
||||||
|
## 1. Doel
|
||||||
|
Thumbnails voegen vooral waarde toe in directories met veel afbeeldingsbestanden: de gebruiker kan sneller visueel herkennen welk bestand relevant is zonder elk bestand afzonderlijk te openen. Binnen de huidige dual-pane workflow moet dit ondersteunend blijven en niet omslaan naar een galerij-UI. De lijst blijft primair een file-managerlijst.
|
||||||
|
|
||||||
|
Een aan/uit instelling is nodig omdat thumbnails extra I/O, extra requests en visuele drukte toevoegen. Sommige gebruikers willen maximale performance en een compactere lijst, vooral op grote directories of netwerkvolumes.
|
||||||
|
|
||||||
|
## 2. Scope
|
||||||
|
Aanbevolen veilige v1-scope:
|
||||||
|
- Wel:
|
||||||
|
- image thumbnails voor `jpg`, `jpeg`, `png`, `webp`
|
||||||
|
- Niet in v1:
|
||||||
|
- video thumbnails
|
||||||
|
- pdf thumbnails
|
||||||
|
- generieke document thumbnails
|
||||||
|
- server-side media processing pipeline
|
||||||
|
- embedded metadata-based speciale rendering
|
||||||
|
|
||||||
|
Aanbeveling: beperk v1 strikt tot browser-native renderbare image files. Dat houdt de backend klein, voorkomt extra dependencies en minimaliseert regressierisico.
|
||||||
|
|
||||||
|
## 3. UI-gedrag
|
||||||
|
### Thumbnail / icoon positie
|
||||||
|
Thumbnails of iconen komen altijd links van de bestandsnaam, in een vaste visuele slot binnen de naamkolom.
|
||||||
|
|
||||||
|
### Vaste uitlijning
|
||||||
|
Elke rij krijgt altijd dezelfde linker mediaslot:
|
||||||
|
- thumbnail als beschikbaar en thumbnails ingeschakeld zijn
|
||||||
|
- anders een passend icoon of placeholder
|
||||||
|
|
||||||
|
Dit geldt voor alle rijen, zodat naamkolom en tekstuitlijning stabiel blijven.
|
||||||
|
|
||||||
|
Aanbevolen gedrag:
|
||||||
|
- vaste slotbreedte, bijvoorbeeld 20 tot 28 px voor compacte v1
|
||||||
|
- thumbnails klein en uniform
|
||||||
|
- directories gebruiken een folder-icoon
|
||||||
|
- non-image files gebruiken een file-icoon
|
||||||
|
- image files zonder thumbnail of met thumbnails uit: ook file-icoon of neutrale image-placeholder
|
||||||
|
|
||||||
|
### Gedrag als thumbnails uit staan
|
||||||
|
Als `Show thumbnails` uit staat:
|
||||||
|
- directories tonen een folder-icoon
|
||||||
|
- files tonen een file-icoon
|
||||||
|
- de mediaslot links blijft bestaan
|
||||||
|
- de lijstuitlijning blijft identiek aan de toestand met thumbnails aan
|
||||||
|
|
||||||
|
Dit voorkomt visuele sprongen tussen beide modi.
|
||||||
|
|
||||||
|
## 4. Settings-integratie
|
||||||
|
De instelling komt in:
|
||||||
|
- `F1` -> `Settings` -> `General`
|
||||||
|
- settingnaam: `Show thumbnails`
|
||||||
|
|
||||||
|
Belangrijk:
|
||||||
|
- niet opslaan in browser `localStorage`
|
||||||
|
- wel persistent opslaan via backend in SQLite
|
||||||
|
- frontend leest de instelling bij app-start of bij openen van de settingsmodal
|
||||||
|
- wijziging wordt via backend opgeslagen en daarna direct toegepast in de UI
|
||||||
|
|
||||||
|
Aanbevolen model:
|
||||||
|
- aparte `settings` tabel in SQLite met key/value opslag
|
||||||
|
- minimaal sleutelgebruik in v1: `show_thumbnails`
|
||||||
|
|
||||||
|
## 5. Backend-impact
|
||||||
|
Aanbevolen minimale backenduitbreiding:
|
||||||
|
- aparte `settings` tabel
|
||||||
|
- eenvoudige read/write API, bijvoorbeeld:
|
||||||
|
- `GET /api/settings`
|
||||||
|
- `POST /api/settings`
|
||||||
|
- apart read-only thumbnail-endpoint, bijvoorbeeld:
|
||||||
|
- `GET /api/files/thumbnail?path=...`
|
||||||
|
|
||||||
|
Waarom apart endpoint:
|
||||||
|
- browse-responses blijven klein
|
||||||
|
- thumbnail-fetches kunnen lazy gebeuren
|
||||||
|
- bestaande `path_guard` en whitelist-validatie blijven leidend
|
||||||
|
|
||||||
|
## 6. Thumbnail-bron
|
||||||
|
Aanbevolen v1-richting:
|
||||||
|
- aparte read-only thumbnail/image route
|
||||||
|
- geen inline base64 in browse-response
|
||||||
|
- geen volledige browse-response verrijken met binaire data
|
||||||
|
|
||||||
|
Veilige v1-aanpak:
|
||||||
|
- thumbnails alleen voor ondersteunde image files
|
||||||
|
- kleine preview in vaste slot
|
||||||
|
- als server-side downscale zonder dependency niet goed haalbaar is, dan liever een eenvoudige image-serving route gecombineerd met kleine frontendweergave en lazy loading
|
||||||
|
|
||||||
|
Geen aparte disk-cache in v1.
|
||||||
|
|
||||||
|
## 7. Lijstlayout en selectie-impact
|
||||||
|
### Mediaslot / icoon-slot
|
||||||
|
De naamkolom krijgt links een compacte vaste structuur:
|
||||||
|
- selectievakje
|
||||||
|
- mediaslot (thumbnail of icoon)
|
||||||
|
- naamtekst
|
||||||
|
|
||||||
|
Dat houdt de UI voorspelbaar en ondersteunt zowel thumbnail- als niet-thumbnailweergave.
|
||||||
|
|
||||||
|
### Selectie-UX
|
||||||
|
De bestaande selectie-UX moet leidend blijven:
|
||||||
|
- row highlight voor selectie
|
||||||
|
- current row styling
|
||||||
|
- active pane styling
|
||||||
|
- checkbox-toggle
|
||||||
|
- `Cmd/Ctrl+klik`
|
||||||
|
- `Shift+Arrow`
|
||||||
|
- wildcard-selectie
|
||||||
|
- keyboardnavigatie
|
||||||
|
|
||||||
|
### Checkbox behouden of verwijderen?
|
||||||
|
Aanbevolen keuze voor Thumbnail v1: **checkbox behouden**.
|
||||||
|
|
||||||
|
Afweging:
|
||||||
|
- Screen real estate:
|
||||||
|
- ja, checkbox + thumbnail-slot kost ruimte
|
||||||
|
- maar de mediaslot kan compact blijven en de checkbox is al onderdeel van de huidige interactie
|
||||||
|
- Regressierisico:
|
||||||
|
- verwijderen van de checkbox verandert zichtbaar en functioneel selectiegedrag
|
||||||
|
- dat raakt multi-select en discoverability
|
||||||
|
- Bestaande multi-select flows:
|
||||||
|
- checkbox is nog steeds een directe, expliciete multi-select affordance
|
||||||
|
- Keyboardgebruik:
|
||||||
|
- keyboard blijft werken zonder checkbox, maar checkbox ondersteunt muisgebruikers duidelijk
|
||||||
|
- Wildcard-selectie / Cmd/Ctrl+klik / Shift+Arrow:
|
||||||
|
- die blijven belangrijk, maar vervangen de checkbox niet volledig als expliciete UI affordance
|
||||||
|
|
||||||
|
Conclusie:
|
||||||
|
- checkbox nu verwijderen is een aparte UX-beslissing, geen thumbnailbeslissing
|
||||||
|
- dat verdient een aparte stap met eigen regressie-evaluatie
|
||||||
|
- voor Thumbnail v1 is behoud van checkbox de veiligste route met laag regressierisico
|
||||||
|
|
||||||
|
## 8. Performance en risico
|
||||||
|
Belangrijkste risico's:
|
||||||
|
- directories met veel afbeeldingen genereren veel requests
|
||||||
|
- grote originele afbeeldingen kunnen de lijst vertragen
|
||||||
|
- netwerkmounts geven extra latency
|
||||||
|
- checkbox + mediaslot + naam kan horizontale ruimte krapper maken
|
||||||
|
|
||||||
|
Aanbevolen mitigaties in v1:
|
||||||
|
- thumbnails alleen voor ondersteunde image files
|
||||||
|
- lazy loading aan frontendzijde
|
||||||
|
- beperkt aantal gelijktijdige thumbnail-requests
|
||||||
|
- kleine vaste slotgrootte
|
||||||
|
- geen server-side cachelaag in v1
|
||||||
|
|
||||||
|
## 9. Regressierisico
|
||||||
|
Belangrijkste regressierisico's:
|
||||||
|
- bestandslijst wordt te druk
|
||||||
|
- naamkolom wordt te smal
|
||||||
|
- selectie/current row styling wordt visueel minder duidelijk
|
||||||
|
- browse-performance daalt in grote directories
|
||||||
|
- discussie over checkbox-verwijdering wordt onbedoeld meegesleept in de thumbnailstap
|
||||||
|
|
||||||
|
Beheersmaatregelen:
|
||||||
|
- thumbnails klein houden
|
||||||
|
- checkbox behouden in v1
|
||||||
|
- vaste mediaslot gebruiken
|
||||||
|
- selectie/current row prioriteit geven boven decoratie
|
||||||
|
- geen wijziging aan klikgedrag, keyboardflow of selection model
|
||||||
|
|
||||||
|
## 10. Teststrategie
|
||||||
|
Backend golden tests:
|
||||||
|
- settings default response
|
||||||
|
- settings update persistence
|
||||||
|
- thumbnail endpoint success voor ondersteund imagebestand
|
||||||
|
- thumbnail endpoint not found
|
||||||
|
- traversal blocked
|
||||||
|
- invalid root alias
|
||||||
|
- non-image blocked of nette unsupported fout
|
||||||
|
|
||||||
|
UI smoke/regressietests:
|
||||||
|
- `Settings > General` bevat `Show thumbnails`
|
||||||
|
- instelling wordt opgehaald via backend, niet via localStorage
|
||||||
|
- mediaslot bestaat ook als thumbnails uit staan
|
||||||
|
- directories tonen folder-icoon zonder thumbnails
|
||||||
|
- files tonen file-icoon zonder thumbnails
|
||||||
|
- lijst blijft renderen met checkbox + mediaslot + naam
|
||||||
|
- selectie/current row blijven duidelijk
|
||||||
|
|
||||||
|
Handmatige validatie:
|
||||||
|
- toggle aan/uit werkt direct
|
||||||
|
- instelling blijft behouden na reload/herstart
|
||||||
|
- grote directory blijft bruikbaar
|
||||||
|
- image rows tonen thumbnail links van naam
|
||||||
|
- non-image rows en directories blijven netjes uitgelijnd
|
||||||
|
- checkbox en selectiegedrag blijven werken
|
||||||
|
|
||||||
|
## 11. Aanbeveling
|
||||||
|
Aanbevolen v1-richting met laag regressierisico:
|
||||||
|
- scope:
|
||||||
|
- alleen `jpg/jpeg/png/webp`
|
||||||
|
- default instelling:
|
||||||
|
- `off`
|
||||||
|
- opslagmodel:
|
||||||
|
- SQLite `settings` tabel met `show_thumbnails`
|
||||||
|
- UI:
|
||||||
|
- vaste mediaslot links van de naam
|
||||||
|
- thumbnail waar beschikbaar
|
||||||
|
- anders icoon/placeholder
|
||||||
|
- checkbox behouden in deze fase
|
||||||
|
- backend:
|
||||||
|
- eenvoudige settings read/write API
|
||||||
|
- apart read-only thumbnail-endpoint met bestaande path/securityvalidatie
|
||||||
|
- performance:
|
||||||
|
- lazy loading
|
||||||
|
- geen disk-cache of zware thumbnailpipeline in v1
|
||||||
|
|
||||||
|
Expliciete aanbeveling over checkbox:
|
||||||
|
- **niet verwijderen in Thumbnail v1**
|
||||||
|
- als wenselijk, behandel checkbox-verwijdering als aparte latere UX-slice
|
||||||
|
- reden: dat onderwerp raakt selectiegedrag en discoverability te sterk om mee te liften op thumbnails
|
||||||
|
|
||||||
|
Dit levert een kleine, veilige eerste stap op: thumbnails als optionele verrijking van de bestaande lijst, met stabiele uitlijning, minimale visuele verstoring en zonder onnodige selectie-regressies.
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -66,6 +66,19 @@ async def video(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/thumbnail")
|
||||||
|
async def thumbnail(
|
||||||
|
path: str,
|
||||||
|
service: FileOpsService = Depends(get_file_ops_service),
|
||||||
|
) -> StreamingResponse:
|
||||||
|
prepared = service.prepare_thumbnail_stream(path=path)
|
||||||
|
return StreamingResponse(
|
||||||
|
prepared["content"],
|
||||||
|
headers=prepared["headers"],
|
||||||
|
media_type=prepared["content_type"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/save", response_model=SaveResponse)
|
@router.post("/save", response_model=SaveResponse)
|
||||||
async def save(
|
async def save(
|
||||||
request: SaveRequest,
|
request: SaveRequest,
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
|
from backend.app.api.schemas import SettingsResponse, SettingsUpdateRequest
|
||||||
|
from backend.app.dependencies import get_settings_service
|
||||||
|
from backend.app.services.settings_service import SettingsService
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/settings")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=SettingsResponse)
|
||||||
|
async def get_settings(
|
||||||
|
service: SettingsService = Depends(get_settings_service),
|
||||||
|
) -> SettingsResponse:
|
||||||
|
return service.get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=SettingsResponse)
|
||||||
|
async def update_settings(
|
||||||
|
request: SettingsUpdateRequest,
|
||||||
|
service: SettingsService = Depends(get_settings_service),
|
||||||
|
) -> SettingsResponse:
|
||||||
|
return service.update_settings(request)
|
||||||
@@ -94,6 +94,14 @@ class FileInfoResponse(BaseModel):
|
|||||||
group: str | None = None
|
group: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsResponse(BaseModel):
|
||||||
|
show_thumbnails: bool
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsUpdateRequest(BaseModel):
|
||||||
|
show_thumbnails: bool
|
||||||
|
|
||||||
|
|
||||||
class TaskListItem(BaseModel):
|
class TaskListItem(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
operation: str
|
operation: str
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,51 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsRepository:
|
||||||
|
def __init__(self, db_path: str):
|
||||||
|
self._db_path = db_path
|
||||||
|
self._ensure_schema()
|
||||||
|
|
||||||
|
def get_settings(self) -> dict[str, str]:
|
||||||
|
with self._connection() as conn:
|
||||||
|
rows = conn.execute("SELECT key, value FROM settings").fetchall()
|
||||||
|
return {row["key"]: row["value"] for row in rows}
|
||||||
|
|
||||||
|
def set_setting(self, key: str, value: str) -> None:
|
||||||
|
with self._connection() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO settings (key, value)
|
||||||
|
VALUES (?, ?)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value
|
||||||
|
""",
|
||||||
|
(key, value),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _ensure_schema(self) -> None:
|
||||||
|
db_path = Path(self._db_path)
|
||||||
|
if db_path.parent and str(db_path.parent) not in {"", "."}:
|
||||||
|
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with self._connection() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _connection(self):
|
||||||
|
conn = sqlite3.connect(self._db_path)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
try:
|
||||||
|
yield conn
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
@@ -5,6 +5,7 @@ from functools import lru_cache
|
|||||||
from backend.app.config import Settings, get_settings
|
from backend.app.config import Settings, get_settings
|
||||||
from backend.app.db.bookmark_repository import BookmarkRepository
|
from backend.app.db.bookmark_repository import BookmarkRepository
|
||||||
from backend.app.db.history_repository import HistoryRepository
|
from backend.app.db.history_repository import HistoryRepository
|
||||||
|
from backend.app.db.settings_repository import SettingsRepository
|
||||||
from backend.app.db.task_repository import TaskRepository
|
from backend.app.db.task_repository import TaskRepository
|
||||||
from backend.app.fs.filesystem_adapter import FilesystemAdapter
|
from backend.app.fs.filesystem_adapter import FilesystemAdapter
|
||||||
from backend.app.security.path_guard import PathGuard
|
from backend.app.security.path_guard import PathGuard
|
||||||
@@ -15,6 +16,7 @@ from backend.app.services.file_ops_service import FileOpsService
|
|||||||
from backend.app.services.history_service import HistoryService
|
from backend.app.services.history_service import HistoryService
|
||||||
from backend.app.services.move_task_service import MoveTaskService
|
from backend.app.services.move_task_service import MoveTaskService
|
||||||
from backend.app.services.search_service import SearchService
|
from backend.app.services.search_service import SearchService
|
||||||
|
from backend.app.services.settings_service import SettingsService
|
||||||
from backend.app.services.task_service import TaskService
|
from backend.app.services.task_service import TaskService
|
||||||
from backend.app.tasks_runner import TaskRunner
|
from backend.app.tasks_runner import TaskRunner
|
||||||
|
|
||||||
@@ -47,6 +49,12 @@ def get_history_repository() -> HistoryRepository:
|
|||||||
return HistoryRepository(db_path=settings.task_db_path)
|
return HistoryRepository(db_path=settings.task_db_path)
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def get_settings_repository() -> SettingsRepository:
|
||||||
|
settings: Settings = get_settings()
|
||||||
|
return SettingsRepository(db_path=settings.task_db_path)
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=1)
|
@lru_cache(maxsize=1)
|
||||||
def get_task_runner() -> TaskRunner:
|
def get_task_runner() -> TaskRunner:
|
||||||
return TaskRunner(
|
return TaskRunner(
|
||||||
@@ -100,3 +108,7 @@ async def get_history_service() -> HistoryService:
|
|||||||
|
|
||||||
async def get_search_service() -> SearchService:
|
async def get_search_service() -> SearchService:
|
||||||
return SearchService(path_guard=get_path_guard(), filesystem=get_filesystem_adapter())
|
return SearchService(path_guard=get_path_guard(), filesystem=get_filesystem_adapter())
|
||||||
|
|
||||||
|
|
||||||
|
async def get_settings_service() -> SettingsService:
|
||||||
|
return SettingsService(repository=get_settings_repository())
|
||||||
|
|||||||
Binary file not shown.
@@ -147,6 +147,14 @@ class FilesystemAdapter:
|
|||||||
remaining -= len(chunk)
|
remaining -= len(chunk)
|
||||||
yield chunk
|
yield chunk
|
||||||
|
|
||||||
|
async def stream_file(self, path: Path, chunk_size: int = 1024 * 1024):
|
||||||
|
with path.open("rb") as handle:
|
||||||
|
while True:
|
||||||
|
chunk = handle.read(chunk_size)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
yield chunk
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def modified_iso(path: Path) -> str:
|
def modified_iso(path: Path) -> str:
|
||||||
stat = path.stat()
|
stat = path.stat()
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from backend.app.api.routes_files import router as files_router
|
|||||||
from backend.app.api.routes_history import router as history_router
|
from backend.app.api.routes_history import router as history_router
|
||||||
from backend.app.api.routes_move import router as move_router
|
from backend.app.api.routes_move import router as move_router
|
||||||
from backend.app.api.routes_search import router as search_router
|
from backend.app.api.routes_search import router as search_router
|
||||||
|
from backend.app.api.routes_settings import router as settings_router
|
||||||
from backend.app.api.routes_tasks import router as tasks_router
|
from backend.app.api.routes_tasks import router as tasks_router
|
||||||
from backend.app.logging import configure_logging
|
from backend.app.logging import configure_logging
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@ app.include_router(files_router, prefix="/api")
|
|||||||
app.include_router(copy_router, prefix="/api")
|
app.include_router(copy_router, prefix="/api")
|
||||||
app.include_router(move_router, prefix="/api")
|
app.include_router(move_router, prefix="/api")
|
||||||
app.include_router(search_router, prefix="/api")
|
app.include_router(search_router, prefix="/api")
|
||||||
|
app.include_router(settings_router, prefix="/api")
|
||||||
app.include_router(bookmarks_router, prefix="/api")
|
app.include_router(bookmarks_router, prefix="/api")
|
||||||
app.include_router(history_router, prefix="/api")
|
app.include_router(history_router, prefix="/api")
|
||||||
app.include_router(tasks_router, prefix="/api")
|
app.include_router(tasks_router, prefix="/api")
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -25,6 +25,12 @@ SPECIAL_TEXT_FILENAMES = {
|
|||||||
"dockerfile": "text/plain",
|
"dockerfile": "text/plain",
|
||||||
"containerfile": "text/plain",
|
"containerfile": "text/plain",
|
||||||
}
|
}
|
||||||
|
THUMBNAIL_CONTENT_TYPES = {
|
||||||
|
".jpg": "image/jpeg",
|
||||||
|
".jpeg": "image/jpeg",
|
||||||
|
".png": "image/png",
|
||||||
|
".webp": "image/webp",
|
||||||
|
}
|
||||||
VIDEO_CONTENT_TYPES = {
|
VIDEO_CONTENT_TYPES = {
|
||||||
".mp4": "video/mp4",
|
".mp4": "video/mp4",
|
||||||
".mkv": "video/x-matroska",
|
".mkv": "video/x-matroska",
|
||||||
@@ -370,6 +376,39 @@ class FileOpsService:
|
|||||||
"content": self._filesystem.stream_file_range(resolved_target.absolute, start, end),
|
"content": self._filesystem.stream_file_range(resolved_target.absolute, start, end),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def prepare_thumbnail_stream(self, path: str) -> dict:
|
||||||
|
resolved_target = self._path_guard.resolve_existing_path(path)
|
||||||
|
|
||||||
|
if resolved_target.absolute.is_dir():
|
||||||
|
raise AppError(
|
||||||
|
code="type_conflict",
|
||||||
|
message="Source must be a file",
|
||||||
|
status_code=409,
|
||||||
|
details={"path": resolved_target.relative},
|
||||||
|
)
|
||||||
|
if not resolved_target.absolute.is_file():
|
||||||
|
raise AppError(
|
||||||
|
code="type_conflict",
|
||||||
|
message="Unsupported path type for thumbnail",
|
||||||
|
status_code=409,
|
||||||
|
details={"path": resolved_target.relative},
|
||||||
|
)
|
||||||
|
|
||||||
|
content_type = self._thumbnail_content_type_for(resolved_target.absolute)
|
||||||
|
if content_type is None:
|
||||||
|
raise AppError(
|
||||||
|
code="unsupported_type",
|
||||||
|
message="File type is not supported for thumbnail",
|
||||||
|
status_code=409,
|
||||||
|
details={"path": resolved_target.relative},
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"headers": {"Content-Length": str(int(resolved_target.absolute.stat().st_size))},
|
||||||
|
"content_type": content_type,
|
||||||
|
"content": self._filesystem.stream_file(resolved_target.absolute),
|
||||||
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _join_relative(base: str, name: str) -> str:
|
def _join_relative(base: str, name: str) -> str:
|
||||||
return f"{base}/{name}" if base else name
|
return f"{base}/{name}" if base else name
|
||||||
@@ -385,6 +424,10 @@ class FileOpsService:
|
|||||||
def _video_content_type_for(path: Path) -> str | None:
|
def _video_content_type_for(path: Path) -> str | None:
|
||||||
return VIDEO_CONTENT_TYPES.get(path.suffix.lower())
|
return VIDEO_CONTENT_TYPES.get(path.suffix.lower())
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _thumbnail_content_type_for(path: Path) -> str | None:
|
||||||
|
return THUMBNAIL_CONTENT_TYPES.get(path.suffix.lower())
|
||||||
|
|
||||||
def _record_history(
|
def _record_history(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from backend.app.api.schemas import SettingsResponse, SettingsUpdateRequest
|
||||||
|
from backend.app.db.settings_repository import SettingsRepository
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsService:
|
||||||
|
def __init__(self, repository: SettingsRepository):
|
||||||
|
self._repository = repository
|
||||||
|
|
||||||
|
def get_settings(self) -> SettingsResponse:
|
||||||
|
values = self._repository.get_settings()
|
||||||
|
return SettingsResponse(show_thumbnails=self._as_bool(values.get("show_thumbnails"), default=False))
|
||||||
|
|
||||||
|
def update_settings(self, request: SettingsUpdateRequest) -> SettingsResponse:
|
||||||
|
self._repository.set_setting("show_thumbnails", "true" if request.show_thumbnails else "false")
|
||||||
|
return self.get_settings()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _as_bool(value: str | None, default: bool) -> bool:
|
||||||
|
if value is None:
|
||||||
|
return default
|
||||||
|
return value.strip().lower() in {"1", "true", "yes", "on"}
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,59 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[3]))
|
||||||
|
|
||||||
|
from backend.app.dependencies import get_settings_service
|
||||||
|
from backend.app.db.settings_repository import SettingsRepository
|
||||||
|
from backend.app.main import app
|
||||||
|
from backend.app.services.settings_service import SettingsService
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsApiGoldenTest(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.temp_dir = tempfile.TemporaryDirectory()
|
||||||
|
repository = SettingsRepository(str(Path(self.temp_dir.name) / "tasks.db"))
|
||||||
|
service = SettingsService(repository=repository)
|
||||||
|
|
||||||
|
async def _override_settings_service() -> SettingsService:
|
||||||
|
return service
|
||||||
|
|
||||||
|
app.dependency_overrides[get_settings_service] = _override_settings_service
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
self.temp_dir.cleanup()
|
||||||
|
|
||||||
|
def _request(self, method: str, url: str, payload: dict | None = None) -> httpx.Response:
|
||||||
|
async def _run() -> httpx.Response:
|
||||||
|
transport = httpx.ASGITransport(app=app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
|
||||||
|
if method == "GET":
|
||||||
|
return await client.get(url)
|
||||||
|
return await client.post(url, json=payload)
|
||||||
|
|
||||||
|
return asyncio.run(_run())
|
||||||
|
|
||||||
|
def test_settings_default_response(self) -> None:
|
||||||
|
response = self._request("GET", "/api/settings")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.json(), {"show_thumbnails": False})
|
||||||
|
|
||||||
|
def test_settings_update_persistence(self) -> None:
|
||||||
|
response = self._request("POST", "/api/settings", {"show_thumbnails": True})
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.json(), {"show_thumbnails": True})
|
||||||
|
self.assertEqual(self._request("GET", "/api/settings").json(), {"show_thumbnails": True})
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[3]))
|
||||||
|
|
||||||
|
from backend.app.dependencies import get_file_ops_service
|
||||||
|
from backend.app.fs.filesystem_adapter import FilesystemAdapter
|
||||||
|
from backend.app.main import app
|
||||||
|
from backend.app.security.path_guard import PathGuard
|
||||||
|
from backend.app.services.file_ops_service import FileOpsService
|
||||||
|
|
||||||
|
|
||||||
|
class ThumbnailApiGoldenTest(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.temp_dir = tempfile.TemporaryDirectory()
|
||||||
|
self.root = Path(self.temp_dir.name) / "root"
|
||||||
|
self.root.mkdir(parents=True, exist_ok=True)
|
||||||
|
service = FileOpsService(path_guard=PathGuard({"storage1": str(self.root)}), filesystem=FilesystemAdapter())
|
||||||
|
|
||||||
|
async def _override_file_ops_service() -> FileOpsService:
|
||||||
|
return service
|
||||||
|
|
||||||
|
app.dependency_overrides[get_file_ops_service] = _override_file_ops_service
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
self.temp_dir.cleanup()
|
||||||
|
|
||||||
|
def _request(self, path: str) -> httpx.Response:
|
||||||
|
async def _run() -> httpx.Response:
|
||||||
|
transport = httpx.ASGITransport(app=app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
|
||||||
|
return await client.get("/api/files/thumbnail", params={"path": path})
|
||||||
|
|
||||||
|
return asyncio.run(_run())
|
||||||
|
|
||||||
|
def test_thumbnail_success_for_supported_image(self) -> None:
|
||||||
|
image = self.root / "poster.jpg"
|
||||||
|
image.write_bytes(b"\xff\xd8\xff\xe0thumbnail")
|
||||||
|
|
||||||
|
response = self._request("storage1/poster.jpg")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.headers["content-type"], "image/jpeg")
|
||||||
|
self.assertEqual(response.content, b"\xff\xd8\xff\xe0thumbnail")
|
||||||
|
|
||||||
|
def test_thumbnail_not_found(self) -> None:
|
||||||
|
response = self._request("storage1/missing.jpg")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
self.assertEqual(response.json()["error"]["code"], "path_not_found")
|
||||||
|
|
||||||
|
def test_thumbnail_traversal_blocked(self) -> None:
|
||||||
|
response = self._request("storage1/../etc/passwd")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
self.assertEqual(response.json()["error"]["code"], "path_traversal_detected")
|
||||||
|
|
||||||
|
def test_thumbnail_invalid_root_alias(self) -> None:
|
||||||
|
response = self._request("unknown/file.jpg")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
self.assertEqual(response.json()["error"]["code"], "invalid_root_alias")
|
||||||
|
|
||||||
|
def test_thumbnail_non_image_blocked(self) -> None:
|
||||||
|
text_file = self.root / "notes.txt"
|
||||||
|
text_file.write_text("hello", encoding="utf-8")
|
||||||
|
|
||||||
|
response = self._request("storage1/notes.txt")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 409)
|
||||||
|
self.assertEqual(response.json()["error"]["code"], "unsupported_type")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -63,6 +63,8 @@ class UiSmokeGoldenTest(unittest.TestCase):
|
|||||||
self.assertIn('id="rename-apply-btn"', body)
|
self.assertIn('id="rename-apply-btn"', body)
|
||||||
self.assertIn('id="settings-general-tab"', body)
|
self.assertIn('id="settings-general-tab"', body)
|
||||||
self.assertIn('id="settings-logs-tab"', body)
|
self.assertIn('id="settings-logs-tab"', body)
|
||||||
|
self.assertIn('id="settings-show-thumbnails"', body)
|
||||||
|
self.assertIn("Show thumbnails", body)
|
||||||
self.assertIn('id="settings-logs-list"', body)
|
self.assertIn('id="settings-logs-list"', body)
|
||||||
self.assertIn('id="viewer-content"', body)
|
self.assertIn('id="viewer-content"', body)
|
||||||
self.assertIn('id="editor-modal"', body)
|
self.assertIn('id="editor-modal"', body)
|
||||||
@@ -113,6 +115,12 @@ class UiSmokeGoldenTest(unittest.TestCase):
|
|||||||
self.assertIn("document.documentElement.dataset.theme", app_js)
|
self.assertIn("document.documentElement.dataset.theme", app_js)
|
||||||
self.assertIn('document.getElementById("theme-toggle").onclick = toggleTheme;', app_js)
|
self.assertIn('document.getElementById("theme-toggle").onclick = toggleTheme;', app_js)
|
||||||
self.assertIn('document.getElementById("settings-btn").onclick = () => openSettings("general");', app_js)
|
self.assertIn('document.getElementById("settings-btn").onclick = () => openSettings("general");', app_js)
|
||||||
|
self.assertIn('async function loadSettings()', app_js)
|
||||||
|
self.assertIn('await loadSettings();', app_js)
|
||||||
|
self.assertIn('settings.showThumbnailsInput.onchange = handleShowThumbnailsChange;', app_js)
|
||||||
|
self.assertIn('"/api/settings"', app_js)
|
||||||
|
self.assertIn('`/api/files/thumbnail?', app_js)
|
||||||
|
self.assertIn('function createMediaSlot(entry)', app_js)
|
||||||
self.assertIn('function openSearch()', app_js)
|
self.assertIn('function openSearch()', app_js)
|
||||||
self.assertIn('async function submitSearch()', app_js)
|
self.assertIn('async function submitSearch()', app_js)
|
||||||
self.assertIn('async function openInfo()', app_js)
|
self.assertIn('async function openInfo()', app_js)
|
||||||
@@ -149,6 +157,9 @@ class UiSmokeGoldenTest(unittest.TestCase):
|
|||||||
self.assertIn('#theme-toggle', style_css)
|
self.assertIn('#theme-toggle', style_css)
|
||||||
self.assertIn('.settings-card', style_css)
|
self.assertIn('.settings-card', style_css)
|
||||||
self.assertIn('.settings-tabs', style_css)
|
self.assertIn('.settings-tabs', style_css)
|
||||||
|
self.assertIn('.entry-media-slot', style_css)
|
||||||
|
self.assertIn('.entry-media-icon.folder', style_css)
|
||||||
|
self.assertIn('.entry-media-icon.file', style_css)
|
||||||
|
|
||||||
app_js_url = app.url_path_for("ui", path="/app.js")
|
app_js_url = app.url_path_for("ui", path="/app.js")
|
||||||
style_css_url = app.url_path_for("ui", path="/style.css")
|
style_css_url = app.url_path_for("ui", path="/style.css")
|
||||||
|
|||||||
+76
-2
@@ -47,6 +47,7 @@ let batchMoveState = {
|
|||||||
let settingsState = {
|
let settingsState = {
|
||||||
activeTab: "general",
|
activeTab: "general",
|
||||||
logsLoaded: false,
|
logsLoaded: false,
|
||||||
|
showThumbnails: false,
|
||||||
};
|
};
|
||||||
let searchState = {
|
let searchState = {
|
||||||
pane: "left",
|
pane: "left",
|
||||||
@@ -200,6 +201,7 @@ function settingsElements() {
|
|||||||
generalTab: document.getElementById("settings-general-tab"),
|
generalTab: document.getElementById("settings-general-tab"),
|
||||||
logsTab: document.getElementById("settings-logs-tab"),
|
logsTab: document.getElementById("settings-logs-tab"),
|
||||||
generalPanel: document.getElementById("settings-general-panel"),
|
generalPanel: document.getElementById("settings-general-panel"),
|
||||||
|
showThumbnailsInput: document.getElementById("settings-show-thumbnails"),
|
||||||
logsPanel: document.getElementById("settings-logs-panel"),
|
logsPanel: document.getElementById("settings-logs-panel"),
|
||||||
logsList: document.getElementById("settings-logs-list"),
|
logsList: document.getElementById("settings-logs-list"),
|
||||||
logsError: document.getElementById("settings-logs-error"),
|
logsError: document.getElementById("settings-logs-error"),
|
||||||
@@ -357,6 +359,58 @@ function currentRowItem(pane) {
|
|||||||
return model.visibleItems[model.currentRowIndex];
|
return model.visibleItems[model.currentRowIndex];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function thumbnailsEnabled() {
|
||||||
|
return !!settingsState.showThumbnails;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isThumbnailCandidate(entry) {
|
||||||
|
if (!entry || entry.kind !== "file") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const lower = (entry.name || "").toLowerCase();
|
||||||
|
return lower.endsWith(".jpg") || lower.endsWith(".jpeg") || lower.endsWith(".png") || lower.endsWith(".webp");
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMediaSlot(entry) {
|
||||||
|
const slot = document.createElement("span");
|
||||||
|
slot.className = "entry-media-slot";
|
||||||
|
if (thumbnailsEnabled() && isThumbnailCandidate(entry)) {
|
||||||
|
const image = document.createElement("img");
|
||||||
|
image.className = "entry-thumbnail";
|
||||||
|
image.loading = "lazy";
|
||||||
|
image.alt = "";
|
||||||
|
image.src = `/api/files/thumbnail?${new URLSearchParams({ path: entry.path }).toString()}`;
|
||||||
|
slot.append(image);
|
||||||
|
return slot;
|
||||||
|
}
|
||||||
|
|
||||||
|
const icon = document.createElement("span");
|
||||||
|
icon.className = `entry-media-icon ${entry.kind === "directory" ? "folder" : "file"}`;
|
||||||
|
icon.setAttribute("aria-hidden", "true");
|
||||||
|
slot.append(icon);
|
||||||
|
return slot;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSettings() {
|
||||||
|
const data = await apiRequest("GET", "/api/settings");
|
||||||
|
settingsState.showThumbnails = !!data.show_thumbnails;
|
||||||
|
const elements = settingsElements();
|
||||||
|
if (elements.showThumbnailsInput) {
|
||||||
|
elements.showThumbnailsInput.checked = settingsState.showThumbnails;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSettings(update) {
|
||||||
|
const data = await apiRequest("POST", "/api/settings", update);
|
||||||
|
settingsState.showThumbnails = !!data.show_thumbnails;
|
||||||
|
const elements = settingsElements();
|
||||||
|
if (elements.showThumbnailsInput) {
|
||||||
|
elements.showThumbnailsInput.checked = settingsState.showThumbnails;
|
||||||
|
}
|
||||||
|
renderPaneItems("left");
|
||||||
|
renderPaneItems("right");
|
||||||
|
}
|
||||||
|
|
||||||
function updateActionButtons() {
|
function updateActionButtons() {
|
||||||
const selectedItems = activePaneState().selectedItems;
|
const selectedItems = activePaneState().selectedItems;
|
||||||
const count = selectedItems.length;
|
const count = selectedItems.length;
|
||||||
@@ -547,6 +601,7 @@ function createBrowseItem(pane, entry, kind) {
|
|||||||
|
|
||||||
const name = document.createElement("span");
|
const name = document.createElement("span");
|
||||||
name.className = `entry-name ${kind === "directory" ? "entry-dir" : "entry-file"}`;
|
name.className = `entry-name ${kind === "directory" ? "entry-dir" : "entry-file"}`;
|
||||||
|
name.append(createMediaSlot({ ...entry, kind }));
|
||||||
|
|
||||||
if (kind === "directory") {
|
if (kind === "directory") {
|
||||||
const open = document.createElement("button");
|
const open = document.createElement("button");
|
||||||
@@ -558,10 +613,12 @@ function createBrowseItem(pane, entry, kind) {
|
|||||||
setActivePane(pane);
|
setActivePane(pane);
|
||||||
navigateTo(pane, entry.path);
|
navigateTo(pane, entry.path);
|
||||||
};
|
};
|
||||||
|
open.classList.add("entry-label");
|
||||||
name.append(open);
|
name.append(open);
|
||||||
} else {
|
} else {
|
||||||
const fileName = document.createElement("span");
|
const fileName = document.createElement("span");
|
||||||
fileName.textContent = entry.name;
|
fileName.textContent = entry.name;
|
||||||
|
fileName.className = "entry-label";
|
||||||
fileName.onclick = (ev) => {
|
fileName.onclick = (ev) => {
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
setActivePane(pane);
|
setActivePane(pane);
|
||||||
@@ -626,6 +683,11 @@ function renderPaneItems(pane) {
|
|||||||
renderPaneItems(pane);
|
renderPaneItems(pane);
|
||||||
};
|
};
|
||||||
up.append(document.createElement("span"));
|
up.append(document.createElement("span"));
|
||||||
|
const upNameCell = document.createElement("span");
|
||||||
|
upNameCell.className = "entry-name entry-dir";
|
||||||
|
const upMedia = document.createElement("span");
|
||||||
|
upMedia.className = "entry-media-slot";
|
||||||
|
upNameCell.append(upMedia);
|
||||||
const upName = document.createElement("button");
|
const upName = document.createElement("button");
|
||||||
upName.type = "button";
|
upName.type = "button";
|
||||||
upName.className = "dir-link";
|
upName.className = "dir-link";
|
||||||
@@ -635,8 +697,7 @@ function renderPaneItems(pane) {
|
|||||||
setActivePane(pane);
|
setActivePane(pane);
|
||||||
navigateTo(pane, entry.path);
|
navigateTo(pane, entry.path);
|
||||||
};
|
};
|
||||||
const upNameCell = document.createElement("span");
|
upName.classList.add("entry-label");
|
||||||
upNameCell.className = "entry-name entry-dir";
|
|
||||||
upNameCell.append(upName);
|
upNameCell.append(upName);
|
||||||
up.append(upNameCell);
|
up.append(upNameCell);
|
||||||
const upSize = document.createElement("span");
|
const upSize = document.createElement("span");
|
||||||
@@ -1630,6 +1691,17 @@ async function loadHistoryForSettings() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleShowThumbnailsChange(event) {
|
||||||
|
const input = event.target;
|
||||||
|
try {
|
||||||
|
await saveSettings({ show_thumbnails: !!input.checked });
|
||||||
|
} catch (err) {
|
||||||
|
input.checked = settingsState.showThumbnails;
|
||||||
|
settingsElements().logsError.textContent = "";
|
||||||
|
setError("actions-error", `Settings: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function closeSettings() {
|
function closeSettings() {
|
||||||
settingsElements().overlay.classList.add("hidden");
|
settingsElements().overlay.classList.add("hidden");
|
||||||
}
|
}
|
||||||
@@ -2097,6 +2169,7 @@ function setupEvents() {
|
|||||||
setSettingsTab("logs");
|
setSettingsTab("logs");
|
||||||
await loadHistoryForSettings();
|
await loadHistoryForSettings();
|
||||||
};
|
};
|
||||||
|
settings.showThumbnailsInput.onchange = handleShowThumbnailsChange;
|
||||||
settings.overlay.onclick = (event) => {
|
settings.overlay.onclick = (event) => {
|
||||||
if (event.target === settings.overlay) {
|
if (event.target === settings.overlay) {
|
||||||
closeSettings();
|
closeSettings();
|
||||||
@@ -2204,6 +2277,7 @@ async function init() {
|
|||||||
applyTheme(preferredTheme());
|
applyTheme(preferredTheme());
|
||||||
setActivePane("left");
|
setActivePane("left");
|
||||||
setupEvents();
|
setupEvents();
|
||||||
|
await loadSettings();
|
||||||
await Promise.all([loadBrowsePane("left"), loadBrowsePane("right")]);
|
await Promise.all([loadBrowsePane("left"), loadBrowsePane("right")]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -90,7 +90,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<section id="settings-general-panel" class="settings-panel" role="tabpanel" aria-labelledby="settings-general-tab">
|
<section id="settings-general-panel" class="settings-panel" role="tabpanel" aria-labelledby="settings-general-tab">
|
||||||
<div class="settings-placeholder-title">General</div>
|
<div class="settings-placeholder-title">General</div>
|
||||||
<div class="popup-meta">Future application settings will appear here.</div>
|
<label class="settings-option" for="settings-show-thumbnails">
|
||||||
|
<input id="settings-show-thumbnails" type="checkbox">
|
||||||
|
<span>Show thumbnails</span>
|
||||||
|
</label>
|
||||||
|
<div class="popup-meta">Image thumbnails are available for jpg, jpeg, png and webp files.</div>
|
||||||
</section>
|
</section>
|
||||||
<section id="settings-logs-panel" class="settings-panel hidden" role="tabpanel" aria-labelledby="settings-logs-tab">
|
<section id="settings-logs-panel" class="settings-panel hidden" role="tabpanel" aria-labelledby="settings-logs-tab">
|
||||||
<div id="settings-logs-error" class="error"></div>
|
<div id="settings-logs-error" class="error"></div>
|
||||||
|
|||||||
@@ -318,6 +318,104 @@ button:disabled {
|
|||||||
background: var(--color-list-row-hover);
|
background: var(--color-list-row-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.entry-name {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-media-slot {
|
||||||
|
width: 28px;
|
||||||
|
min-width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--color-button-secondary-bg);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-media-slot img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-media-icon {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-media-icon.folder::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 1px;
|
||||||
|
top: 4px;
|
||||||
|
width: 12px;
|
||||||
|
height: 8px;
|
||||||
|
border: 1.5px solid var(--color-text-muted);
|
||||||
|
border-radius: 2px;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-media-icon.folder::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 2px;
|
||||||
|
top: 2px;
|
||||||
|
width: 5px;
|
||||||
|
height: 3px;
|
||||||
|
border: 1.5px solid var(--color-text-muted);
|
||||||
|
border-bottom: 0;
|
||||||
|
border-radius: 2px 2px 0 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-media-icon.file::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 2px;
|
||||||
|
top: 1px;
|
||||||
|
width: 9px;
|
||||||
|
height: 12px;
|
||||||
|
border: 1.5px solid var(--color-text-muted);
|
||||||
|
border-radius: 2px;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-media-icon.file::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
right: 1px;
|
||||||
|
top: 1px;
|
||||||
|
width: 4px;
|
||||||
|
height: 4px;
|
||||||
|
background: var(--color-button-secondary-bg);
|
||||||
|
border-top: 1.5px solid var(--color-text-muted);
|
||||||
|
border-right: 1.5px solid var(--color-text-muted);
|
||||||
|
transform: skew(-12deg, -12deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-label {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 10px 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.list li.is-selected {
|
.list li.is-selected {
|
||||||
background: var(--color-selection-bg);
|
background: var(--color-selection-bg);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user