From 3b376fa8ff209b0f5c0b2e05ac8d5ecf73315bfb Mon Sep 17 00:00:00 2001 From: kodi Date: Thu, 12 Mar 2026 12:27:47 +0100 Subject: [PATCH] feat: thumbnails added --- project_docs/THUMBNAIL_V1_DESIGN.md | 226 ++++++++++++++++++ project_docs/THUMBNAIL_V1_DESIGN.md_OUD | 213 +++++++++++++++++ .../__pycache__/dependencies.cpython-313.pyc | Bin 4851 -> 5441 bytes .../app/__pycache__/main.cpython-313.pyc | Bin 2806 -> 2922 bytes .../__pycache__/routes_files.cpython-313.pyc | Bin 3974 -> 4468 bytes .../routes_settings.cpython-313.pyc | Bin 0 -> 1250 bytes .../api/__pycache__/schemas.cpython-313.pyc | Bin 8333 -> 8732 bytes webui/backend/app/api/routes_files.py | 13 + webui/backend/app/api/routes_settings.py | 24 ++ webui/backend/app/api/schemas.py | 8 + .../settings_repository.cpython-313.pyc | Bin 0 -> 3231 bytes webui/backend/app/db/settings_repository.py | 51 ++++ webui/backend/app/dependencies.py | 12 + .../filesystem_adapter.cpython-313.pyc | Bin 10113 -> 10554 bytes webui/backend/app/fs/filesystem_adapter.py | 8 + webui/backend/app/main.py | 2 + .../file_ops_service.cpython-313.pyc | Bin 18687 -> 20398 bytes .../settings_service.cpython-313.pyc | Bin 0 -> 1936 bytes .../backend/app/services/file_ops_service.py | 43 ++++ .../backend/app/services/settings_service.py | 23 ++ webui/backend/data/tasks.db | Bin 65536 -> 73728 bytes .../test_api_settings_golden.cpython-313.pyc | Bin 0 -> 4702 bytes .../test_api_thumbnail_golden.cpython-313.pyc | Bin 0 -> 6485 bytes .../test_ui_smoke_golden.cpython-313.pyc | Bin 11361 -> 12237 bytes .../tests/golden/test_api_settings_golden.py | 59 +++++ .../tests/golden/test_api_thumbnail_golden.py | 83 +++++++ .../tests/golden/test_ui_smoke_golden.py | 11 + webui/html/app.js | 78 +++++- webui/html/index.html | 6 +- webui/html/style.css | 98 ++++++++ 30 files changed, 955 insertions(+), 3 deletions(-) create mode 100644 project_docs/THUMBNAIL_V1_DESIGN.md create mode 100644 project_docs/THUMBNAIL_V1_DESIGN.md_OUD create mode 100644 webui/backend/app/api/__pycache__/routes_settings.cpython-313.pyc create mode 100644 webui/backend/app/api/routes_settings.py create mode 100644 webui/backend/app/db/__pycache__/settings_repository.cpython-313.pyc create mode 100644 webui/backend/app/db/settings_repository.py create mode 100644 webui/backend/app/services/__pycache__/settings_service.cpython-313.pyc create mode 100644 webui/backend/app/services/settings_service.py create mode 100644 webui/backend/tests/golden/__pycache__/test_api_settings_golden.cpython-313.pyc create mode 100644 webui/backend/tests/golden/__pycache__/test_api_thumbnail_golden.cpython-313.pyc create mode 100644 webui/backend/tests/golden/test_api_settings_golden.py create mode 100644 webui/backend/tests/golden/test_api_thumbnail_golden.py diff --git a/project_docs/THUMBNAIL_V1_DESIGN.md b/project_docs/THUMBNAIL_V1_DESIGN.md new file mode 100644 index 0000000..bb6df42 --- /dev/null +++ b/project_docs/THUMBNAIL_V1_DESIGN.md @@ -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. diff --git a/project_docs/THUMBNAIL_V1_DESIGN.md_OUD b/project_docs/THUMBNAIL_V1_DESIGN.md_OUD new file mode 100644 index 0000000..93099cc --- /dev/null +++ b/project_docs/THUMBNAIL_V1_DESIGN.md_OUD @@ -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. diff --git a/webui/backend/app/__pycache__/dependencies.cpython-313.pyc b/webui/backend/app/__pycache__/dependencies.cpython-313.pyc index a8e015a010174808e8793db653d52f3dda194efe..d1c5071e0b208ce157f774ed51afaad4b1c2f7c3 100644 GIT binary patch literal 5441 zcmc&%%~Ko66`#=;LIMO5V7@Gb5Hj+ISZpw64Ysk9UDr(F-E1e7#CW3;VgSV;DR0Ck zB$ut^AIN1-Ijj$R@il)5l&Esm6r8eKTfW&P=ipo3d!v?S1ghO^Ih!fXo1gl1_w?_5 zbx*ywH>BY8moNWW{I*X~{zZS}<+FiW4|x>j3q>eG)fG(zH9^>;do(Y28GCh~<_EtP zfB^sZ>0Me7f{guow-$mB{n}7+%5&fbThdASYeNsz6g7JVpr6nQBII2%; zDM&Gn=`&gy(h22>H>rq0F|@3TOJaCT`MqzkVG+3G(9MbqF5Rp{mk}c_UB;oiEJj_r z%MRTYG3L@;apQ z8zRZ*hFB2Oj26VANHJOzH^mI2o7?Hk(o3>OMzzBEQmI_cSBvG6iGRCyVLw;M7j}${ z#|jFgS}m5gO{-_ysOHSZKkOF0UoJo0%fr)8j7r%oR?BdJtpg8>CP7zYR8Hb*?fYfk zbPRrugFh(l8s>pnHTKpv^OdRrw49Ig)twLb^RS73BJ!oefX|8r1Dks9L-~)U(Q1f1 zC|3?>?p9MDP5e>CbhI?q*=mXWy!@Hr9@Z<2JQQ{ueI$`qQ`kKu#H991B?Dfnwt2hu z@_#ame>Mt@@Z$$1rB_kZ`U-+Cly&OMmkiR9vW|ND`jzrYBZ*}TJ%zfgrj(555gMTf z%H?VQ$+dc_7-_kN1}11-Y+;*2j5u%)Xl4n_VkaWeZpl%%CI5$fU=imOC;J?e}PuN(Ma>0gTZC&O)Tndv2gL(&WrTzBm9L|4t*;>7K30%1{^~>c1dy) zxP~LLc(=P?;@NLr%)Ebuzwqrt-|ZiZKzBz1>;jp)P6l&0WPyfVA(24g3VkX7UaBt- z5jc~sRaIoAS@&ZXzM3r*60R1kHD0^cc&$(?5UfxuKdtClJ7;`w0q5a}=ez|nJS0gZ zNN}P@T1mo%6NuOQc$-rwD8q+NV(D6J_9T|6#jc!;&)3FpyzcV#1YRjVf8Z>6BJ?*R ze+R*|lK8I;fOI`6Wy!mxQs#TCkfRP>$2l=|BKY8%X!B~L6y?m}jS2Tm-m2ghj(C7~ zeH6t@)P6HkHU`+eUIPPsc8Lm{Gxisoy_QC)cz1g>m6K59BB*j z!V=C5L=JACK+g>jrmElBbAv>aLLgVJm`$RzrG1FEnL=R`9X*Ln)ne1HM`b)fn~oiSZ7%9>MxAufUF`Z1-sT_*d)>pw$;F!7+8Vxz z4!YM#mgRMBAo%WeexGz(r-HzjOi5(tYel5b_7+jJ0$?W%d(991*k*f;3N)wExk)!f z^b2@WvbQH`gP-6afp?usylsOq={rurNW2zFG*YnJPbuj42O5^SHurzE4BW>xXZ#Su z$M-hD9-jK#CZLM>gr@i@i4Rb?Qt=%Y7fL)&%axdf`0dtMI@!OyP;$C8_hacY4SO1R zo2|Ms_Eo8tzIpuMaqYpAqwuGPzE9r*6;v0IADmx6Wfd15#mBj^#it&*8^~q;40{*x zc4XJ+@yzYo%<572&Y|zlTeyc>*9zaEso^G0js~XGb`-U?n!sh-WUMWbtbzTPT;dirYX@!yiCa(y86t$Tm}_ zb%Ft6ZcVjyl(FmRM^-sAVbeQ~j$|?`-Zo+TCji;r{njlr+d4}%zjer8)}(VX(GoG zhMIAdue_?No+`<2l*w`equLc?;5WVSF k)phkvumP9f1OwO@WudCSLyfB5C9$sl8|9h`3AEFH0I?WyK>z>% delta 1695 zcma)6J#1T581-{($BF;qXUBD1$90lAiCxEz1Equ#3aU^`Bhlf)K-0=N$s;jtV(WWO zq>8~Xqe?{I1QjzX3sNhjuIvmTIvKICFrvc10(|E>FLn$_Sn~C`KKDEC`#JZ0Q+SpO zKMRG%UGjNw^r*fXItnM<-x^1YPdm3qW?jluu~kFOsd&ouzULy2$%p0!xbtfA6nB1r z%c;aEE;qo71RZl*64yNs%edi zYDr}@T2du-PNR}4tE@(4wX9||THcw>SAKEvi|!r%+WnY67K>o&3)R4hnDkzW`^f^Pq(tCLW` zBM4m~(3Hi}>zANzq+jU4{=P+2mVa0HslOl^fBE-jmSitQh7pFo_oT8w$6dcamh?U+ z`^)_0*k?Caz#xc9vgAE90o;%Lwof+(@s=USy*X7;ZCv9G>!C6Q;%w!_&rPq|u=YB1R~Ni2jJO z*!C@AqAuI2(bn$CY~GL~7LGlS1urEjNLp^S4``ooRV9%d;BXBcjEm=BxiFoR4Xo}E zFs*~(ItRl?4u%sPjKMk>qjE4h>R`0Q!6-Ig>5f;c{Nu!ZK8j|IF@6+XFaq3-=8Pa$ zVku6UQ6t1fGw(wR-FdE-uDE3SRuKG7Fe<@a1S1fJOPI^BdHgT$ zHMb8Ucdb|Gw$OOX*?+h=9Z&M5c*eK-ldJrzk3W&^VZ6zI##aKr`we5m&=&X`bOcln diff --git a/webui/backend/app/__pycache__/main.cpython-313.pyc b/webui/backend/app/__pycache__/main.cpython-313.pyc index 83c89429afb6d672924ef8bcf4b9d63f0ce11416..c7bc97dc982f6e0afb6591a6cd4741533b94fabe 100644 GIT binary patch delta 494 zcmew+`bv!NGcPX}0}xct-;}AzF_BM#v0$S57JqhcIWN8mSdC*R3$|RvN;fTFc&M35zMX2&A?Ek z7NZvAKe>ZZJyt`BA($sdjgP^Y3Fekm#u!$hC~q*IC1a6hjAl?M7Ss4+xPb2024Xds zzF>i1K})7!peG%j5z^c~+Gel^}1ReyPdp7P zKPiZjFAqwPbP^4RnDUjmM8tOe7h;lsdJ}DvP*sRJg74jBJw2P)tIt4H2 zMx+~Qm_u3N5RFDz1S4qyFy(wjdyw|PPiK-Gg@|j99>+iyVp=!J!g(!62H>f-=+-mK zw{FkREEHx-u&1??3-H>RTv?E>ps-j|a$a_D#;vk%RkV4>X(b``D>q1*POv18uwH~L z!U+y(xyero-UuwQ;!=SXrwH)UtepGo4%c12)nLsPs`~=#!J4nL?hma`)cnV`ooWlU zCDlQHl~u0sFp(m69hq|BPVu_TeF;?kG3kY8dLk)W8s;%(awuc^&uix}if|D=>J>5q zm)+J1-ic`((k{_ZL*Y#_{I7;Ov$$AlHt5GHQQC&3wAuR^jN-4dzj>U-eJLjLAR zLzUtcjsHl&cemvh2l`Mj4o6ySOAOR0loEp@MrHtAd|{RY%VX-}oZ=Xl?`@v-?2(-L zzmFO-_KPPZe49$t*md&Ajv~pXEpuiEVAwmVj&Zr) DCLORv delta 488 zcmeyO)F#jOnU|M~0SH{DZOZKBoX97^xMZUGK1QjD&%7C>CmS((Gs;Y^V+>@JoqPev zmYXcY6euhoEGET}#gfHn3epJ#Me>u+F^Mya#|Tc2W0V(H2$n#TS4fxCRNO4bJe!$O zbMh`Wbv9LyF4f7;+2+ftgP1ZvqL>*-C@3ft$$~^Qz{KRm>~q+(K}_w*t{kdB-Ps)f z*bG1dhLiI-H5d&i&)~FUG@pE(Gk$-+D;s`?-WmLNh3NE9;x37EN7AW;zE zIyr`?fzf*MT^{**Ymgu(kkI5PQUFr7n2Sq_ia;*A#gdetp96MZkuZ>71Xd~tWNEU2 zl@{56L_zLHk_QDT)F_C&G)SHei%NEoN=3FJumXN83IxCkChy_>AO#LIP?$pk2OKQs hAg%}*0S6o0e3-R*W;KIB6J#;#Q%h@oI4glJ4KUThO%y5ZKOsb#>Tw(Fa%@#6w| zkbl6$n_Rj2my~eOY(fI@fZnS0;>|bHT_MIeP2c?9%zM9iUu`#&Ndc`_A3oN9XaHZN z60bxLuXIbE8FpE}vSWZCrWC9Q1aplmO&-pzG(9^}W%c&TyNFgDmi=p6>r9DW+NIR2z+EPz~zMhM3kud-?v|QnM9s46Qrd=Z#f)I^gjj zs5jO{i3^_$c{mcEtoWSc0RVEThm9r zR(~GKi25*kr1GS>${*LF;;59VdGH2+b*~V(2re~j1xlwalo>!=xJ$68ud!WrkBHN! zLnGaaq7%~J4X5%|-_Jj&)*eyT3RnGl9%V7apNwYx7uZkcN~8&hQ4-%^6FNyq4}Eg7 zdro%WxzIK){1OITy&fE5H5qoibOI`cs|2DH*uPR(fp{)@Nnjs2P}uz~Rq{U}QGYD# z)gK4QM99}%7YAv>p;$;D#z?4GQ!3Wno|D^I+;?W$#!M9LnPDoGp?J_H>(E3Vw6K9D zDbpqn(}tE5@+oD4BND0m!7x>+thLC2S1Kqo4Xw4R2&j-Ki-GdT7M_i2JWKVaZ+%wO z9`Ywup*R$IICk1(Rm~x9)aqPdQZigVeI0jkHlC+=>f!`?oLEDE}hAl5IZC~I%Dhra>p=v48;RjI)LRP`^@{9J$rJ~?3kb>-;QsM zZ{Kcb^PA&sHg}YrY){?T&))3lI?H{|oKyQ@V&rFn^sWxtd`BbHNlVv>t%>bY+nw8- qXtVk6Cb(0PbHZR(KWB0so%TZ_kvhF*j{dX=Nt$clH9{!=7JdUrNFo9N literal 0 HcmV?d00001 diff --git a/webui/backend/app/api/__pycache__/schemas.cpython-313.pyc b/webui/backend/app/api/__pycache__/schemas.cpython-313.pyc index a9f14ee88c19298510fd8c09d0eda0a6d5ac0aa0..a775e50d21abee59527f1d90118d731d3e3b2e3c 100644 GIT binary patch delta 1288 zcmZvbO-NKx6vyZ0(8r9!%u}B;?>QfhNo7U32{I}(Dzm~YY15aD_|&K4jK25GR3t%B zDn#u_WJ(G_EnGyk3R<`*an&XeaoH}~1P0L}=>E?$D`?>T=G=46Jsb;{pHtX+N@6u=w`9BPotCclhD^##+jLPY(yzb_z;A7< z#E54_)t0>)l_^!l>hE}$I~r4tamncHGHmCwfn5k{U89LeT&sr&u;DsZ`K+RgszzUf zUBaZMkRw_tRTS|xzyz4|GgK}b*r`@27dG8f%9ClB0b72*OY+B-y)idvn*Dz&KN8*W zM9o$sz{#5+H3KcA=6C0rQe5@fPReqO#`=_FwbGOPlKqHnIY1Y13*d0KA@u;gKp)_i z-3bxsrn~|YowAIx^tB)mx(5T7v7T2R$V$BsF|k3~}L)!(lN^ufhj>cVPym=}TA_cilXZy2Km|B>*2UcNoX=K49YJeWMV8 zIeHT*5u?0$Zrd)3YlZrAsqf88o3?ZHL^j*jp0e7i9i#oamEoxw$6f$_RuRCc!!2$4WT zA%a9QC`3#=n8*PyiZR^e;N1fWX*l}_7&&;<_nifl#7*{-**Ej%edo>W>nqPLDSL_{ zmx$-|%a1AVqM}N#J{(^ywGA}aw`}|@ZBQug%t;z;J58yN5`I5DtyC!EIxF3w&d^2r z?s7?K`sF$o?H5UOAT8d#GJ%f3vE`a5l%Jl>vPTve}~>JcyJfLeeG2&&56YX@C2AZBjjg4#O@W8d2sbT(>sPvF!KN?^@On$YPI9)RDVH4A=5^OfY3) zOr8g00jvN2iLs_=0UltDegqTJg3#QpeiGkfPA>R{s5+~U{!?#s5nBL+8BmA6A-H1; zTJjfJzAvmvn=~4}ATJ>v*rYGv25D)(nGsr7WonH0PizU_n3s?1N^1K3hqsePZqVF6 z6T7bh*Kl`@%P@g0@g5AZBf4#i7AxF^Tb2a)JL;qKIWpYLKQI3*JpVknJYPIP+;r|B eH-)c*3uM*HG*lCkS4B1nrw!1Hny)k*UH=0Emk6Q& diff --git a/webui/backend/app/api/routes_files.py b/webui/backend/app/api/routes_files.py index a6799d6..20aca79 100644 --- a/webui/backend/app/api/routes_files.py +++ b/webui/backend/app/api/routes_files.py @@ -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) async def save( request: SaveRequest, diff --git a/webui/backend/app/api/routes_settings.py b/webui/backend/app/api/routes_settings.py new file mode 100644 index 0000000..108db37 --- /dev/null +++ b/webui/backend/app/api/routes_settings.py @@ -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) diff --git a/webui/backend/app/api/schemas.py b/webui/backend/app/api/schemas.py index 863cfeb..df06328 100644 --- a/webui/backend/app/api/schemas.py +++ b/webui/backend/app/api/schemas.py @@ -94,6 +94,14 @@ class FileInfoResponse(BaseModel): group: str | None = None +class SettingsResponse(BaseModel): + show_thumbnails: bool + + +class SettingsUpdateRequest(BaseModel): + show_thumbnails: bool + + class TaskListItem(BaseModel): id: str operation: str diff --git a/webui/backend/app/db/__pycache__/settings_repository.cpython-313.pyc b/webui/backend/app/db/__pycache__/settings_repository.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..af9a6a35222639c7535ca8fdb18ded05a3bb2675 GIT binary patch literal 3231 zcma)8&2JmW6`%d$Qv8xl%8F&h;@D|mucOLtZMRkZ5l|A1NTeu(OQs5(VzF8-W!BVE znO!Be3pYUzg&d@ibr2E*1?--Zpg@4+lpcCe{{pK@Dq#@=ZPSZ;aw`EYiX8gh?oy#i z!pIPKe6#P(n>RDR_nX;?$0G#VFYkR;>j@F^HFla!^c8Mr0+cnP5|zu5DUNXzbw0;4 z9{z=#FeNh4`zGe3sSpcMa*!M)s&t5`p|iXng~{{0B0s{8!nsl1vKo%=)EbsufPTVg zSdMw!STM1BEjs1a50G)axB zqNjz_7^VVN`Sq|GIz-L}QCW{Fv&ei{iMvVFbex(sZx_wQhFxjjRb0_T}TaE3dJF<8KqH8s)=4hHb*twpOrm+VC z+bIz5knMx%_u|{(*j9LOBRu$J`0x%c!QfB^4M5>9`T;0wxX@N>;M80wPb{v?b=wM+ zI~$BEO2MF(zvg%g0R_J%Xejv^pCYdcHh+rPpuG|$q;Xp1F+^EV1^n0e={O-#Lczll zzDJIbVRAf(U4c2PJSAzcQc^f}K}eG-XTulttrA5n+)S(4e0ID6?0e$)uNx zGt;!W$F2YtSMn^1;6^oos%07oT~{`*o5qr3y5Xwn7+3XrT@e{%LtGKPT#+^2vRwfs z788ZWdeI!4Hyv$nhIE(HYIVSf_ACfEvH1S2_~DKC;VYeOrq~DG7vE+q zl{*Ks>W84pyZRN8!`hn4!%rB6+J-!VC)!(+V}PUhGGD}+YHO<4oZq|5p5meJ1Wc)L z5jV&sE;mvFMwb_LW?GKzhRy3W+tC`=ZvLSIBICs@f~u4m%V+82C0dv%(d_J`T2fnZ z+%XEF53r=A>}-i%E>2Eoim%aJ_O;=zo<6O4z8Jnx&gVP)I!i#284@hR&2&TlT3o?) zm@6$@tJGKvv;~j#SOVQWR3mpliXPjs7zQ}f1j z`9Khgjknw=TxnWWH}Ik4hK$C-Ld|g{qu#Jhpgf$)hk^fy@lk+8*p<>+Zq%2+$9@IG zBLKlad;3@3e)sJUoO`$K-rDT_{<8Sh;L&^M?w(r~zX%@$cIz3~PWHb$wGBs{yPbP) z`fs32-Jbew{=dr;zVeOzJOumok20sZKlWvY$a;J~sOv+?%qe00lmNQIdCP*wnUCIM z;18u6Vy3gitec5S&2X*&=7%YWpDQ7szR}C)a8LUobX~E~uyFO*VL*07iQ>u#tidjX%vW2hPd$R%{`!LHolw< zO?Mntn}g?vt!W<p2~F3(FtyJ124>29P)O6>N>q{QRrNvQAN zyetju2%znt=7+(-N*I%_F2O!P(hMt_;rY0c;IUq>&3Snt&pB7I=h1~X`zU(vW#op> q`-{KGE`R}V6nhZFV}awihvewjB=L~E_>dfY5_*Xn_zwZ$4gNpY;) 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() diff --git a/webui/backend/app/dependencies.py b/webui/backend/app/dependencies.py index 669717e..bae6229 100644 --- a/webui/backend/app/dependencies.py +++ b/webui/backend/app/dependencies.py @@ -5,6 +5,7 @@ from functools import lru_cache from backend.app.config import Settings, get_settings from backend.app.db.bookmark_repository import BookmarkRepository 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.fs.filesystem_adapter import FilesystemAdapter 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.move_task_service import MoveTaskService 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.tasks_runner import TaskRunner @@ -47,6 +49,12 @@ def get_history_repository() -> HistoryRepository: 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) def get_task_runner() -> TaskRunner: return TaskRunner( @@ -100,3 +108,7 @@ async def get_history_service() -> HistoryService: async def get_search_service() -> SearchService: return SearchService(path_guard=get_path_guard(), filesystem=get_filesystem_adapter()) + + +async def get_settings_service() -> SettingsService: + return SettingsService(repository=get_settings_repository()) diff --git a/webui/backend/app/fs/__pycache__/filesystem_adapter.cpython-313.pyc b/webui/backend/app/fs/__pycache__/filesystem_adapter.cpython-313.pyc index 5d90b54309baecfea28061eff84a357daae1f9b8..e5ed6bc89fb2fb578e0cd4dee4029b340004ee24 100644 GIT binary patch delta 747 zcmZvYJ!n%=6vyvlIh7YEP1FWo%D|Nj2x{@>x9yEeCd-oNYf zxe3;0r&s5(hyM4iPv|zt0$~&gQ-CrRBA_J6vc@zR167g(qM%7~LJS;|bclmf(g-9# zm*hekk>rLXxFmV-3%4XMl1Gvc#=!s|az$IF%nuWg0>8LIH;Qf_j1FA@Oqhhg&;`+@ zAvkoQg)n3wBwSH!g~cKeW@DGVd8^-{UDHtQwp1sost~%niI5>C|40)dphaqD6w**r zq^$BCJ;GaIcdDV4wQ2i~T*T0vUe*q2*T@Dn$r+PxnTJm(XW1$y@(R0+2fHKK!Da*( zggg`STu2+G=|C|>u>(k7JZ0tu!$Ger4S?1*s5d}g-1kq5i%bHe`5>qN+N23N|^a8QTag!!N zSvsp}&^rh{fP*0F1=@Ri3ID;`-(vmihyA)!+cbZ@Ubn($x!x>aMQ_ZH%XjW>m0g7r zC{zWZHiV$Yl!`_*gkh6pM+BNA9Z}dNIWPiUk`tqFNOEy0g$)%rf4{}NWxkeX; zTQ97d>C+_K=?M;xqe9&-T%gSvTZ@xMf9HIdj1}nYs9rxU?c str: stat = path.stat() diff --git a/webui/backend/app/main.py b/webui/backend/app/main.py index 9b5740b..e07a32e 100644 --- a/webui/backend/app/main.py +++ b/webui/backend/app/main.py @@ -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_move import router as move_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.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(move_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(history_router, prefix="/api") app.include_router(tasks_router, prefix="/api") diff --git a/webui/backend/app/services/__pycache__/file_ops_service.cpython-313.pyc b/webui/backend/app/services/__pycache__/file_ops_service.cpython-313.pyc index 6e4dec5c5d2e8040097dc07cda2f480110cca34e..4af8890f40a008d55243ac6ff2d467a38db64f35 100644 GIT binary patch delta 2080 zcmah}du&rx96qP__OW(r$GY}*-K%V{^{!B`+t>zmY>e^BUU0UNxj`xAvUS}M%oz^`61rf8|)rZ zQlu{4$qY&-8zWm4wZmNyfY(45zz_kfKoE0mX2e-RFW(C>kFpTYI94JnQp!w)vXM$= zYLtU=k$T3fL3v0c>nSK7rN~T6(`-mP&I?fnG%D80jepMG~cM zs8iEZ;Q)YB)d~`=v=T#=ndr^+B#>){3bH3ZlYDL0kb`D3xny2MzR4BlsD?a&yd>o2 z1h_WNX91uyW#)(hQX>uG5Z{)J(4&;j1+&AQMsj3j9yzDaQEV}j^LiusT%}Ggr%Pkc za@fYTfh`uYM`VJQ&yg!cs{&+Y+?H!}CT4E8fPz<_QK zBy^!Lmc0F*-Sl2W9$VRf$e@VFE9Jdh*!yz$eMu1vy1}V9ALI6;Yw$k|9>v60K|*7dAhi8rKO?UH+{MYur>Ex2$2- z#&nP+o-I7<8(kK2ZHl@!#azu%SM#`QbIjEdb#;t8pBgXe922_YIrg}a9TSS9LUBwe zjS8is&9SnEXj#L!(0EVHr)wtxPZZ@Zz&9d?%io0XE#j!SZM)3&_!)yw8L(F;#BOYQ zt9XPgt8~DVq_c7<`Ka7XUa9CA7Fsv7HLhu@-Pqu%Yi??-Z))|lcC^$Z{6ATvG&WH( zzx$nY&hXzic7~mrjTRjQw+%%xLrK(7@_yO5j8&&r#T@Ra!#(b(9yhEW)2t@XR>oA< zsT1EIJF2Q@=}1)#JV$=4dH}y7->nS8FC(q1o`k9>Gu|Yxy9?kLx$IsFZ$<973pjX@ z=xVmYABe98+vPraj~WwPzdw-BwtM>qrFx79u&BkSSiorp-;zf)%XMcMJ8_CRv-C zQD(sHNhT(le9-dfjigqVWE14{+QnV}SuC&U7wQ%j)9a8|rw zlWR!fI=4v$R2fqWsH}lg9Ho;oCEM5i2CbxZ{e9~d7WD~(FBn{9Aje-M+csFVkLYh2 z4?;)C@CILLRy-{$o+?nhm!iy8i~(U%57I30)VzC2PN2Vyf28hEL&vXxp}gXM0Or#t AO#lD@ delta 1162 zcmZ`%Ye-XJ82;X~?Wl9ka_N@lT*oXAnM4?I6ctYG3RWF3#U~o-0fi+!~8;q1&bAOF~&VjZryY1!(I@bO5l(m z7(2xp7zborLEsk5#{#qzvR&3)z^&*;D{*c^585PCaXPA!Wndu|VaBL86K7zi)U&V{ zvm~>d)x2mQb!0ajE^(T9!+BI8b}bYpyGiI z_KRZ+KEOqBGUOH=3ztLCc(Je-#MWhdpxqc;b`D^lXsa6b_0sRTM{tLLRungX5A$&^ zoiB-a^;|eC&R5S9Z~Q~*LlPf}`z!oo^!JJY92M1pAMjW#S($)y#*LNLpgtwzGx2ei z22VuZ>UnU>2&~pD&?6$jEpT7-1$p^p@^qSzvb4lnQ})d}o7g)INikeonvOaCjd|DV zjWRErV(NrE8sMirp5VTNzV&UbQBJm$wJ{b+n7*6lAzi<8oXI-WbF9CWLQ)+T{cB5> z4%2xvfn;(<$>drkdo&(hG|rO3Q&-mT-(=a~t?Aa4bTc`En%wZiw%7Gx9HyZ4E()poXAJRECSZJw#^`$gANZMxdEre3NJj zFHk=15EsH#;%j&Wri*v$MwZ+qlR?l&Aaf?yNo7{Gv^BOjNBw-nw9ws)?-ia6kz!BM X;r(p0xRsa8_04fWg}GlCp`76_{U0Av diff --git a/webui/backend/app/services/__pycache__/settings_service.cpython-313.pyc b/webui/backend/app/services/__pycache__/settings_service.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..442111e7ac2a09626ce97b9cca183045b6760109 GIT binary patch literal 1936 zcmb7E&2Jk;6rb7kt~ZXu#z{k?P${lSX$`_Dpj3*02vVsMiDV>?A_1+m+Sp@f)Ag=9 zvu+gvQp5qZ;=n0aIQLe#Ku_=|*b2zesFjd7px!DRi7RhrcT?jcBu4hzH}7lS{N}wk zoSMoZSo`08?|q{q^t;@oD-$z#_#QBah#-PD(FSI?D0yvD+t8W5kzpCN*Efv~lbJ;{ zk4lJS77#I3v^WCGw#;(&iQJU2m~?|6jNHfz176m|G@()C1ub5qyaOUC=2FR4r{PAl zMsIZ~kHB*#Rn%xF{MR%yeQAZ2|2-qc|Jb$)9 zn{L;KN?>QexoogJd_=xQBfumsA|{I#U~###9frQpL0Q(=l$4scT^}kbftuMMV=qd@ zxpC}9BTj+^Uj+jA73Kzo^Zmm4L7~zwRE`Rl@0yS7#Rr)mroNl{vH8>P!`ma3=xS6kKJc5K9rwvbZ-#Ar*F4wQcIf#o{=;V+Qge z_**~#VJknd=KI$Cz*_2COT7zMe!e(Z{}_G`&7RZz#cCbfvje->w~I%1sb`g*$M&=t zs}NCQE0N!VvH~1Ni#|at#o@ooo8Y&>8j#YeYZ82c-w;|D2z`%ob`Av4(FTv0*Aa#v z-l0tHRk5)-(2kDG|S!sR|$N~Coa{Au8$FmD}tH<`4ulMimfA#smF7@rw zk-gNrwBEPZd)B(TK9eO{g*SNtzRUyy=bMa1T^6W*fX7^vD%qp#RY~M6$|jR#^Jxw; zFMd|6h5=RSij3@>*zPrHmy=^AaN88dOwMuIVWaCyKJPfUx~`vSY{zMOj7Po~&>(ai zCWoP_7FGmK|L R(ffay)A%a>6G^Ik{{|VKvy%V- literal 0 HcmV?d00001 diff --git a/webui/backend/app/services/file_ops_service.py b/webui/backend/app/services/file_ops_service.py index 5b3a402..80fc5a4 100644 --- a/webui/backend/app/services/file_ops_service.py +++ b/webui/backend/app/services/file_ops_service.py @@ -25,6 +25,12 @@ SPECIAL_TEXT_FILENAMES = { "dockerfile": "text/plain", "containerfile": "text/plain", } +THUMBNAIL_CONTENT_TYPES = { + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".webp": "image/webp", +} VIDEO_CONTENT_TYPES = { ".mp4": "video/mp4", ".mkv": "video/x-matroska", @@ -370,6 +376,39 @@ class FileOpsService: "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 def _join_relative(base: str, name: str) -> str: return f"{base}/{name}" if base else name @@ -385,6 +424,10 @@ class FileOpsService: def _video_content_type_for(path: Path) -> str | None: 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( self, *, diff --git a/webui/backend/app/services/settings_service.py b/webui/backend/app/services/settings_service.py new file mode 100644 index 0000000..10994fe --- /dev/null +++ b/webui/backend/app/services/settings_service.py @@ -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"} diff --git a/webui/backend/data/tasks.db b/webui/backend/data/tasks.db index dc3df1f2bfcd6cb199e6c3c1c8fda6d465d66e2a..ed35ad77ebeec007b9d02e624cf3f0b377090ed2 100644 GIT binary patch delta 299 zcmZo@U}-qOGC^99i-Cbb2#8^Tb)t^3EEj`bRUt3`4+aj7!wfv<_|Nj#bE)wNaQ1T? z=8W6e=+0r@D8bGyE-TB}#9We?l#^PVT2hjkmtG8`1)YOj9Yb6dLL8lZToquV3L29q z@{2NOr&dlbfB$p4ms|1D76V}4;FW^GP1WdMA1R`dV> delta 65 zcmZoTz|zpbGC^99gMooT0El6LWulI;BnN|DRUt3`4+b{Qeg>X%{AYRWxzu`G16%`n~_+XL&_ z&7E1ONmV(C8r&*U8bzo?c}S{Os*&=LR{cre3Qu|5Wr-F|KuQ($fj3f6NuxgWoI5*S z8w@nL_MSQS-gE!Xch0%z-mb5&B~YRt{m~ed3Hb^uUV%CmdOJ=DIZqU#&%%PXP;j5m7F=eF|QND~d?8`(zA`zIb7Z)A;}4@@+TH?wBW%M&f* zkF&=kBuu)AQqxA1+9@g4cmFzrg4@QzQ>{wfz8?{y!6`v@;C|U|;_Wy>W_`21Xz(Tq zMX6J#nP%43Y$I!0lTa65)a<#NI5kw7XLVb(blWz}Sqm6;+DPfp-l$aCVtOuX8FrQ} zT%?Ym<#N#3;B_jld}{S}Dto54$D#E+nIeoT1kj{_WR2o$BPS8d8b#ooq|_>PiiG@0 z%KXso=j{RD0-Tc-nbpjS(V)}fEqf$q9Lr|Xx*69k`(_iYld2%eHxd8}yP&uK1jMYEOQQ{R<%NbQeb36KS3r?GwJ*_ilr1ffVWliVM zx`6jtx}C`N9mr%;TE-ggd+-FUuxAS~`{pKDB-fhSmL~sWA#QI9ej4>Tex}=bW-<&@ z(K<)A_4(Y4kfBxL|-k7Ud_e%3H6=!FTF^QD2rjigL&2awqEo!O0YwfLD;` zDvAN~3Vp?g!AJ2WXhKN%1_XsdKtq=n9~F=iVl^mvp`wRGg~>GzjCRN!n1OQ+3$6n{ zYU*pKm>EHL|jkDgv7s9d-q_@Bf*4sdMfi62_IPLfzgFf^1s$wu%S1f8QDYsOh=mh ze-VKDTl+|daH*M&wD>Qz2*`(n(0r+fj)eS|o{*5=)`aZ`=}4>p(lE+grYLi{o{qHp zFK_T8-$JoHM3Hahd^_hm{38Q_%UkHklflb9LF9J^Kql&AA=u2f8#sb}F&&ULEgf(V zXb7B}NayK!&?$hV8+u1~5t@HGK) zX9L+7dgrxYzV`mW2fIrR-37Uu2dFiP5!wYfFunadV9tYU$H2JnEefO|o>Q6>WMIU- z1sroFj8yv+!5bIBxz``Jl2%p6c|l$g6=^4}&HgzlT4y_;`=XCy;>fH5 znuRx8qyoccOkAp&NLruK@);X+?*i-$tnWn+ABD?WV?Zzo3H|!uy9YmNS#Bsb?Ol?; zl!IkCT$IBX!sXW9Vr%cRbyeO4ca7KjVaNL&A9NPBANW)+Y#aMrK6*c_7`HjB8vxFS z!Ky%R2@0+N^BuvJs=$0NaNV_0*H9j4AnTi?ziN`PhZ)d4Yk&a-CgHvFf)Z!LuqrzY zB+d>Yi)O`+AVFZ(*g#}0n)zH>vvoD6Gt01SNb7WG_y1!B_m6u9S2_cvY6p)mcNX^l z;InjLpHgaye&%IPgs)b`GK9@Ca zI*=e?!c>W1jyRb$bvLz=-8j__y>77L97e3Ob!j7IM}8XN!WjA3UYw)WkyRDbUsZK# zRdqg_&S#Jhswz5PrKdqvXAEZ98N<}gtg5n3oZty0=&$TqB=}l%T2vL?)kvwDZ8Kv! z4@iJ~BMNjO!Niy0D~l7JJY#@;i`?pt_DPX{%d)iPPO~UIOYa6HX_~r2aTIQM zUNcNpjke>4XY!E$!-g5U1RFvL!CJ6bn9Jr(!$woU+tr@S7}Gr8XT8`dVw2-HAm}rj zp`vhZd_2JM0}w8VpS#-^%;I(A{@|)4is98ecf>gS`55b6*`B zR}11v+-%{A)y>|EtvXAkg}+f8ISxk}g^a}2kS~c z1WM@*()|_L@CB*;f;7PY4HEu}1ivK0Ir(+@T*K=PzoPH%de^@2+l-cqQ) z9NJS1?fH}zL;Ffi`>&D%|0F{<$dMZ)0`!i*hfdQA&y_dz7B}_YAy~ULNr@O-I$IK2 VuZh8P&%g2f&tJGD5V4Kp`9IFI3R3_8 literal 0 HcmV?d00001 diff --git a/webui/backend/tests/golden/__pycache__/test_api_thumbnail_golden.cpython-313.pyc b/webui/backend/tests/golden/__pycache__/test_api_thumbnail_golden.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5a3fd1e4cf2c76f10ed8fc208fc23607a4431da8 GIT binary patch literal 6485 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 IEzITr1H3Jw&;S4c literal 0 HcmV?d00001 diff --git a/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc b/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc index 3f367d197f7aa6a0dd2d4f6db80f2ae28602d1cd..76e07f098bc6806a9493372692492fc282884407 100644 GIT binary patch delta 853 zcmZvaO=#0#9LDpeNy$djIjvbc*c#i-ErTTWqwCsz=$wP?Fco8EphaVvZE;yLnsj3U z6$F`rF!a565xgiv5$b{U-Dgp0@hT!L53;Y@yg#|nUdf^E?3Cm<2=Ew%n4>>Yf ze=HwJquz1^p%ESt2H*#B9vrvNh9^S%K8cX_OX@Lbc4`{)0F%JxejAFr8bA=RwnV@% z)?z=3uHf)zjDp$PUGR4-0t=fHbiisX2_J5`Z@1?6+AzG*oCDn5coOiQq&zv`$w5yJ zd2-m3G#rYvc&CsNx}X(rt?3l9LRS{mg77L%VR>B0!BaTBJ0BFfcN!T-m>zpQPJ?Sr zqA{mQ^jKMwOhwmdRTqoGRyFO6wO5=K74sjr+ZxzzkMr6)GCpQrxam>*UE6+? z^-X3m>kw%lQx(Sj!F=Q@H4c%3Ri;V^0MGomjP}^yI)*`N6b0FP#7dO;7-iNGkE$~m df`x8cp8~N}EHpoIedNZ;Y5>6+OO*RLe*jjY5##^> delta 282 zcmX>b|1g5@GcPX}0}vdawkh+2>_)zS%#1FZd0BEeCf{UJnOw=^G5IM^#N@NQB8;|^ z_wg!EF69kn(Ps#DoGixI&gisx9$z-oW=p{sW=6Zs(?t52Ham-NXJoysDitC-`M-oX zQy%+dJ{FbDHj>v_C%=}rV%7*{nd~5?$gByZ1LTz%Av|q}D5K6~B}H9M-C(_7eM_c1 z*2#K`imbZ92EqE1>l9@f4JQY%DhP$L1sj<%ft<|1pumvMplQ7MoT38b=FQ4mST?WG zuw!J)1j-bvPX3^IeDX0Z{>j_5_%|D9|7V;Wqx*p)kdf8=dm5AR<`6wkMiE~oM#~u- V*CjMBN@#vz0P#K~Ox~z30|3KxQ5FCI diff --git a/webui/backend/tests/golden/test_api_settings_golden.py b/webui/backend/tests/golden/test_api_settings_golden.py new file mode 100644 index 0000000..57eeb34 --- /dev/null +++ b/webui/backend/tests/golden/test_api_settings_golden.py @@ -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() diff --git a/webui/backend/tests/golden/test_api_thumbnail_golden.py b/webui/backend/tests/golden/test_api_thumbnail_golden.py new file mode 100644 index 0000000..6e6a03f --- /dev/null +++ b/webui/backend/tests/golden/test_api_thumbnail_golden.py @@ -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() diff --git a/webui/backend/tests/golden/test_ui_smoke_golden.py b/webui/backend/tests/golden/test_ui_smoke_golden.py index 304d8df..44ea591 100644 --- a/webui/backend/tests/golden/test_ui_smoke_golden.py +++ b/webui/backend/tests/golden/test_ui_smoke_golden.py @@ -63,6 +63,8 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('id="rename-apply-btn"', body) self.assertIn('id="settings-general-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="viewer-content"', 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.getElementById("theme-toggle").onclick = toggleTheme;', 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('async function submitSearch()', 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('.settings-card', 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") style_css_url = app.url_path_for("ui", path="/style.css") diff --git a/webui/html/app.js b/webui/html/app.js index 0f82fa4..ef79320 100644 --- a/webui/html/app.js +++ b/webui/html/app.js @@ -47,6 +47,7 @@ let batchMoveState = { let settingsState = { activeTab: "general", logsLoaded: false, + showThumbnails: false, }; let searchState = { pane: "left", @@ -200,6 +201,7 @@ function settingsElements() { generalTab: document.getElementById("settings-general-tab"), logsTab: document.getElementById("settings-logs-tab"), generalPanel: document.getElementById("settings-general-panel"), + showThumbnailsInput: document.getElementById("settings-show-thumbnails"), logsPanel: document.getElementById("settings-logs-panel"), logsList: document.getElementById("settings-logs-list"), logsError: document.getElementById("settings-logs-error"), @@ -357,6 +359,58 @@ function currentRowItem(pane) { 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() { const selectedItems = activePaneState().selectedItems; const count = selectedItems.length; @@ -547,6 +601,7 @@ function createBrowseItem(pane, entry, kind) { const name = document.createElement("span"); name.className = `entry-name ${kind === "directory" ? "entry-dir" : "entry-file"}`; + name.append(createMediaSlot({ ...entry, kind })); if (kind === "directory") { const open = document.createElement("button"); @@ -558,10 +613,12 @@ function createBrowseItem(pane, entry, kind) { setActivePane(pane); navigateTo(pane, entry.path); }; + open.classList.add("entry-label"); name.append(open); } else { const fileName = document.createElement("span"); fileName.textContent = entry.name; + fileName.className = "entry-label"; fileName.onclick = (ev) => { ev.stopPropagation(); setActivePane(pane); @@ -626,6 +683,11 @@ function renderPaneItems(pane) { renderPaneItems(pane); }; 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"); upName.type = "button"; upName.className = "dir-link"; @@ -635,8 +697,7 @@ function renderPaneItems(pane) { setActivePane(pane); navigateTo(pane, entry.path); }; - const upNameCell = document.createElement("span"); - upNameCell.className = "entry-name entry-dir"; + upName.classList.add("entry-label"); upNameCell.append(upName); up.append(upNameCell); 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() { settingsElements().overlay.classList.add("hidden"); } @@ -2097,6 +2169,7 @@ function setupEvents() { setSettingsTab("logs"); await loadHistoryForSettings(); }; + settings.showThumbnailsInput.onchange = handleShowThumbnailsChange; settings.overlay.onclick = (event) => { if (event.target === settings.overlay) { closeSettings(); @@ -2204,6 +2277,7 @@ async function init() { applyTheme(preferredTheme()); setActivePane("left"); setupEvents(); + await loadSettings(); await Promise.all([loadBrowsePane("left"), loadBrowsePane("right")]); } diff --git a/webui/html/index.html b/webui/html/index.html index 0bff28a..c629d41 100644 --- a/webui/html/index.html +++ b/webui/html/index.html @@ -90,7 +90,11 @@
General
- + +