Add svelte-heros icon library

- Install svelte-heros for consistent Heroicons usage
- Replace inline SVGs for password toggle with Eye/EyeOff icons
- Fix USE_EMBEDDED mode to serve frontend from filesystem
This commit is contained in:
Christoph Haas 2026-03-29 15:16:58 +02:00
parent 8b2d2649ac
commit 46d9ff26dd
5 changed files with 49 additions and 45 deletions

View file

@ -4,15 +4,22 @@ import (
"embed" "embed"
"io/fs" "io/fs"
"net/http" "net/http"
"os"
) )
//go:embed all:embed //go:embed all:embed
var Files embed.FS var Files embed.FS
func FrontendFileSystem() http.FileSystem { func FrontendFileSystem() (http.FileSystem, string) {
return http.FS(Files) if os.Getenv("USE_EMBEDDED") == "false" {
return http.Dir("../frontend/build"), ""
}
return http.FS(Files), "embed/"
} }
func FrontendFS() fs.FS { func FrontendFS() fs.FS {
if os.Getenv("USE_EMBEDDED") == "false" {
return os.DirFS("../frontend/build")
}
return Files return Files
} }

View file

@ -120,7 +120,7 @@ func main() {
// Get the embedded filesystem containing the frontend. // Get the embedded filesystem containing the frontend.
// The frontend is compiled into the binary during build time. // The frontend is compiled into the binary during build time.
frontendFS := FrontendFileSystem() frontendFS, frontendPrefix := FrontendFileSystem()
// Set Gin mode based on environment. // Set Gin mode based on environment.
// USE_EMBEDDED=false means development mode (e.g., via air hot-reloader). // USE_EMBEDDED=false means development mode (e.g., via air hot-reloader).
@ -142,31 +142,31 @@ func main() {
// Set up SPA fallback for non-API routes. // Set up SPA fallback for non-API routes.
// This must be the last route to catch all unmatched paths. // This must be the last route to catch all unmatched paths.
engine.NoRoute(func(c *gin.Context) { engine.NoRoute(func(c *gin.Context) {
path := c.Request.URL.Path reqPath := c.Request.URL.Path
// If it's an API path, return 404. // If it's an API path, return 404.
// The API router should have handled /api/* routes. // The API router should have handled /api/* routes.
if strings.HasPrefix(path, "/api/") { if strings.HasPrefix(reqPath, "/api/") {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return return
} }
// Serve SvelteKit hashed assets at /_app/* // Serve SvelteKit hashed assets at /_app/*
if strings.HasPrefix(path, "/_app/") { if strings.HasPrefix(reqPath, "/_app/") {
assetPath := "embed/_app/" + strings.TrimPrefix(path, "/_app/") filePath := frontendPrefix + "_app/" + strings.TrimPrefix(reqPath, "/_app/")
serveGinStaticFile(c, frontendFS, assetPath) serveGinStaticFile(c, frontendFS, filePath)
return return
} }
// Serve favicon // Serve favicon
if path == "/favicon.png" { if reqPath == "/favicon.png" {
serveGinStaticFile(c, frontendFS, "embed/favicon.png") serveGinStaticFile(c, frontendFS, frontendPrefix+"favicon.png")
return return
} }
// For all other paths, serve the SPA index.html. // For all other paths, serve the SPA index.html.
// This allows client-side routing (e.g., /domains/example.org). // This allows client-side routing (e.g., /domains/example.org).
serveGinStaticFile(c, frontendFS, "embed/index.html") serveGinStaticFile(c, frontendFS, frontendPrefix+"index.html")
}) })
// Build the address string for binding: "IP:PORT" // Build the address string for binding: "IP:PORT"

View file

@ -14,6 +14,7 @@
"daisyui": "^5.5.19", "daisyui": "^5.5.19",
"postcss": "^8.5.8", "postcss": "^8.5.8",
"svelte": "^5.0.0", "svelte": "^5.0.0",
"svelte-heros": "^8.0.1",
"tailwindcss": "^4.2.2", "tailwindcss": "^4.2.2",
"typescript": "^5.0.0", "typescript": "^5.0.0",
"vite": "^5.0.0", "vite": "^5.0.0",
@ -289,6 +290,8 @@
"svelte": ["svelte@5.55.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.6.4", "esm-env": "^1.2.1", "esrap": "^2.2.2", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-SThllKq6TRMBwPtat7ASnm/9CDXnIhBR0NPGw0ujn2DVYx9rVwsPZxDaDQcYGdUz/3BYVsCzdq7pZarRQoGvtw=="], "svelte": ["svelte@5.55.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.6.4", "esm-env": "^1.2.1", "esrap": "^2.2.2", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-SThllKq6TRMBwPtat7ASnm/9CDXnIhBR0NPGw0ujn2DVYx9rVwsPZxDaDQcYGdUz/3BYVsCzdq7pZarRQoGvtw=="],
"svelte-heros": ["svelte-heros@8.0.1", "", { "peerDependencies": { "svelte": "^5.0.0" } }, "sha512-67rDThxmdbjvJxKDqDUnkTsmyI5JtXcS0S0Ryg/EmTlHscFyJNgJyEARX5/xt3ONsZA0i6+dWnq+EX5jH9Wq1Q=="],
"tailwindcss": ["tailwindcss@4.2.2", "", {}, "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q=="], "tailwindcss": ["tailwindcss@4.2.2", "", {}, "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q=="],
"tapable": ["tapable@2.3.2", "", {}, "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA=="], "tapable": ["tapable@2.3.2", "", {}, "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA=="],

View file

@ -18,6 +18,7 @@
"daisyui": "^5.5.19", "daisyui": "^5.5.19",
"postcss": "^8.5.8", "postcss": "^8.5.8",
"svelte": "^5.0.0", "svelte": "^5.0.0",
"svelte-heros": "^8.0.1",
"tailwindcss": "^4.2.2", "tailwindcss": "^4.2.2",
"typescript": "^5.0.0", "typescript": "^5.0.0",
"vite": "^5.0.0" "vite": "^5.0.0"

View file

@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/stores'; import { page } from '$app/stores';
import { Eye, EyeOff } from 'svelte-heros';
interface User { interface User {
id: number; id: number;
@ -14,9 +15,8 @@
let localPart = $state(''); let localPart = $state('');
let newUser = $state({ password: '', quota: 0 }); let newUser = $state({ password: '', quota: 0 });
let createError = $state(''); let createError = $state('');
let createdPassword = $state('');
let showCreatedPassword = $state(false);
let domainName = $state(''); let domainName = $state('');
let showPassword = $state(false);
async function loadUsers(name: string) { async function loadUsers(name: string) {
try { try {
@ -37,10 +37,9 @@
function openModal() { function openModal() {
createError = ''; createError = '';
createdPassword = '';
showCreatedPassword = false;
localPart = ''; localPart = '';
newUser = { password: '', quota: 0 }; showPassword = false;
newUser = { password: generatePasswordStr(), quota: 0 };
dialogEl?.showModal(); dialogEl?.showModal();
} }
@ -48,7 +47,7 @@
dialogEl?.close(); dialogEl?.close();
} }
function generatePassword() { function generatePasswordStr(): string {
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*'; const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*';
let password = ''; let password = '';
const array = new Uint8Array(16); const array = new Uint8Array(16);
@ -56,7 +55,7 @@
for (let i = 0; i < 16; i++) { for (let i = 0; i < 16; i++) {
password += chars[array[i] % chars.length]; password += chars[array[i] % chars.length];
} }
newUser.password = password; return password;
} }
async function createUser() { async function createUser() {
@ -70,14 +69,13 @@
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authorization: `Bearer ${token}` Authorization: `Bearer ${token}`
}, },
body: JSON.stringify({ email, password: newUser.password || undefined, quota: newUser.quota }) body: JSON.stringify({ email, password: newUser.password, quota: newUser.quota })
}); });
const data = await res.json();
if (res.ok) { if (res.ok) {
createdPassword = data.password || '';
showCreatedPassword = true;
await loadUsers(domainName); await loadUsers(domainName);
closeModal();
} else { } else {
const data = await res.json();
createError = data.error || 'Failed to create user'; createError = data.error || 'Failed to create user';
} }
} catch (e) { } catch (e) {
@ -87,7 +85,7 @@
} }
async function copyPassword() { async function copyPassword() {
await navigator.clipboard.writeText(createdPassword); await navigator.clipboard.writeText(newUser.password);
} }
async function deleteUser(id: number) { async function deleteUser(id: number) {
@ -211,18 +209,26 @@
</div> </div>
</fieldset> </fieldset>
<fieldset class="fieldset mt-4"> <fieldset class="fieldset mt-4">
<legend class="fieldset-legend">Password (leave empty to generate)</legend> <legend class="fieldset-legend">Password</legend>
<div class="join w-full"> <div class="join w-full">
<input <input
type={showCreatedPassword ? 'text' : 'password'} type={showPassword ? 'text' : 'password'}
class="input input-bordered join-item flex-1" class="input input-bordered join-item flex-1 font-mono"
bind:value={newUser.password} bind:value={newUser.password}
required
/> />
<button type="button" class="btn join-item" onclick={() => showCreatedPassword = !showCreatedPassword}> <button type="button" class="btn join-item" onclick={() => showPassword = !showPassword}>
{showCreatedPassword ? '🙈' : '👁️'} {#if showPassword}
<EyeOff class="h-5 w-5" />
{:else}
<Eye class="h-5 w-5" />
{/if}
</button> </button>
<button type="button" class="btn btn-secondary join-item" onclick={generatePassword}> <button type="button" class="btn join-item" onclick={() => navigator.clipboard.writeText(newUser.password)}>
Generate Copy
</button>
<button type="button" class="btn btn-secondary join-item" onclick={() => newUser.password = generatePasswordStr()}>
Regenerate
</button> </button>
</div> </div>
</fieldset> </fieldset>
@ -235,27 +241,14 @@
min="0" min="0"
/> />
</fieldset> </fieldset>
{#if showCreatedPassword && createdPassword}
<div class="alert alert-success mt-4">
<div class="flex-1">
<label class="text-sm font-medium">User created! Copy password:</label>
<div class="flex items-center gap-2 mt-1">
<code class="flex-1 bg-base-200 p-2 rounded text-sm break-all">{createdPassword}</code>
<button type="button" class="btn btn-sm" onclick={copyPassword}>
Copy
</button>
</div>
</div>
</div>
{/if}
{#if createError} {#if createError}
<div class="alert alert-error mt-4"> <div class="alert alert-error mt-4">
<span>{createError}</span> <span>{createError}</span>
</div> </div>
{/if} {/if}
<div class="modal-action"> <div class="modal-action">
<button type="button" class="btn" onclick={closeModal}>Close</button> <button type="button" class="btn" onclick={closeModal}>Cancel</button>
<button type="submit" class="btn btn-primary" disabled={showCreatedPassword}>Create</button> <button type="submit" class="btn btn-primary">Create</button>
</div> </div>
</form> </form>
</div> </div>