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"
"io/fs"
"net/http"
"os"
)
//go:embed all:embed
var Files embed.FS
func FrontendFileSystem() http.FileSystem {
return http.FS(Files)
func FrontendFileSystem() (http.FileSystem, string) {
if os.Getenv("USE_EMBEDDED") == "false" {
return http.Dir("../frontend/build"), ""
}
return http.FS(Files), "embed/"
}
func FrontendFS() fs.FS {
if os.Getenv("USE_EMBEDDED") == "false" {
return os.DirFS("../frontend/build")
}
return Files
}

View file

@ -120,7 +120,7 @@ func main() {
// Get the embedded filesystem containing the frontend.
// The frontend is compiled into the binary during build time.
frontendFS := FrontendFileSystem()
frontendFS, frontendPrefix := FrontendFileSystem()
// Set Gin mode based on environment.
// 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.
// This must be the last route to catch all unmatched paths.
engine.NoRoute(func(c *gin.Context) {
path := c.Request.URL.Path
reqPath := c.Request.URL.Path
// If it's an API path, return 404.
// 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"})
return
}
// Serve SvelteKit hashed assets at /_app/*
if strings.HasPrefix(path, "/_app/") {
assetPath := "embed/_app/" + strings.TrimPrefix(path, "/_app/")
serveGinStaticFile(c, frontendFS, assetPath)
if strings.HasPrefix(reqPath, "/_app/") {
filePath := frontendPrefix + "_app/" + strings.TrimPrefix(reqPath, "/_app/")
serveGinStaticFile(c, frontendFS, filePath)
return
}
// Serve favicon
if path == "/favicon.png" {
serveGinStaticFile(c, frontendFS, "embed/favicon.png")
if reqPath == "/favicon.png" {
serveGinStaticFile(c, frontendFS, frontendPrefix+"favicon.png")
return
}
// For all other paths, serve the SPA index.html.
// 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"

View file

@ -14,6 +14,7 @@
"daisyui": "^5.5.19",
"postcss": "^8.5.8",
"svelte": "^5.0.0",
"svelte-heros": "^8.0.1",
"tailwindcss": "^4.2.2",
"typescript": "^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-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=="],
"tapable": ["tapable@2.3.2", "", {}, "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA=="],

View file

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

View file

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