v1.0.22
This commit is contained in:
1 parent
aefa5e7925
commit
b55e1e5aad
107 files changed
+2193
-443
No files matched your search
@@ -8,9 +8,26 @@
|
||||
imageUrl: string;
|
||||
onCancel: () => void;
|
||||
onSave: (dataUrl: string) => void;
|
||||
cropShape?: 'round' | 'rect';
|
||||
outputSize?: number;
|
||||
outputFormat?: 'image/jpeg' | 'image/webp';
|
||||
outputQuality?: number;
|
||||
title?: string;
|
||||
saveLabel?: string;
|
||||
}
|
||||
|
||||
let { show, imageUrl, onCancel, onSave }: Props = $props();
|
||||
let {
|
||||
show,
|
||||
imageUrl,
|
||||
onCancel,
|
||||
onSave,
|
||||
cropShape = 'round',
|
||||
outputSize = 256,
|
||||
outputFormat = 'image/jpeg',
|
||||
outputQuality = 0.9,
|
||||
title = 'Crop avatar',
|
||||
saveLabel = 'Save avatar'
|
||||
}: Props = $props();
|
||||
|
||||
// Cropper state
|
||||
let crop = $state({ x: 0, y: 0 });
|
||||
@@ -144,9 +161,9 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Set canvas size to output size (256x256 for avatar)
|
||||
canvas.width = 256;
|
||||
canvas.height = 256;
|
||||
// Set canvas size to output size
|
||||
canvas.width = outputSize;
|
||||
canvas.height = outputSize;
|
||||
|
||||
// Ensure we use a square crop area to avoid stretching
|
||||
// Center the square within the original crop area
|
||||
@@ -163,12 +180,12 @@
|
||||
size,
|
||||
0,
|
||||
0,
|
||||
256,
|
||||
256
|
||||
outputSize,
|
||||
outputSize
|
||||
);
|
||||
|
||||
// Convert to data URL
|
||||
const dataUrl = canvas.toDataURL('image/jpeg', 0.9);
|
||||
const dataUrl = canvas.toDataURL(outputFormat, outputQuality);
|
||||
resolve(dataUrl);
|
||||
};
|
||||
|
||||
@@ -204,16 +221,18 @@
|
||||
handleCancel();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
{#if show && imageUrl}
|
||||
<div class="fixed inset-0 bg-black/80 z-50 flex items-center justify-center p-4">
|
||||
<div class="fixed inset-0 bg-black/80 z-[200] flex items-center justify-center p-4">
|
||||
<div class="bg-background rounded-lg w-full max-w-2xl max-h-[90vh] flex flex-col shadow-2xl">
|
||||
<!-- Header -->
|
||||
<div class="p-4 border-b">
|
||||
<h3 class="text-lg font-semibold">Crop avatar</h3>
|
||||
<h3 class="text-lg font-semibold">{title}</h3>
|
||||
<p class="text-sm text-muted-foreground mt-1">
|
||||
Drag to reposition. Use the slider to zoom.
|
||||
</p>
|
||||
@@ -226,7 +245,8 @@
|
||||
bind:crop
|
||||
bind:zoom
|
||||
aspect={1}
|
||||
cropShape="round"
|
||||
minZoom={0.5}
|
||||
cropShape={cropShape}
|
||||
showGrid={false}
|
||||
on:cropcomplete={onCropComplete}
|
||||
on:mediaLoaded={onMediaLoaded}
|
||||
@@ -239,7 +259,7 @@
|
||||
<ZoomOut class="w-5 h-5 text-muted-foreground shrink-0" />
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
min="0.5"
|
||||
max="3"
|
||||
step="0.1"
|
||||
bind:value={zoom}
|
||||
@@ -266,7 +286,7 @@
|
||||
disabled={saving || !imageLoaded}
|
||||
>
|
||||
<Check class="w-4 h-4" />
|
||||
{saving ? 'Uploading...' : !imageLoaded ? 'Loading...' : 'Save avatar'}
|
||||
{saving ? 'Uploading...' : !imageLoaded ? 'Loading...' : saveLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { getIconComponent, isCustomIcon } from '$lib/utils/icons';
|
||||
import type { Component } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
icon: string;
|
||||
envId: number;
|
||||
class?: string;
|
||||
cacheBust?: string | number;
|
||||
}
|
||||
|
||||
let { icon, envId, class: className = 'w-4 h-4', cacheBust }: Props = $props();
|
||||
|
||||
const isCustom = $derived(isCustomIcon(icon));
|
||||
const LucideIcon = $derived(!isCustom ? getIconComponent(icon) : null) as Component | null;
|
||||
const imgSrc = $derived(isCustom ? `/api/environments/${envId}/icon${cacheBust ? `?v=${cacheBust}` : ''}` : '');
|
||||
</script>
|
||||
|
||||
{#if isCustom}
|
||||
<img src={imgSrc} alt="" class="{className} rounded-full object-cover" />
|
||||
{:else if LucideIcon}
|
||||
<LucideIcon class={className} />
|
||||
{/if}
|
||||
@@ -43,15 +43,17 @@
|
||||
let selectedEditorFont = $state('system-mono');
|
||||
|
||||
onMount(async () => {
|
||||
// Load monospace fonts for dropdown previews
|
||||
// Load bundled monospace fonts for dropdown previews
|
||||
const fontsToLoad = monospaceFonts.filter(f => f.googleFont);
|
||||
if (fontsToLoad.length > 0) {
|
||||
const families = fontsToLoad.map(f => `family=${f.googleFont}`).join('&');
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = `https://fonts.googleapis.com/css2?${families}&display=swap`;
|
||||
link.onload = () => { monoFontsLoaded = true; };
|
||||
document.head.appendChild(link);
|
||||
let loaded = 0;
|
||||
for (const font of fontsToLoad) {
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = `/fonts/${font.id}/font.css`;
|
||||
link.onload = () => { if (++loaded >= fontsToLoad.length) monoFontsLoaded = true; };
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
} else {
|
||||
monoFontsLoaded = true;
|
||||
}
|
||||
|
||||
@@ -29,7 +29,22 @@
|
||||
'Europe/Kyiv': 'Europe/Kiev',
|
||||
'Asia/Ho_Chi_Minh': 'Asia/Saigon',
|
||||
'America/Nuuk': 'America/Godthab',
|
||||
'Pacific/Kanton': 'Pacific/Enderbury'
|
||||
'Pacific/Kanton': 'Pacific/Enderbury',
|
||||
'Asia/Kolkata': 'Asia/Calcutta',
|
||||
'Asia/Kathmandu': 'Asia/Katmandu',
|
||||
'Asia/Yangon': 'Asia/Rangoon',
|
||||
'Asia/Kashgar': 'Asia/Urumqi',
|
||||
'Atlantic/Faroe': 'Atlantic/Faeroe',
|
||||
'Europe/Uzhgorod': 'Europe/Kiev',
|
||||
'Europe/Zaporozhye': 'Europe/Kiev',
|
||||
'America/Atikokan': 'America/Coral_Harbour',
|
||||
'America/Argentina/Buenos_Aires': 'America/Buenos_Aires',
|
||||
'America/Argentina/Catamarca': 'America/Catamarca',
|
||||
'America/Argentina/Cordoba': 'America/Cordoba',
|
||||
'America/Argentina/Jujuy': 'America/Jujuy',
|
||||
'America/Argentina/Mendoza': 'America/Mendoza',
|
||||
'Pacific/Pohnpei': 'Pacific/Ponape',
|
||||
'Pacific/Chuuk': 'Pacific/Truk'
|
||||
};
|
||||
|
||||
// Reverse map: canonical → modern alias names (for display hints)
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { Cpu, MemoryStick, Box, Globe, ChevronDown, Check, HardDrive, Clock, Wifi, WifiOff, Route, UndoDot, Icon, AlertCircle, Loader2 } from 'lucide-svelte';
|
||||
import { Cpu, MemoryStick, Box, Globe, ChevronDown, Check, HardDrive, Clock, Wifi, WifiOff, Route, UndoDot, Icon, AlertCircle, Loader2, Search, X } from 'lucide-svelte';
|
||||
import { whale } from '@lucide/lab';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { currentEnvironment, environments, type Environment } from '$lib/stores/environment';
|
||||
import { sseConnected } from '$lib/stores/events';
|
||||
import { getIconComponent } from '$lib/utils/icons';
|
||||
import EnvironmentIcon from '$lib/components/EnvironmentIcon.svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { themeStore, type FontSize } from '$lib/stores/theme';
|
||||
import { getTimeFormat } from '$lib/stores/settings';
|
||||
@@ -77,6 +77,8 @@
|
||||
let diskUsageLoading = $state(false);
|
||||
let envAbortController: AbortController | null = null; // Aborts ALL requests when switching envs
|
||||
let showDropdown = $state(false);
|
||||
let searchTerm = $state('');
|
||||
let searchInputRef = $state<HTMLInputElement | null>(null);
|
||||
let currentEnvId = $state<number | null>(null);
|
||||
let lastUpdated = $state<Date>(new Date());
|
||||
let isConnected = $state(false);
|
||||
@@ -94,6 +96,22 @@
|
||||
|
||||
// Reactive environment list from store
|
||||
let envList = $derived($environments);
|
||||
const showSearch = $derived(envList.length > 8);
|
||||
const filteredEnvList = $derived(
|
||||
searchTerm.trim()
|
||||
? envList.filter((e: Environment) => e.name.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||
: envList
|
||||
);
|
||||
|
||||
// Clear search and focus when dropdown opens/closes
|
||||
$effect(() => {
|
||||
if (showDropdown && showSearch) {
|
||||
// Use tick to wait for DOM render
|
||||
setTimeout(() => searchInputRef?.focus(), 0);
|
||||
} else {
|
||||
searchTerm = '';
|
||||
}
|
||||
});
|
||||
|
||||
sseConnected.subscribe(v => isConnected = v);
|
||||
|
||||
@@ -349,14 +367,12 @@
|
||||
class="flex items-center gap-1.5 -ml-1 px-1 py-1 rounded-md hover:bg-muted transition-colors cursor-pointer"
|
||||
>
|
||||
{#if hostInfo?.environment && Number(hostInfo.environment.id) === Number(currentEnvId)}
|
||||
{@const EnvIcon = getIconComponent(hostInfo.environment.icon || 'globe')}
|
||||
<EnvIcon class="{iconSizeLargeClass()} text-primary" />
|
||||
<EnvironmentIcon icon={hostInfo.environment.icon || 'globe'} envId={hostInfo.environment.id} class="{iconSizeLargeClass()} text-primary" />
|
||||
<span class="font-medium text-foreground">{hostInfo.environment.name}</span>
|
||||
{:else if currentEnvId && envList.length > 0}
|
||||
{@const currentEnv = envList.find(e => Number(e.id) === Number(currentEnvId))}
|
||||
{#if currentEnv}
|
||||
{@const EnvIcon = getIconComponent(currentEnv.icon || 'globe')}
|
||||
<EnvIcon class="{iconSizeLargeClass()} text-primary" />
|
||||
<EnvironmentIcon icon={currentEnv.icon || 'globe'} envId={currentEnv.id} class="{iconSizeLargeClass()} text-primary" />
|
||||
<span class="font-medium text-foreground">{currentEnv.name}</span>
|
||||
{:else}
|
||||
<Globe class="{iconSizeLargeClass()} text-muted-foreground" />
|
||||
@@ -371,9 +387,40 @@
|
||||
|
||||
{#if showDropdown && envList.length > 0}
|
||||
<div class="absolute top-full left-0 mt-1 min-w-56 w-max max-w-80 bg-popover border rounded-md shadow-lg z-50">
|
||||
<div class="py-1">
|
||||
{#each envList as env (env.id)}
|
||||
{@const EnvIcon = getIconComponent(env.icon || 'globe')}
|
||||
{#if showSearch}
|
||||
<div class="sticky top-0 bg-popover border-b px-2 py-1.5">
|
||||
<div class="relative">
|
||||
<Search class="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
|
||||
<input
|
||||
bind:this={searchInputRef}
|
||||
bind:value={searchTerm}
|
||||
type="text"
|
||||
placeholder="Search environments..."
|
||||
class="w-full pl-7 pr-7 py-1 text-sm bg-transparent border rounded focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
if (searchTerm) {
|
||||
searchTerm = '';
|
||||
} else {
|
||||
showDropdown = false;
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{#if searchTerm}
|
||||
<button
|
||||
class="absolute right-1.5 top-1/2 -translate-y-1/2 p-0.5 rounded hover:bg-muted"
|
||||
onclick={(e) => { e.stopPropagation(); searchTerm = ''; searchInputRef?.focus(); }}
|
||||
>
|
||||
<X class="w-3 h-3 text-muted-foreground" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="py-1 max-h-[calc(100vh-8rem)] overflow-y-auto">
|
||||
{#each filteredEnvList as env (env.id)}
|
||||
{@const isOffline = offlineEnvIds.has(env.id)}
|
||||
{@const isSwitching = switchingEnvId === env.id}
|
||||
<button
|
||||
@@ -387,7 +434,7 @@
|
||||
{:else if isOffline}
|
||||
<WifiOff class="{iconSizeLargeClass()} text-destructive shrink-0" />
|
||||
{:else}
|
||||
<EnvIcon class="{iconSizeLargeClass()} text-muted-foreground shrink-0" />
|
||||
<EnvironmentIcon icon={env.icon || 'globe'} envId={env.id} class="{iconSizeLargeClass()} text-muted-foreground shrink-0" />
|
||||
{/if}
|
||||
<span class="flex-1 whitespace-nowrap" class:text-muted-foreground={isOffline}>{env.name}</span>
|
||||
{#if isOffline && !isSwitching}
|
||||
@@ -396,6 +443,10 @@
|
||||
<Check class="{iconSizeLargeClass()} text-primary shrink-0" />
|
||||
{/if}
|
||||
</button>
|
||||
{:else}
|
||||
<div class="px-3 py-2 text-sm text-muted-foreground">
|
||||
No matching environments
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user