update to latest signal-cli, UI audit improvements"

This commit is contained in:
lone-cloud 2026-04-13 12:17:59 -07:00
parent 88aeff8539
commit c9cb3a7289
Signed by: lone-cloud
GPG key ID: B0848536D672CD8D
19 changed files with 411 additions and 190 deletions

View file

@ -35,3 +35,23 @@ if time.Since(l.generatedAt) < l.ttl {
- Prefer composition over inheritance - Prefer composition over inheritance
- Handle errors properly, don't ignore them - Handle errors properly, don't ignore them
## Design Context
### Users
Self-hosters — technically capable people who run their own infrastructure. They visit this UI occasionally to configure integrations (Signal, Telegram, WebPush, Proton Mail), register apps, and verify things are wired up. Could be on any device, but desktop is most common. Setup is a one-time or rare task; the UI is not used daily.
### Brand Personality
Sharp. Minimal. No-nonsense. This is a tool for people who run servers, not a product trying to sell them something. The interface should feel like it respects their time — no filler, no decorative chrome, no marketing energy.
### Aesthetic Direction
Refined utilitarian. Light theme by default, dark by system preference (keep existing both-modes behavior). The palette should feel clean and precise, not sterile. The cyan accent (`#56c3de`) is a good starting point for the brand hue — it reads as calm and technical without being loud. Neutrals should be very subtly tinted toward it.
Anti-reference: PHPMyAdmin / boring CRUD admin panel. The goal is something that wouldn't look out of place as a polished open-source project UI — functional, cohesive, with quiet confidence.
### Design Principles
1. **Earn every element** — if it doesn't serve a function, remove it. No decorative chrome.
2. **Precision over personality** — tight spacing, clear hierarchy, no ambiguity about what's interactive.
3. **Trust through clarity** — statuses should be instantly readable; nothing should make the user wonder "did that work?"
4. **Quiet confidence** — not boring, not flashy. Typography and spacing do the work, not color.
5. **Polish the structure, don't replace it** — the existing `<details>`-card pattern and HTMX architecture should be respected. Improve the CSS layer, don't overhaul templates.

2
.gitignore vendored
View file

@ -6,3 +6,5 @@ prism-*
tmp tmp
.VSCodeCounter/ .VSCodeCounter/
.image-digests .image-digests
.github/skills/
.impeccable.md

View file

@ -1 +1 @@
1.1.3 1.2.0

7
go.mod
View file

@ -9,6 +9,8 @@ require (
github.com/go-chi/httprate v0.15.0 github.com/go-chi/httprate v0.15.0
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/mymmrac/telego v1.8.0 github.com/mymmrac/telego v1.8.0
github.com/yeqown/go-qrcode/v2 v2.2.5
github.com/yeqown/go-qrcode/writer/standard v1.3.0
golang.org/x/crypto v0.50.0 golang.org/x/crypto v0.50.0
modernc.org/sqlite v1.48.2 modernc.org/sqlite v1.48.2
) )
@ -23,20 +25,25 @@ require (
github.com/cloudwego/base64x v0.1.6 // indirect github.com/cloudwego/base64x v0.1.6 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/emersion/go-bcrypt v0.0.0-20170822072041-6e724a1baa63 // indirect github.com/emersion/go-bcrypt v0.0.0-20170822072041-6e724a1baa63 // indirect
github.com/fogleman/gg v1.3.0 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/grbit/go-json v0.11.0 // indirect github.com/grbit/go-json v0.11.0 // indirect
github.com/klauspost/compress v1.18.4 // indirect github.com/klauspost/compress v1.18.4 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.69.0 // indirect github.com/valyala/fasthttp v1.69.0 // indirect
github.com/valyala/fastjson v1.6.10 // indirect github.com/valyala/fastjson v1.6.10 // indirect
github.com/yeqown/reedsolomon v1.0.0 // indirect
github.com/zeebo/xxh3 v1.1.0 // indirect github.com/zeebo/xxh3 v1.1.0 // indirect
golang.org/x/arch v0.24.0 // indirect golang.org/x/arch v0.24.0 // indirect
golang.org/x/image v0.10.0 // indirect
golang.org/x/sys v0.43.0 // indirect golang.org/x/sys v0.43.0 // indirect
modernc.org/libc v1.70.0 // indirect modernc.org/libc v1.70.0 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect

15
go.sum
View file

@ -23,6 +23,8 @@ github.com/emersion/go-bcrypt v0.0.0-20170822072041-6e724a1baa63 h1:7aCSuwTBzg7B
github.com/emersion/go-bcrypt v0.0.0-20170822072041-6e724a1baa63/go.mod h1:eRwwJnuLVFtYTC+AI2JDJTMcuQUTYhBIK4I6bC5tpqw= github.com/emersion/go-bcrypt v0.0.0-20170822072041-6e724a1baa63/go.mod h1:eRwwJnuLVFtYTC+AI2JDJTMcuQUTYhBIK4I6bC5tpqw=
github.com/emersion/hydroxide v0.2.31 h1:ofPKtEpD+AtE2oKJhcQKhUjubQS8+AHIjCuO3aeLBsM= github.com/emersion/hydroxide v0.2.31 h1:ofPKtEpD+AtE2oKJhcQKhUjubQS8+AHIjCuO3aeLBsM=
github.com/emersion/hydroxide v0.2.31/go.mod h1:jhoMVyP0z2GACrmFkL0ppcWKt2LbC02auADSSsB4vH4= github.com/emersion/hydroxide v0.2.31/go.mod h1:jhoMVyP0z2GACrmFkL0ppcWKt2LbC02auADSSsB4vH4=
github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8=
github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5g= github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5g=
@ -30,6 +32,8 @@ github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWK
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
@ -51,6 +55,8 @@ github.com/mymmrac/telego v1.8.0 h1:EvIprWo9Cn0MHgumvvqNXPAXO1yJj3pu2cdCCeDxbow=
github.com/mymmrac/telego v1.8.0/go.mod h1:pdLV346EgVuq7Xrh3kMggeBiazeHhsdEoK0RTEOPXRM= github.com/mymmrac/telego v1.8.0/go.mod h1:pdLV346EgVuq7Xrh3kMggeBiazeHhsdEoK0RTEOPXRM=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
@ -75,6 +81,12 @@ github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADT
github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE= github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yeqown/go-qrcode/v2 v2.2.5 h1:HCOe2bSjkhZyYoyyNaXNzh4DJZll6inVJQQw+8228Zk=
github.com/yeqown/go-qrcode/v2 v2.2.5/go.mod h1:uHpt9CM0V1HeXLz+Wg5MN50/sI/fQhfkZlOM+cOTHxw=
github.com/yeqown/go-qrcode/writer/standard v1.3.0 h1:chdyhEfRtUPgQtuPeaWVGQ/TQx4rE1PqeoW3U+53t34=
github.com/yeqown/go-qrcode/writer/standard v1.3.0/go.mod h1:O4MbzsotGCvy8upYPCR91j81dr5XLT7heuljcNXW+oQ=
github.com/yeqown/reedsolomon v1.0.0 h1:x1h/Ej/uJnNu8jaX7GLHBWmZKCAWjEJTetkqaabr4B0=
github.com/yeqown/reedsolomon v1.0.0/go.mod h1:P76zpcn2TCuL0ul1Fso373qHRc69LKwAw/Iy6g1WiiM=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
@ -92,6 +104,8 @@ golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/image v0.10.0 h1:gXjUUtwtx5yOE0VKWq1CH4IJAClq4UGgUA3i+rpON9M=
golang.org/x/image v0.10.0/go.mod h1:jtrku+n79PfroUbvDdeUWMAI+heR786BofxrbiSF+J0=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
@ -144,6 +158,7 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=

View file

@ -1,39 +1,28 @@
/* Variables */ /* Variables */
:root { :root {
--bg-primary: #f5f5f5; color-scheme: light dark;
--bg-secondary: #fdfdfd; --bg-primary: light-dark(#f4f7f8, #141a1d);
--text-primary: #000; --bg-secondary: light-dark(#fafcfd, #1e262a);
--text-secondary: #666; --text-primary: light-dark(#0f1517, #dce9ed);
--text-secondary: light-dark(#566870, #8eacb5);
--text-on-color: #fff; --text-on-color: #fff;
--border-color: rgba(0, 0, 0, 0.1); --border-color: light-dark(rgba(0, 0, 0, 0.1), rgba(255, 255, 255, 0.1));
--accent: #56c3de; --accent: light-dark(#0b7589, #7dd4e8);
--success: #06d6a0; --accent-hover: light-dark(#086b7c, #5ec8e0);
--error: #ef476f; --text-on-accent: light-dark(#fff, #0a2a33);
--spinner-track: #e0e0e0; --success: #077a5d;
--success-hover: #065e48;
--error: #c01746;
--error-hover: #9e1239;
--spinner-track: light-dark(#dae5e9, #2e3e44);
--shadow-tooltip: light-dark(rgba(0, 0, 0, 0.25), rgba(0, 0, 0, 0.5));
} }
@media (prefers-color-scheme: dark) { :root[data-theme="light"] {
:root:not([data-theme="light"]) { color-scheme: light;
--bg-primary: #1a1a1a;
--bg-secondary: #2d2d2d;
--text-primary: #e0e0e0;
--text-secondary: #a0a0a0;
--text-on-color: #fff;
--border-color: rgba(255, 255, 255, 0.1);
--accent: #56c3de;
--spinner-track: #4a4a4a;
}
} }
:root[data-theme="dark"] { :root[data-theme="dark"] {
--bg-primary: #1a1a1a; color-scheme: dark;
--bg-secondary: #2d2d2d;
--text-primary: #e0e0e0;
--text-secondary: #a0a0a0;
--text-on-color: #fff;
--border-color: rgba(255, 255, 255, 0.1);
--accent: #56c3de;
--spinner-track: #4a4a4a;
} }
/* Reset */ /* Reset */
@ -81,9 +70,8 @@ h6 {
/* Base */ /* Base */
body { body {
font-family: system-ui; font-family:
line-height: 1.5; ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
-webkit-font-smoothing: antialiased;
max-width: 50rem; max-width: 50rem;
margin: 1rem auto; margin: 1rem auto;
padding: 0 1.25rem 1.25rem; padding: 0 1.25rem 1.25rem;
@ -101,21 +89,23 @@ a {
} }
a:hover { a:hover {
opacity: 0.8; color: var(--accent-hover);
} }
/* Components */ /* Components */
/* Cards */ /* Cards */
.card { .card,
.integration-card {
background: var(--bg-secondary); background: var(--bg-secondary);
border-radius: 0.5rem; border-radius: 0.5rem;
padding: 0; padding: 0;
margin-bottom: 1rem; margin-bottom: 1.25rem;
box-shadow: 0 0.125rem 0.25rem var(--border-color); box-shadow: 0 0.125rem 0.25rem var(--border-color);
} }
.card-header { .card-header,
.integration-header {
font-weight: 600; font-weight: 600;
font-size: 1.1em; font-size: 1.1em;
display: flex; display: flex;
@ -127,11 +117,13 @@ a:hover {
user-select: none; user-select: none;
} }
.card-header::-webkit-details-marker { .card-header::-webkit-details-marker,
.integration-header::-webkit-details-marker {
display: none; display: none;
} }
.card-header::before { .card-header::before,
.integration-header::before {
content: "▶"; content: "▶";
font-size: 0.7em; font-size: 0.7em;
margin-right: 0.5rem; margin-right: 0.5rem;
@ -139,14 +131,39 @@ a:hover {
display: inline-block; display: inline-block;
} }
details[open] > .card-header::before { details[open] > .card-header::before,
details[open] > .integration-header::before {
transform: rotate(90deg); transform: rotate(90deg);
} }
.card-content { .card-content,
.integration-content {
padding: 0 1.25rem 1.25rem 1.25rem; padding: 0 1.25rem 1.25rem 1.25rem;
} }
/* Page identity */
.site-header {
padding-bottom: 1.25rem;
display: flex;
align-items: baseline;
gap: 0.5rem;
}
.site-wordmark {
font-size: 0.9rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--accent);
}
.card-header h2,
.integration-header h2 {
margin-bottom: 0;
font-size: 1.2em;
font-weight: 600;
}
/* Tooltips */ /* Tooltips */
.integration-status:has(.tooltip) { .integration-status:has(.tooltip) {
cursor: help; cursor: help;
@ -171,7 +188,7 @@ details[open] > .card-header::before {
transition: opacity 0.2s; transition: opacity 0.2s;
pointer-events: none; pointer-events: none;
z-index: 10; z-index: 10;
box-shadow: 0 0.25rem 0.5rem rgba(0, 0, 0, 0.3); box-shadow: 0 0.25rem 0.5rem var(--shadow-tooltip);
} }
.tooltip::after { .tooltip::after {
@ -184,7 +201,8 @@ details[open] > .card-header::before {
border-top-color: var(--bg-secondary); border-top-color: var(--bg-secondary);
} }
:hover > .tooltip { :hover > .tooltip,
:focus-within > .tooltip {
visibility: visible; visibility: visible;
opacity: 1; opacity: 1;
} }
@ -202,6 +220,10 @@ details[open] > .card-header::before {
line-height: 1; line-height: 1;
opacity: 0.6; opacity: 0.6;
transition: opacity 0.2s; transition: opacity 0.2s;
min-width: 2.75rem;
min-height: 2.75rem;
display: grid;
place-items: center;
} }
.theme-toggle:hover { .theme-toggle:hover {
@ -212,8 +234,6 @@ details[open] > .card-header::before {
.app-list { .app-list {
list-style: none; list-style: none;
padding: 0; padding: 0;
max-height: 18.75rem;
overflow: visible;
} }
.app-item { .app-item {
@ -238,8 +258,14 @@ details[open] > .card-header::before {
font-size: 1.2em; font-size: 1.2em;
} }
.app-subscriptions {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
.channel-badge { .channel-badge {
padding: 0.15rem 0.5rem; padding: 0.375rem 0.625rem;
border-radius: 0.25rem; border-radius: 0.25rem;
font-size: 0.85em; font-size: 0.85em;
font-weight: 500; font-weight: 500;
@ -251,15 +277,20 @@ details[open] > .card-header::before {
justify-content: center; justify-content: center;
gap: 0.25rem; gap: 0.25rem;
min-width: 5rem; min-width: 5rem;
min-height: 2.75rem;
} }
.badge-active { .badge-active {
cursor: pointer; cursor: pointer;
transition: filter 0.2s ease; transition: background-color 0.2s ease;
} }
.badge-active:hover { .badge-active:hover {
filter: brightness(0.9); background-color: var(--accent-hover);
}
.channel-webpush.badge-active:hover {
background-color: var(--success-hover);
} }
.badge-active.htmx-request { .badge-active.htmx-request {
@ -271,11 +302,11 @@ details[open] > .card-header::before {
.badge-inactive { .badge-inactive {
cursor: pointer; cursor: pointer;
border: 0.0625rem dashed currentColor; border: 0.0625rem dashed currentColor;
transition: filter 0.2s ease; transition: opacity 0.2s ease;
} }
.badge-inactive:hover { .badge-inactive:hover {
filter: brightness(1.2); opacity: 0.75;
} }
.badge-inactive.htmx-request { .badge-inactive.htmx-request {
@ -290,7 +321,7 @@ details[open] > .card-header::before {
.channel-signal.badge-active { .channel-signal.badge-active {
background: var(--accent); background: var(--accent);
color: var(--text-on-color); color: var(--text-on-accent);
} }
.channel-signal.badge-inactive { .channel-signal.badge-inactive {
@ -300,7 +331,7 @@ details[open] > .card-header::before {
.channel-signal.badge-subscribed { .channel-signal.badge-subscribed {
background: var(--accent); background: var(--accent);
color: var(--text-on-color); color: var(--text-on-accent);
} }
.channel-webpush.badge-active { .channel-webpush.badge-active {
@ -310,12 +341,12 @@ details[open] > .card-header::before {
.channel-webpush.badge-subscribed { .channel-webpush.badge-subscribed {
background: var(--accent); background: var(--accent);
color: var(--text-on-color); color: var(--text-on-accent);
} }
.channel-telegram.badge-active { .channel-telegram.badge-active {
background: var(--accent); background: var(--accent);
color: var(--text-on-color); color: var(--text-on-accent);
} }
.channel-telegram.badge-inactive { .channel-telegram.badge-inactive {
@ -325,7 +356,7 @@ details[open] > .card-header::before {
.channel-telegram.badge-subscribed { .channel-telegram.badge-subscribed {
background: var(--accent); background: var(--accent);
color: var(--text-on-color); color: var(--text-on-accent);
} }
.app-actions { .app-actions {
@ -341,12 +372,12 @@ details[open] > .card-header::before {
border: none; border: none;
border-radius: 0.25rem; border-radius: 0.25rem;
cursor: pointer; cursor: pointer;
transition: filter 0.2s ease; transition: background-color 0.2s ease;
white-space: nowrap; white-space: nowrap;
} }
.btn-delete:hover { .btn-delete:hover {
filter: brightness(0.85); background-color: var(--error-hover);
} }
.btn-delete-sub { .btn-delete-sub {
@ -356,10 +387,13 @@ details[open] > .card-header::before {
font-size: 1em; font-size: 1em;
font-weight: bold; font-weight: bold;
cursor: pointer; cursor: pointer;
padding: 0 0.25rem; padding: 0.375rem 0.5rem;
min-width: 2.75rem;
min-height: 2.75rem;
margin-left: 0.25rem; margin-left: 0.25rem;
opacity: 0.7; opacity: 0.7;
transition: opacity 0.2s ease; transition: opacity 0.2s ease;
text-align: center;
} }
.btn-delete-sub:hover { .btn-delete-sub:hover {
@ -390,54 +424,10 @@ details[open] > .card-header::before {
} }
/* Integrations */ /* Integrations */
.integration-card {
background: var(--bg-secondary);
border-radius: 0.5rem;
margin-bottom: 1.25rem;
box-shadow: 0 0.125rem 0.25rem var(--border-color);
padding: 0;
}
.integration-container { .integration-container {
margin-bottom: 1.25rem; margin-bottom: 1.25rem;
} }
.integration-header {
font-weight: 600;
font-size: 1.1em;
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1.25rem;
cursor: pointer;
list-style: none;
user-select: none;
}
.integration-name {
font-size: 1.2em;
}
.integration-header::-webkit-details-marker {
display: none;
}
.integration-header::before {
content: "▶";
font-size: 0.7em;
margin-right: 0.5rem;
transition: transform 0.2s;
display: inline-block;
}
details[open] > .integration-header::before {
transform: rotate(90deg);
}
.integration-content {
padding: 0 1.25rem 1.25rem 1.25rem;
}
.integration-status { .integration-status {
padding: 0.25rem 0.625rem; padding: 0.25rem 0.625rem;
border-radius: 0.25rem; border-radius: 0.25rem;
@ -472,12 +462,14 @@ details[open] > .integration-header::before {
.channel-not-configured { .channel-not-configured {
background: var(--error); background: var(--error);
color: var(--text-on-color); color: var(--text-on-color);
padding: 0.25rem 0.625rem;
border-radius: 0.25rem;
} }
/* Forms */ /* Forms */
.auth-form { .auth-form {
margin-top: 1rem; margin-top: 1rem;
max-width: 400px; max-width: 25rem;
} }
.form-group { .form-group {
@ -496,7 +488,7 @@ details[open] > .integration-header::before {
.form-group input[type="password"] { .form-group input[type="password"] {
width: 100%; width: 100%;
padding: 0.5rem; padding: 0.5rem;
border: 1px solid var(--border-color); border: 0.0625rem solid var(--border-color);
border-radius: 0.25rem; border-radius: 0.25rem;
background: var(--bg-secondary); background: var(--bg-secondary);
color: var(--text-primary); color: var(--text-primary);
@ -523,8 +515,10 @@ details[open] > .integration-header::before {
cursor: pointer; cursor: pointer;
padding: 0; padding: 0;
color: var(--text-secondary); color: var(--text-secondary);
display: flex; display: grid;
align-items: center; place-items: center;
min-width: 2.75rem;
min-height: 2.75rem;
transition: color 0.2s; transition: color 0.2s;
} }
@ -551,13 +545,19 @@ details[open] > .integration-header::before {
cursor: pointer; cursor: pointer;
font-size: 1rem; font-size: 1rem;
font-weight: 500; font-weight: 500;
transition: filter 0.2s ease; transition: background-color 0.2s ease;
}
.btn-primary:hover {
background-color: var(--accent-hover);
}
.btn-secondary:hover {
opacity: 0.8;
} }
.btn-primary:hover,
.btn-secondary:hover,
.btn-danger:hover { .btn-danger:hover {
filter: brightness(0.85); background-color: var(--error-hover);
} }
.btn-primary:disabled, .btn-primary:disabled,
@ -569,7 +569,7 @@ details[open] > .integration-header::before {
.btn-primary { .btn-primary {
background: var(--accent); background: var(--accent);
color: var(--text-on-color); color: var(--text-on-accent);
} }
.btn-secondary { .btn-secondary {
@ -607,7 +607,7 @@ details[open] > .integration-header::before {
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
background: var(--accent); background: var(--accent);
color: var(--text-on-color); color: var(--text-on-accent);
} }
/* QR Code */ /* QR Code */
@ -634,7 +634,7 @@ details[open] > .integration-header::before {
/* Toast Notifications */ /* Toast Notifications */
#toast-container { #toast-container {
position: fixed; position: fixed;
bottom: 1.5rem; bottom: 4rem;
right: 1.5rem; right: 1.5rem;
z-index: 9999; z-index: 9999;
display: flex; display: flex;
@ -649,7 +649,7 @@ details[open] > .integration-header::before {
padding: 0.875rem 1.25rem; padding: 0.875rem 1.25rem;
border-radius: 0.375rem; border-radius: 0.375rem;
box-shadow: box-shadow:
0 0.25rem 0.5rem rgba(0, 0, 0, 0.1), 0 0.25rem 0.5rem var(--shadow-tooltip),
0 0 0 0.0625rem var(--border-color); 0 0 0 0.0625rem var(--border-color);
min-width: 15rem; min-width: 15rem;
max-width: 25rem; max-width: 25rem;
@ -672,7 +672,7 @@ details[open] > .integration-header::before {
.toast.info { .toast.info {
background: var(--accent); background: var(--accent);
color: var(--text-on-color); color: var(--text-on-accent);
} }
.toast.hiding { .toast.hiding {
@ -700,3 +700,81 @@ details[open] > .integration-header::before {
opacity: 0; opacity: 0;
} }
} }
/* Inline confirm widget */
.confirm-inline {
display: inline-flex;
align-items: center;
gap: 0.375rem;
flex-wrap: wrap;
}
.confirm-message {
font-size: 0.875rem;
color: var(--text-secondary);
}
.btn-sm {
padding: 0.25rem 0.625rem;
font-size: 0.875rem;
min-height: 2.75rem;
}
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: 2px;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.skip-nav {
position: absolute;
top: -100%;
left: 1rem;
background: var(--bg-secondary);
color: var(--accent);
padding: 0.5rem 1rem;
border-radius: 0.25rem;
font-weight: 600;
z-index: 10000;
transition: top 0.1s;
}
.skip-nav:focus {
top: 1rem;
}
#signal-qr-code:not([src]),
#signal-qr-code[src=""] {
display: none;
}
@media (max-width: 30rem) {
.theme-toggle {
bottom: 0.75rem;
right: 0.75rem;
font-size: 1.25rem;
}
#toast-container {
right: 1rem;
left: 1rem;
bottom: 3.5rem;
}
.toast {
min-width: 0;
max-width: none;
}
}

View file

@ -4,36 +4,43 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>Prism</title> <title>Prism</title>
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#56C3DE"> <meta name="theme-color" content="#0b7589" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#7dd4e8" media="(prefers-color-scheme: dark)">
<link rel="icon" type="image/webp" href="/favicon.webp"> <link rel="icon" type="image/webp" href="/favicon.webp">
<link rel="manifest" href="/manifest.json"> <link rel="manifest" href="/manifest.json">
<link rel="stylesheet" href="/index.css?v={{.Version}}"> <link rel="stylesheet" href="/index.css?v={{.Version}}">
<script src="/htmx.min.js"></script> <script src="/theme-init.js?v={{.Version}}"></script>
<script src="/theme.js?v={{.Version}}"></script> <script src="/htmx.min.js" defer></script>
<script src="/toast.js?v={{.Version}}"></script> <script src="/theme.js?v={{.Version}}" defer></script>
<script src="/integration.js?v={{.Version}}"></script> <script src="/toast.js?v={{.Version}}" defer></script>
<script src="/integration.js?v={{.Version}}" defer></script>
</head> </head>
<body> <body>
<details class="card" open> <a href="#main-content" class="skip-nav">Skip to content</a>
<summary class="card-header"> <main id="main-content">
<span class="integration-name">Registered Apps</span> <h1 class="sr-only">Prism</h1>
</summary> <details class="card" open>
<div id="apps-list" class="card-content" <summary class="card-header">
hx-get="/fragment/apps" <h2>Registered Apps</h2>
hx-trigger="load, reload"> </summary>
<div class="loading"> <div id="apps-list" class="card-content"
<div class="spinner"></div> hx-get="/fragment/apps"
hx-trigger="load, reload">
<div class="loading" role="status">
<div class="spinner"></div>
<span class="sr-only">Loading…</span>
</div>
</div> </div>
</details>
<div id="integrations"
hx-get="/fragment/integrations"
hx-trigger="load, reload">
</div> </div>
</details> </main>
<div id="integrations" <div id="toast-container" role="status" aria-live="polite" aria-atomic="false"></div>
hx-get="/fragment/integrations"
hx-trigger="load, reload">
</div>
<div id="toast-container"></div> <button type="button" id="theme-toggle" class="theme-toggle" aria-label="Toggle theme"></button>
<button type="button" id="theme-toggle" class="theme-toggle"></button>
</body> </body>
</html> </html>

View file

@ -1,3 +1,36 @@
function showConfirm(btn, message, onConfirm) {
const wrapper = document.createElement('span');
wrapper.className = 'confirm-inline';
wrapper.setAttribute('role', 'group');
wrapper.setAttribute('aria-label', message);
const msg = document.createElement('span');
msg.className = 'confirm-message';
msg.textContent = message;
const yes = document.createElement('button');
yes.type = 'button';
yes.className = 'btn-danger btn-sm';
yes.textContent = 'Yes';
const cancel = document.createElement('button');
cancel.type = 'button';
cancel.className = 'btn-secondary btn-sm';
cancel.textContent = 'Cancel';
yes.addEventListener('click', () => {
wrapper.replaceWith(btn);
onConfirm();
});
cancel.addEventListener('click', () => {
delete btn.dataset.confirming;
wrapper.replaceWith(btn);
});
wrapper.append(msg, '\u00a0', yes, '\u00a0', cancel);
btn.replaceWith(wrapper);
}
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
document.addEventListener('submit', (e) => { document.addEventListener('submit', (e) => {
const form = e.target; const form = e.target;
@ -126,11 +159,38 @@ async function submitProtonAuth(e) {
let signalLinkingPoll = null; let signalLinkingPoll = null;
document.addEventListener('htmx:beforeSwap', (e) => {
if (!signalLinkingPoll) return;
const t = e.detail.target;
if (t && (t.id === 'integrations' || t.id === 'signal-integration')) {
clearInterval(signalLinkingPoll);
signalLinkingPoll = null;
}
});
document.addEventListener('htmx:confirm', (e) => {
if (!e.detail.question) return;
if (e.detail.elt.dataset.confirming) return;
e.preventDefault();
e.detail.elt.dataset.confirming = '1';
showConfirm(e.detail.elt, e.detail.question, () => {
delete e.detail.elt.dataset.confirming;
e.detail.issueRequest(true);
});
});
async function linkSignal(btn) { async function linkSignal(btn) {
if (signalLinkingPoll) {
clearInterval(signalLinkingPoll);
signalLinkingPoll = null;
}
const qrContainer = document.getElementById('signal-qr-container'); const qrContainer = document.getElementById('signal-qr-container');
const qrCode = document.getElementById('signal-qr-code'); const qrCode = document.getElementById('signal-qr-code');
const showQrError = (msg) => { const showQrError = (msg) => {
qrContainer.innerHTML = `<p class="channel-not-configured">Error: ${msg}</p>`; const p = document.createElement('p');
p.className = 'channel-not-configured';
p.textContent = `Error: ${msg}`;
qrContainer.replaceChildren(p);
qrContainer.style.display = 'block'; qrContainer.style.display = 'block';
btn.style.display = 'inline-block'; btn.style.display = 'inline-block';
}; };
@ -152,8 +212,7 @@ async function linkSignal(btn) {
} }
const data = await response.json(); const data = await response.json();
const qrUrl = `https://api.qrserver.com/v1/create-qr-code/?size=400x400&data=${encodeURIComponent(data.qr_code)}`; qrCode.src = data.qr_code;
qrCode.src = qrUrl;
qrContainer.style.display = 'block'; qrContainer.style.display = 'block';
let statusCheckInProgress = false; let statusCheckInProgress = false;
@ -167,8 +226,10 @@ async function linkSignal(btn) {
if (statusData.linked) { if (statusData.linked) {
clearInterval(signalLinkingPoll); clearInterval(signalLinkingPoll);
qrContainer.innerHTML = const linked = document.createElement('p');
'<p class="auth-status success">Linked! Refreshing...</p>'; linked.className = 'auth-status success';
linked.textContent = 'Linked! Refreshing...';
qrContainer.replaceChildren(linked);
showToast('Signal linked', 'success'); showToast('Signal linked', 'success');
setTimeout(() => reloadIntegrations(), 1000); setTimeout(() => reloadIntegrations(), 1000);
} }
@ -184,45 +245,37 @@ async function linkSignal(btn) {
} }
async function deleteTelegram(btn) { async function deleteTelegram(btn) {
if (!confirm('Unlink Telegram integration?')) return; showConfirm(btn, 'Unlink Telegram?', async () => {
try {
btn.disabled = true; const response = await fetch('/api/v1/telegram/link', {
method: 'DELETE',
try { });
const response = await fetch('/api/v1/telegram/link', { method: 'DELETE' }); if (response.ok) {
checkToast(response);
if (response.ok) { reloadIntegrations();
checkToast(response); } else {
reloadIntegrations(); const error = await response.json();
} else { showToast(error.error || 'Failed to unlink Telegram', 'error');
const error = await response.json(); }
alert(`Error: ${error.error || 'Failed to unlink integration'}`); } catch (err) {
btn.disabled = false; showToast(err.message, 'error');
} }
} catch (err) { });
alert(`Error: ${err.message}`);
btn.disabled = false;
}
} }
async function deleteProton(btn) { async function deleteProton(btn) {
if (!confirm('Unlink Proton Mail integration?')) return; showConfirm(btn, 'Unlink Proton Mail?', async () => {
try {
btn.disabled = true; const response = await fetch('/api/v1/proton/auth', { method: 'DELETE' });
if (response.ok) {
try { checkToast(response);
const response = await fetch('/api/v1/proton/auth', { method: 'DELETE' }); reloadIntegrations();
} else {
if (response.ok) { const error = await response.json();
checkToast(response); showToast(error.error || 'Failed to unlink Proton', 'error');
reloadIntegrations(); }
} else { } catch (err) {
const error = await response.json(); showToast(err.message, 'error');
alert(`Error: ${error.error || 'Failed to unlink integration'}`);
btn.disabled = false;
} }
} catch (err) { });
alert(`Error: ${err.message}`);
btn.disabled = false;
}
} }

5
public/theme-init.js Normal file
View file

@ -0,0 +1,5 @@
(() => {
var t = localStorage.getItem('theme');
if (t && t !== 'system')
document.documentElement.setAttribute('data-theme', t);
})();

View file

@ -23,8 +23,14 @@ window.cycleTheme = () => {
function updateButtonText(theme) { function updateButtonText(theme) {
const btn = document.getElementById('theme-toggle'); const btn = document.getElementById('theme-toggle');
if (btn) { if (btn) {
const labels = {
system: 'Toggle theme (system)',
light: 'Toggle theme (light)',
dark: 'Toggle theme (dark)',
};
btn.textContent = btn.textContent =
theme === 'system' ? '🌓' : theme === 'light' ? '☀️' : '🌙'; theme === 'system' ? '🌓' : theme === 'light' ? '☀️' : '🌙';
btn.setAttribute('aria-label', labels[theme] || 'Toggle theme');
} }
} }

View file

@ -12,14 +12,14 @@
<div class="password-wrapper"> <div class="password-wrapper">
<input type="password" id="proton-password" name="password" placeholder="Your Proton password" required> <input type="password" id="proton-password" name="password" placeholder="Your Proton password" required>
<button type="button" class="password-toggle" data-action="toggle-password" aria-label="Show password"> <button type="button" class="password-toggle" data-action="toggle-password" aria-label="Show password">
<svg class="eye-icon eye-show" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7z"/><circle cx="12" cy="12" r="3"/></svg> <svg class="eye-icon eye-show" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7z"/><circle cx="12" cy="12" r="3"/></svg>
<svg class="eye-icon eye-hide" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-10-7-10-7a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 10 7 10 7a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg> <svg class="eye-icon eye-hide" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-10-7-10-7a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 10 7 10 7a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>
</button> </button>
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="proton-totp">2FA Code (optional):</label> <label for="proton-totp">2FA Code (optional):</label>
<input type="text" id="proton-totp" name="totp" placeholder="123456" pattern="[0-9]{6}"> <input type="text" id="proton-totp" name="totp" placeholder="123456" pattern="[0-9]{6}" inputmode="numeric" autocomplete="one-time-code">
</div> </div>
<button type="submit" class="btn-primary">Link</button> <button type="submit" class="btn-primary">Link</button>
<div id="proton-auth-status" class="auth-status"></div> <div id="proton-auth-status" class="auth-status"></div>

View file

@ -1,11 +1,16 @@
package signal package signal
import ( import (
"bytes"
"encoding/base64"
"encoding/json" "encoding/json"
"html/template" "html/template"
"log/slog" "log/slog"
"net/http" "net/http"
qrcode "github.com/yeqown/go-qrcode/v2"
"github.com/yeqown/go-qrcode/writer/standard"
"prism/service/util" "prism/service/util"
) )
@ -111,9 +116,27 @@ func (h *Handlers) HandleLinkDevice(w http.ResponseWriter, r *http.Request) {
return return
} }
qrc, err := qrcode.NewWith(qrCode, qrcode.WithErrorCorrectionLevel(qrcode.ErrorCorrectionMedium))
if err != nil {
h.logger.Error("Failed to encode QR code", "error", err)
util.JSONError(w, "Failed to generate QR code", http.StatusInternalServerError)
return
}
var buf bytes.Buffer
w2 := standard.NewWithWriter(nopWriteCloser{&buf},
standard.WithBuiltinImageEncoder(standard.PNG_FORMAT),
standard.WithQRWidth(10),
)
if err := qrc.Save(w2); err != nil {
h.logger.Error("Failed to render QR code", "error", err)
util.JSONError(w, "Failed to generate QR code", http.StatusInternalServerError)
return
}
dataURL := "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes())
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{ json.NewEncoder(w).Encode(map[string]string{
"qr_code": qrCode, "qr_code": dataURL,
"status": "linking", "status": "linking",
}) })
} }
@ -145,3 +168,7 @@ func (h *Handlers) HandleLinkStatus(w http.ResponseWriter, r *http.Request) {
}) })
} }
} }
type nopWriteCloser struct{ *bytes.Buffer }
func (nopWriteCloser) Close() error { return nil }

View file

@ -19,7 +19,7 @@
<li>Scan the QR code below</li> <li>Scan the QR code below</li>
</ol> </ol>
<div class="qr-code-wrapper"> <div class="qr-code-wrapper">
<img id="signal-qr-code" alt="Signal QR Code"> <img id="signal-qr-code" alt="QR code — scan with Signal to link this device" width="400" height="400">
</div> </div>
</div> </div>
{{end}} {{end}}

View file

@ -1,10 +1,10 @@
{{if .NotConfigured}} {{if .NotConfigured}}
<p><strong>Setup Telegram Bot:</strong></p> <p><strong>Setup Telegram Bot:</strong></p>
<ol class="link-instructions"> <ol class="link-instructions">
<li>Message <a href="https://t.me/BotFather" target="_blank">@BotFather</a> on Telegram</li> <li>Message <a href="https://t.me/BotFather" target="_blank" rel="noopener noreferrer">@BotFather</a> on Telegram</li>
<li>Send <code>/newbot</code> and follow the prompts</li> <li>Send <code>/newbot</code> and follow the prompts</li>
<li>Copy the bot token from BotFather</li> <li>Copy the bot token from BotFather</li>
<li>Message <a href="https://t.me/userinfobot" target="_blank">@userinfobot</a> to get your Chat ID</li> <li>Message <a href="https://t.me/userinfobot" target="_blank" rel="noopener noreferrer">@userinfobot</a> to get your Chat ID</li>
<li>Enter both below:</li> <li>Enter both below:</li>
</ol> </ol>
<form class="auth-form" data-handler="telegram"> <form class="auth-form" data-handler="telegram">

View file

@ -1,17 +1,17 @@
{{if .EnableSignal}} {{if .EnableSignal}}
<div id="signal-integration" class="integration-container" hx-get="/fragment/signal" hx-trigger="load"> <div id="signal-integration" class="integration-container" hx-get="/fragment/signal" hx-trigger="load">
<div class="loading"><div class="spinner"></div></div> <div class="loading" role="status"><div class="spinner"></div><span class="sr-only">Loading…</span></div>
</div> </div>
{{end}} {{end}}
{{if .EnableTelegram}} {{if .EnableTelegram}}
<div id="telegram-integration" class="integration-container" hx-get="/fragment/telegram" hx-trigger="load"> <div id="telegram-integration" class="integration-container" hx-get="/fragment/telegram" hx-trigger="load">
<div class="loading"><div class="spinner"></div></div> <div class="loading" role="status"><div class="spinner"></div><span class="sr-only">Loading…</span></div>
</div> </div>
{{end}} {{end}}
{{if .EnableProton}} {{if .EnableProton}}
<div id="proton-integration" class="integration-container" hx-get="/fragment/proton" hx-trigger="load"> <div id="proton-integration" class="integration-container" hx-get="/fragment/proton" hx-trigger="load">
<div class="loading"><div class="spinner"></div></div> <div class="loading" role="status"><div class="spinner"></div><span class="sr-only">Loading…</span></div>
</div> </div>
{{end}} {{end}}

View file

@ -37,7 +37,8 @@
hx-delete="/apps/{{$app.AppName}}/subscriptions/{{.ID}}" hx-delete="/apps/{{$app.AppName}}/subscriptions/{{.ID}}"
hx-target="#apps-list" hx-target="#apps-list"
hx-swap="innerHTML" hx-swap="innerHTML"
hx-confirm="Delete this channel?">×</button> hx-confirm="Delete this channel?"
aria-label="Remove {{$channel.Channel}} channel">×</button>
</span> </span>
{{end}} {{end}}
{{end}} {{end}}

View file

@ -1,6 +1,6 @@
<details class="integration-card"{{if .Open}} open{{end}}> <details class="integration-card"{{if .Open}} open{{end}}>
<summary class="integration-header"> <summary class="integration-header">
<span class="integration-name">{{.Name}}</span> <h2>{{.Name}}</h2>
<span class="integration-status {{.StatusClass}}"> <span class="integration-status {{.StatusClass}}">
{{.StatusText}} {{.StatusText}}
{{if .StatusTooltip}}<span class="tooltip">{{.StatusTooltip}}</span>{{end}} {{if .StatusTooltip}}<span class="tooltip">{{.StatusTooltip}}</span>{{end}}

Binary file not shown.

Binary file not shown.