{
+ // Only close if click is outside this dropdown
+ if (open && dropEl && !dropEl.contains(e.target as Node)) {
+ open = false;
+ // stopPropagation prevents the click from registering on the dropdown again and reopening it
+ e.stopPropagation();
+ }
+ }}
+/>
+
+
+ onclick?.()}>
+ {@render label()}
+
+
+ {@render content()}
+
+
diff --git a/src/lib/components/IconContainer.svelte b/src/lib/components/IconContainer.svelte
deleted file mode 100644
index f34fb0ad..00000000
--- a/src/lib/components/IconContainer.svelte
+++ /dev/null
@@ -1,20 +0,0 @@
-
-
-
-
-
diff --git a/src/lib/components/SearchBar.svelte b/src/lib/components/SearchBar.svelte
new file mode 100644
index 00000000..253ad747
--- /dev/null
+++ b/src/lib/components/SearchBar.svelte
@@ -0,0 +1,61 @@
+
+
+
+
+
+
+ {
+ if (requestSubmit) {
+ if (timeout) {
+ clearTimeout(timeout);
+ }
+ timeout = setTimeout(requestSubmit, requestDelay);
+ }
+ }}
+ onfocus={(e) => {
+ const target = e.currentTarget;
+ // https://stackoverflow.com/a/10576409
+ setTimeout(() => target.setSelectionRange(value.length, value.length), 0);
+ }}
+ />
+
+
+
diff --git a/src/lib/components/SecureDisplay.svelte b/src/lib/components/SecureDisplay.svelte
new file mode 100644
index 00000000..50310574
--- /dev/null
+++ b/src/lib/components/SecureDisplay.svelte
@@ -0,0 +1,29 @@
+
+
+
+
+ {#if value?.length}
+ (visible = !visible)}>
+
+
+ {/if}
+
diff --git a/src/lib/components/SortTable.svelte b/src/lib/components/SortTable.svelte
index 58b72aa3..f052094e 100644
--- a/src/lib/components/SortTable.svelte
+++ b/src/lib/components/SortTable.svelte
@@ -4,7 +4,8 @@
-->
+
+
+ {@render children?.()}
+
+
+
diff --git a/src/lib/icons/ArrowDownIcon.svelte b/src/lib/icons/ArrowDownIcon.svelte
deleted file mode 100644
index bd0bba2e..00000000
--- a/src/lib/icons/ArrowDownIcon.svelte
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
-
-
diff --git a/src/lib/icons/ArrowUpIcon.svelte b/src/lib/icons/ArrowUpIcon.svelte
deleted file mode 100644
index fb9ad43a..00000000
--- a/src/lib/icons/ArrowUpIcon.svelte
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
-
-
diff --git a/src/lib/icons/IconContainer.svelte b/src/lib/icons/IconContainer.svelte
new file mode 100644
index 00000000..8343b9bb
--- /dev/null
+++ b/src/lib/icons/IconContainer.svelte
@@ -0,0 +1,28 @@
+
+
+
+ {#if imageCheck.test(icon)}
+
+ {:else}
+
+ {/if}
+
diff --git a/src/lib/icons/app-builders/dictionaryappbuilder.svg b/src/lib/icons/app-builders/dictionaryappbuilder.svg
new file mode 100644
index 00000000..6329653c
--- /dev/null
+++ b/src/lib/icons/app-builders/dictionaryappbuilder.svg
@@ -0,0 +1,421 @@
+
+
+
+
+
+
+
+ image/svg+xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/lib/icons/app-builders/keyboardappbuilder.svg b/src/lib/icons/app-builders/keyboardappbuilder.svg
new file mode 100644
index 00000000..74d0514e
--- /dev/null
+++ b/src/lib/icons/app-builders/keyboardappbuilder.svg
@@ -0,0 +1,289 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/lib/icons/app-builders/readingappbuilder.svg b/src/lib/icons/app-builders/readingappbuilder.svg
new file mode 100644
index 00000000..080b6d0e
--- /dev/null
+++ b/src/lib/icons/app-builders/readingappbuilder.svg
@@ -0,0 +1,422 @@
+
+
+
+
+
+
+
+ image/svg+xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/lib/icons/app-builders/scriptureappbuilder.svg b/src/lib/icons/app-builders/scriptureappbuilder.svg
new file mode 100644
index 00000000..7a1a5dc8
--- /dev/null
+++ b/src/lib/icons/app-builders/scriptureappbuilder.svg
@@ -0,0 +1,430 @@
+
+
+
+
+
+
+
+ image/svg+xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/lib/icons/index.ts b/src/lib/icons/index.ts
index 1799b70c..e04e0d8a 100644
--- a/src/lib/icons/index.ts
+++ b/src/lib/icons/index.ts
@@ -1,4 +1,56 @@
-import ArrowDownIcon from './ArrowDownIcon.svelte';
-import ArrowUpIcon from './ArrowUpIcon.svelte';
+import type { ApplicationType } from '$lib/valibot';
-export { ArrowDownIcon, ArrowUpIcon };
+const appIcons = import.meta.glob('/src/lib/icons/app-builders/*.svg', {
+ eager: true,
+ import: 'default'
+}) as Record;
+
+export function getAppIcon(type: ApplicationType) {
+ return appIcons[`/src/lib/icons/app-builders/${type}.svg`] ?? '';
+}
+
+export function getStatusIcon(status: string) {
+ switch (status.toLowerCase()) {
+ case 'success':
+ return { color: 'text-success', icon: 'icon-park-outline:success' };
+ case 'failure':
+ return { color: 'text-error', icon: 'material-symbols:error-outline-rounded' };
+ case 'aborted':
+ return { color: 'text-warning', icon: 'ix:cancelled' };
+ case 'pending':
+ return { icon: 'material-symbols:pending-outline' };
+ default:
+ return { icon: Icons.Unknown };
+ }
+}
+
+export const Icons = {
+ Bucket: 'logos:aws-s3',
+ Build: 'material-symbols:build',
+ Close: 'mdi:close',
+ CodeBuild: 'logos:aws-codebuild',
+ CodeCommit: 'logos:aws-codecommit',
+ Dashboard: 'clarity:dashboard-line',
+ Delete: 'mdi:trash',
+ Edit: 'mdi:pencil',
+ Hamburger: 'mdi:hamburger-menu',
+ Invisible: 'mdi:eye-off-outline',
+ Key: 'material-symbols:key',
+ Logout: 'mdi:logout',
+ Open: 'mdi:open-in-new',
+ Product: 'system-uicons:box',
+ Project: 'material-symbols:credit-card-outline',
+ Publish: 'material-symbols:publish',
+ Search: 'mdi:search',
+ SortAsc: 'bx:sort-a-z',
+ SortDesc: 'bx:sort-z-a',
+ Unknown: 'carbon:unknown',
+ User: 'mdi:user',
+ View: 'mdi:eye',
+ Visible: 'mdi:eye'
+} as const;
+
+export type IconType =
+ | (typeof Icons)[keyof typeof Icons]
+ | ReturnType
+ | ReturnType['icon'];
diff --git a/src/lib/server/job-executors/system.ts b/src/lib/server/job-executors/system.ts
index 65ca7c25..c36fbe08 100644
--- a/src/lib/server/job-executors/system.ts
+++ b/src/lib/server/job-executors/system.ts
@@ -17,6 +17,7 @@ import { S3 } from '../aws/s3';
import type { BullMQ } from '../bullmq';
import { prisma } from '../prisma';
import { AWSVars } from '$lib/server/aws/vars';
+import { type ApplicationType, applicationTypes } from '$lib/valibot';
type Logger = (msg: string) => void;
@@ -118,21 +119,12 @@ async function copyFolder(sourceFolder: string, bucket: string, log: Logger) {
}
}
-const appNames = [
- 'scriptureappbuilder',
- 'readingappbuilder',
- 'dictionaryappbuilder',
- 'keyboardappbuilder'
-] as const;
-
-type App = (typeof appNames)[number];
-
export async function refreshAppVersions(
job: Job
): Promise {
// db connectivity handled by server hooks
// Prepare default response structure
- let versions: Map | null = null;
+ let versions: Map | null = null;
let imageHash: string | null = null;
const repoConfig = AWSVars.imageRepo();
@@ -305,7 +297,7 @@ async function fetchAllAppVersionsFromManifest(
repoName: string,
imageTag: string,
log: Logger
-): Promise | null> {
+): Promise | null> {
try {
log(`batchGetImage for ${imageTag}`);
const resp = await client.send(
@@ -403,14 +395,14 @@ async function fetchAllAppVersionsFromManifest(
}
// Extract version for each app from labels
- const appVersions = new Map(
- appNames
+ const appVersions = new Map(
+ applicationTypes
.map((app) => {
const labelKey = `org.opencontainers.image.version_${app}`;
if (labels[labelKey]) {
const version = labels[labelKey];
log(`found version for ${app}: ${version} (label: ${labelKey}) for ${imageTag}`);
- return [app, version] as [App, string];
+ return [app, version] as [ApplicationType, string];
} else {
log(`no version found for ${app} (looked for label: ${labelKey}) for ${imageTag}`);
return null;
diff --git a/src/lib/server/models/job.ts b/src/lib/server/models/job.ts
index a9e83999..c3a2edec 100644
--- a/src/lib/server/models/job.ts
+++ b/src/lib/server/models/job.ts
@@ -1,9 +1,11 @@
+import type { ApplicationType } from '$lib/valibot';
+
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace Job {
- export enum AppType {
- ScriptureApp = 'scriptureappbuilder',
- ReadingApp = 'readingappbuilder',
- DictionaryApp = 'dictionaryappbuilder',
- KeyboardApp = 'keyboardappbuilder'
- }
+ export const AppType = {
+ ScriptureApp: 'scriptureappbuilder',
+ ReadingApp: 'readingappbuilder',
+ DictionaryApp: 'dictionaryappbuilder',
+ KeyboardApp: 'keyboardappbuilder'
+ } satisfies Record;
}
diff --git a/src/lib/server/utils.ts b/src/lib/server/utils.ts
index 98863cea..e69de29b 100644
--- a/src/lib/server/utils.ts
+++ b/src/lib/server/utils.ts
@@ -1,5 +0,0 @@
-export class Utils {
- public static getPrefix() {
- return new Date().toISOString();
- }
-}
diff --git a/src/lib/utils/sorting.ts b/src/lib/utils/sorting.ts
new file mode 100644
index 00000000..2dc69b13
--- /dev/null
+++ b/src/lib/utils/sorting.ts
@@ -0,0 +1,18 @@
+interface NamedEntity {
+ Name: string | null | undefined;
+}
+
+export function byName(
+ a: NamedEntity | null | undefined,
+ b: NamedEntity | null | undefined
+): number {
+ return byString(a?.Name, b?.Name);
+}
+
+export function byString(a: string | null | undefined, b: string | null | undefined): number {
+ return a?.localeCompare(b ?? '', 'en-US') ?? 0;
+}
+
+export function byNumber(a: number | bigint | null, b: number | bigint | null): number {
+ return a === b ? 0 : (a ?? 0) > (b ?? 0) ? 1 : -1;
+}
diff --git a/src/lib/utils/time.ts b/src/lib/utils/time.ts
index 3382e0b2..21b04f17 100644
--- a/src/lib/utils/time.ts
+++ b/src/lib/utils/time.ts
@@ -1,13 +1,64 @@
+import { type Readable, readable } from 'svelte/store';
+
+const locale = 'en-US';
+const relativeTimeFormatter = new Intl.RelativeTimeFormat(locale);
+
type DateType = Date | string | null;
+export function getRelativeTime(date: DateType): Readable;
+export function getRelativeTime(date: DateType[]): Readable;
+export function getRelativeTime(date: DateType | DateType[]): Readable {
+ if (Array.isArray(date)) {
+ return readable(
+ date.map((d) => getRelativeTimeValue(d)),
+ (set) => {
+ const interval = setInterval(() => {
+ set(date.map((d) => getRelativeTimeValue(d)));
+ }, 1000);
+ return () => clearInterval(interval);
+ }
+ );
+ }
+ return readable(getRelativeTimeValue(date), (set) => {
+ const interval = setInterval(() => {
+ set(getRelativeTimeValue(date));
+ }, 1000);
+ return () => clearInterval(interval);
+ });
+}
+export function getRelativeTimeValue(date: DateType): string {
+ if (typeof date === 'string') {
+ date = new Date(date);
+ }
+ if (!date) return '-';
+ // in miliseconds
+ const units = {
+ year: 24 * 60 * 60 * 1000 * 365,
+ month: (24 * 60 * 60 * 1000 * 365) / 12,
+ day: 24 * 60 * 60 * 1000,
+ hour: 60 * 60 * 1000,
+ minute: 60 * 1000,
+ second: 1000
+ } as const;
+ const elapsed = date.valueOf() - new Date().valueOf();
+ for (const u in units) {
+ if (Math.abs(elapsed) > units[u as keyof typeof units] || u == 'second') {
+ const ret = relativeTimeFormatter.format(
+ Math.round(elapsed / units[u as keyof typeof units]),
+ u as keyof typeof units
+ );
+ return ret;
+ }
+ }
+ return 'ERROR';
+}
export function getTimeDateString(date: DateType): string {
if (typeof date === 'string') {
date = new Date(date);
}
- // using en-US as locale until we need to support other locales
- return `${date?.toLocaleDateString('en-US') ?? '-'} ${
+ return `${date?.toLocaleDateString(locale) ?? '-'} ${
date
- ?.toLocaleTimeString('en-US', {
+ ?.toLocaleTimeString(locale, {
hour: 'numeric',
minute: '2-digit',
hour12: true
diff --git a/src/lib/valibot.ts b/src/lib/valibot.ts
index cbd25fd4..d978e2bd 100644
--- a/src/lib/valibot.ts
+++ b/src/lib/valibot.ts
@@ -35,6 +35,7 @@ export function selectFrom>(entries: T) {
}
export const tableSchema = v.object({
+ search: v.string(),
page: paginateSchema,
sort: v.nullable(
v.object({
@@ -119,3 +120,11 @@ export function trimStrings>(
}
return obj;
}
+
+export const applicationTypes = [
+ 'scriptureappbuilder',
+ 'dictionaryappbuilder',
+ 'readingappbuilder',
+ 'keyboardappbuilder'
+] as const;
+export type ApplicationType = (typeof applicationTypes)[number];
diff --git a/src/routes/(api)/project/+server.ts b/src/routes/(api)/project/+server.ts
index bcc512d7..e9582e1f 100644
--- a/src/routes/(api)/project/+server.ts
+++ b/src/routes/(api)/project/+server.ts
@@ -4,18 +4,10 @@ import type { RequestHandler } from './$types';
import { AWSVars } from '$lib/server/aws/vars';
import { prisma } from '$lib/server/prisma';
import { ErrorResponse } from '$lib/utils';
-import { stringLimits } from '$lib/valibot';
+import { applicationTypes, stringLimits } from '$lib/valibot';
const projectSchema = v.strictObject({
- app_id: v.pipe(
- v.string(),
- v.picklist([
- 'scriptureappbuilder',
- 'dictionaryappbuilder',
- 'readingappbuilder',
- 'keyboardappbuilder'
- ])
- ),
+ app_id: v.pipe(v.string(), v.picklist(applicationTypes)),
project_name: v.pipe(v.string(), v.maxBytes(stringLimits.project.project_name)),
language_code: v.pipe(v.string(), v.maxBytes(stringLimits.project.language_code)),
storage_type: v.literal('s3')
diff --git a/src/routes/(ui)/+layout.server.ts b/src/routes/(ui)/+layout.server.ts
index e5cf9daa..62e7f036 100644
--- a/src/routes/(ui)/+layout.server.ts
+++ b/src/routes/(ui)/+layout.server.ts
@@ -1,5 +1,15 @@
import type { LayoutServerLoad } from './$types';
+import { prisma } from '$lib/server/prisma';
export const load = (async ({ locals }) => {
- return { userEmail: locals.userEmail };
+ return {
+ userEmail: locals.userEmail,
+ count: {
+ client: await prisma.client.count(),
+ project: await prisma.project.count(),
+ job: await prisma.job.count(),
+ build: await prisma.build.count(),
+ release: await prisma.release.count()
+ }
+ };
}) satisfies LayoutServerLoad;
diff --git a/src/routes/(ui)/+layout.svelte b/src/routes/(ui)/+layout.svelte
index e1fe5692..c3a9f6f2 100644
--- a/src/routes/(ui)/+layout.svelte
+++ b/src/routes/(ui)/+layout.svelte
@@ -2,7 +2,9 @@
import type { Snippet } from 'svelte';
import type { PageData } from './$types';
import { page } from '$app/state';
- import IconContainer from '$lib/components/IconContainer.svelte';
+ import Dropdown from '$lib/components/Dropdown.svelte';
+ import { type IconType, Icons } from '$lib/icons';
+ import IconContainer from '$lib/icons/IconContainer.svelte';
interface Props {
children: Snippet;
@@ -11,61 +13,150 @@
let { children, data }: Props = $props();
- function isUrlActive(route: string) {
- return page.url.pathname === route;
+ function isUrlActive(route: string, exact = false) {
+ return exact ? page.url.pathname === route : page.url.pathname.startsWith(route);
}
+
+ let drawerToggle: HTMLInputElement;
+ function closeDrawer() {
+ if (drawerToggle.checked) {
+ drawerToggle.click();
+ }
+ }
+
+ const links: { target: keyof PageData['count']; icon: IconType; title: string }[] = [
+ { target: 'client', icon: Icons.User, title: 'Clients' },
+ { target: 'project', icon: Icons.Project, title: 'Projects' },
+ { target: 'job', icon: Icons.Product, title: 'Jobs' },
+ { target: 'build', icon: Icons.Build, title: 'Builds' },
+ { target: 'release', icon: Icons.Publish, title: 'Releases' }
+ ];
-