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:
parent
8b2d2649ac
commit
46d9ff26dd
5 changed files with 49 additions and 45 deletions
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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=="],
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue