Compare commits

...

3 commits

Author SHA1 Message Date
May 269db33bec
feat: PFP switching 2024-11-20 02:17:45 -08:00
May a1294b145f
Svelte 5 port 2024-11-19 18:40:03 -08:00
May eb5b86556d
feat: 🚧 2024-11-19 17:34:49 -08:00
33 changed files with 1270 additions and 2026 deletions

View file

@ -1,8 +1,9 @@
{ {
"useTabs": true, "singleQuote": false,
"singleQuote": true, "trailingComma": "es5",
"trailingComma": "none", "arrowParens": "avoid",
"printWidth": 100, "tabWidth": 4,
"semi": false,
"plugins": ["prettier-plugin-svelte"], "plugins": ["prettier-plugin-svelte"],
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }] "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
} }

1940
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
{ {
"name": "ava-node", "name": "ava",
"version": "1.3.2", "version": "2.0.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
@ -13,23 +13,24 @@
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-node": "^5.2.0", "@sveltejs/adapter-node": "^5.2.0",
"@sveltejs/vite-plugin-svelte": "^3.0.0", "@sveltejs/vite-plugin-svelte": "^4.0.0",
"@types/node": "^20.14.10", "@types/node": "^20.14.10",
"prettier": "^3.1.1", "prettier": "^3.1.1",
"prettier-plugin-svelte": "^3.1.2", "prettier-plugin-svelte": "^3.2.6",
"prisma": "^5.16.2", "prisma": "^5.16.2",
"svelte": "^4.2.7", "svelte": "^5.0.0",
"svelte-check": "^3.6.0", "svelte-check": "^4.0.0",
"tslib": "^2.4.1", "tslib": "^2.4.1",
"typescript": "^5.0.0", "typescript": "^5.5.0",
"vite": "^5.0.3" "vite": "^5.4.4"
}, },
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@fluentui/svg-icons": "^1.1.265",
"@fontsource-variable/inter": "^5.0.18", "@fontsource-variable/inter": "^5.0.18",
"@fontsource-variable/noto-sans-mono": "^5.0.20", "@fontsource-variable/noto-sans-mono": "^5.0.20",
"@prisma/client": "5.16.2", "@prisma/client": "5.16.2",
"@sveltejs/kit": "^2.0.0", "@sveltejs/kit": "^2.5.27",
"mime": "^4.0.4", "mime": "^4.0.4",
"sharp": "^0.33.4" "sharp": "^0.33.4"
}, },

View file

@ -1,9 +0,0 @@
-- CreateTable
CREATE TABLE "Token" (
"id" TEXT NOT NULL PRIMARY KEY,
"token" TEXT NOT NULL,
"refreshToken" TEXT NOT NULL
);
-- CreateIndex
CREATE UNIQUE INDEX "Token_id_key" ON "Token"("id");

View file

@ -1,21 +0,0 @@
/*
Warnings:
- Added the required column `owner` to the `Token` table without a default value. This is not possible if the table is not empty.
*/
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Token" (
"id" TEXT NOT NULL PRIMARY KEY,
"owner" TEXT NOT NULL,
"token" TEXT NOT NULL,
"refreshToken" TEXT NOT NULL
);
INSERT INTO "new_Token" ("id", "refreshToken", "token") SELECT "id", "refreshToken", "token" FROM "Token";
DROP TABLE "Token";
ALTER TABLE "new_Token" RENAME TO "Token";
CREATE UNIQUE INDEX "Token_id_key" ON "Token"("id");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View file

@ -1,15 +0,0 @@
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Token" (
"id" TEXT NOT NULL PRIMARY KEY,
"owner" TEXT NOT NULL,
"token" TEXT NOT NULL,
"refreshToken" TEXT
);
INSERT INTO "new_Token" ("id", "owner", "refreshToken", "token") SELECT "id", "owner", "refreshToken", "token" FROM "Token";
DROP TABLE "Token";
ALTER TABLE "new_Token" RENAME TO "Token";
CREATE UNIQUE INDEX "Token_id_key" ON "Token"("id");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View file

@ -1,8 +0,0 @@
-- CreateTable
CREATE TABLE "User" (
"userId" TEXT NOT NULL PRIMARY KEY,
"identifier" TEXT NOT NULL
);
-- CreateIndex
CREATE UNIQUE INDEX "User_userId_key" ON "User"("userId");

View file

@ -1,12 +0,0 @@
-- CreateTable
CREATE TABLE "Avatar" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
CONSTRAINT "Avatar_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("userId") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "Avatar_id_key" ON "Avatar"("id");
-- CreateIndex
CREATE UNIQUE INDEX "Avatar_userId_key" ON "Avatar"("userId");

View file

@ -1,3 +0,0 @@
-- AlterTable
ALTER TABLE "Avatar" ADD COLUMN "altText" TEXT;
ALTER TABLE "Avatar" ADD COLUMN "source" TEXT;

View file

@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "name" TEXT;

View file

@ -0,0 +1,38 @@
-- CreateTable
CREATE TABLE "Token" (
"id" TEXT NOT NULL PRIMARY KEY,
"owner" TEXT NOT NULL,
"token" TEXT NOT NULL,
"refreshToken" TEXT
);
-- CreateTable
CREATE TABLE "User" (
"userId" TEXT NOT NULL PRIMARY KEY,
"identifier" TEXT NOT NULL,
"name" TEXT,
"currentAvatarId" TEXT
);
-- CreateTable
CREATE TABLE "Avatar" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"usedById" TEXT,
"altText" TEXT,
"source" TEXT,
CONSTRAINT "Avatar_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("userId") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "Avatar_usedById_fkey" FOREIGN KEY ("usedById") REFERENCES "User" ("userId") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "Token_id_key" ON "Token"("id");
-- CreateIndex
CREATE UNIQUE INDEX "User_userId_key" ON "User"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "Avatar_id_key" ON "Avatar"("id");
-- CreateIndex
CREATE UNIQUE INDEX "Avatar_usedById_key" ON "Avatar"("usedById");

View file

@ -0,0 +1,20 @@
/*
Warnings:
- You are about to drop the column `currentAvatarId` on the `User` table. All the data in the column will be lost.
*/
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_User" (
"userId" TEXT NOT NULL PRIMARY KEY,
"identifier" TEXT NOT NULL,
"name" TEXT
);
INSERT INTO "new_User" ("identifier", "name", "userId") SELECT "identifier", "name", "userId" FROM "User";
DROP TABLE "User";
ALTER TABLE "new_User" RENAME TO "User";
CREATE UNIQUE INDEX "User_userId_key" ON "User"("userId");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View file

@ -0,0 +1,34 @@
/*
Warnings:
- You are about to drop the column `usedById` on the `Avatar` table. All the data in the column will be lost.
*/
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Avatar" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"altText" TEXT,
"source" TEXT,
CONSTRAINT "Avatar_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("userId") ON DELETE RESTRICT ON UPDATE CASCADE
);
INSERT INTO "new_Avatar" ("altText", "id", "source", "userId") SELECT "altText", "id", "source", "userId" FROM "Avatar";
DROP TABLE "Avatar";
ALTER TABLE "new_Avatar" RENAME TO "Avatar";
CREATE UNIQUE INDEX "Avatar_id_key" ON "Avatar"("id");
CREATE TABLE "new_User" (
"userId" TEXT NOT NULL PRIMARY KEY,
"identifier" TEXT NOT NULL,
"name" TEXT,
"currentAvatarId" TEXT,
CONSTRAINT "User_currentAvatarId_fkey" FOREIGN KEY ("currentAvatarId") REFERENCES "Avatar" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
INSERT INTO "new_User" ("identifier", "name", "userId") SELECT "identifier", "name", "userId" FROM "User";
DROP TABLE "User";
ALTER TABLE "new_User" RENAME TO "User";
CREATE UNIQUE INDEX "User_userId_key" ON "User"("userId");
CREATE UNIQUE INDEX "User_currentAvatarId_key" ON "User"("currentAvatarId");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View file

@ -22,13 +22,19 @@ model User {
userId String @id @unique userId String @id @unique
identifier String identifier String
name String? name String?
avatar Avatar? avatars Avatar[]
currentAvatarId String? @unique
currentAvatar Avatar? @relation("CurrentAvatar", fields: [currentAvatarId], references: [id])
} }
model Avatar { model Avatar {
id String @id @unique @default(uuid()) id String @id @unique @default(uuid())
user User @relation(fields: [userId], references: [userId]) user User @relation(fields: [userId], references: [userId])
userId String @unique userId String
usedBy User? @relation("CurrentAvatar")
altText String? altText String?
source String? source String?
} }

View file

@ -5,6 +5,7 @@ import { prisma } from "./clientsingleton"
import configuration from "./configuration" import configuration from "./configuration"
import Sharp, { type FormatEnum } from "sharp" import Sharp, { type FormatEnum } from "sharp"
import type { Avatar } from "@prisma/client" import type { Avatar } from "@prisma/client"
import { randomUUID } from "node:crypto"
// todo: make customizable // todo: make customizable
export const avatarDirectory = "./.data/avatars" export const avatarDirectory = "./.data/avatars"
@ -12,10 +13,7 @@ export const defaultAvatarDirectory = "./.data/defaultAvatar/"
await mkdir(defaultAvatarDirectory, { recursive: true }) await mkdir(defaultAvatarDirectory, { recursive: true })
export const missingAvatarQueue = new Map< export const missingAvatarQueue = new Map<string, Promise<string>>()
string,
Promise<string>
>()
/** /**
* @description Generate an avatar at the selected size and format * @description Generate an avatar at the selected size and format
@ -24,10 +22,13 @@ export const missingAvatarQueue = new Map<
* @param fmt Avatar format * @param fmt Avatar format
* @returns Promise that resolves to the path of the newly-generated avatar * @returns Promise that resolves to the path of the newly-generated avatar
*/ */
export function generateMissingAvatar(path: string, size: number, fmt?: keyof Sharp.FormatEnum) { export function generateMissingAvatar(
path: string,
size: number,
fmt?: keyof Sharp.FormatEnum
) {
let qid = JSON.stringify([path, size, fmt]) let qid = JSON.stringify([path, size, fmt])
if (missingAvatarQueue.has(qid)) if (missingAvatarQueue.has(qid)) return missingAvatarQueue.get(qid)!
return missingAvatarQueue.get(qid)!
let prom = new Promise<string>(async (res, rej) => { let prom = new Promise<string>(async (res, rej) => {
// locate best quality currently available // locate best quality currently available
@ -39,9 +40,17 @@ export function generateMissingAvatar(path: string, size: number, fmt?: keyof Sh
: join( : join(
path, path,
av av
.map(e => [parseInt(e.match(/(.*)\..*/)?.[1] || "", 10), e] as [number, string]) .map(
.sort(([a],[b]) => b - a) e =>
[0][1] [
parseInt(
e.match(/(.*)\..*/)?.[1] || "",
10
),
e,
] as [number, string]
)
.sort(([a], [b]) => b - a)[0][1]
) )
const buf = await readFile(pathToBestQualityImg) const buf = await readFile(pathToBestQualityImg)
@ -55,54 +64,86 @@ export function generateMissingAvatar(path: string, size: number, fmt?: keyof Sh
/** /**
* @description Get the path of an avatar for a user * @description Get the path of an avatar for a user
* @param uid UID of the user * @param avatarId Avatar ID
* @param size Avatar size * @param size Avatar size
* @param fmt Avatar format * @param fmt Avatar format
* @returns Path to the avatar of a user * @returns Path to the avatar of a user
*/ */
export async function getPathToAvatarForUid(uid?: string, size: number = configuration.images.default_resolution, fmt?: string) { export async function getPathToAvatar(
if (uid?.includes("/")) avatarId?: string,
throw Error("UID cannot include /") size: number = configuration.images.default_resolution,
fmt?: string
) {
if (avatarId?.includes("/")) throw Error("AvatarID cannot include /")
// check if format is valid // check if format is valid
if (![undefined, ...configuration.images.extra_output_types].includes(fmt as keyof FormatEnum)) if (
![undefined, ...configuration.images.extra_output_types].includes(
fmt as keyof FormatEnum
)
)
return return
// if no uid / no avatar folder then default to the default avatar directory // if no uid / no avatar folder then default to the default avatar directory
let userAvatarDirectory = uid ? join(avatarDirectory, uid) : defaultAvatarDirectory let avDir = avatarId
if (!existsSync(userAvatarDirectory)) ? join(avatarDirectory, avatarId)
userAvatarDirectory = defaultAvatarDirectory : defaultAvatarDirectory
if (!existsSync(avDir)) avDir = defaultAvatarDirectory
// bind a makeMissing function // bind a makeMissing function
const makeMissing = generateMissingAvatar.bind(null, userAvatarDirectory, size, fmt as keyof FormatEnum) const makeMissing = generateMissingAvatar.bind(
null,
// get directory to extract imgs from avDir,
let targetAvatarDirectory = join(userAvatarDirectory, fmt||"") size,
fmt as keyof FormatEnum
// if there's no images for the specified fmt, generate new ones
if (!existsSync(targetAvatarDirectory))
return makeMissing()
let sizes = await readdir(targetAvatarDirectory, {withFileTypes: true})
const targetAvatar = sizes.filter(e => e.isFile()).find(
s => parseInt(s.name.match(/(.*)\..*/)?.[1] || "", 10) == size
) )
if (targetAvatar) // get directory to extract imgs from
return join(targetAvatarDirectory, targetAvatar.name) let targetAvatarDirectory = join(avDir, fmt || "")
// if there's no images for the specified fmt, generate new ones
if (!existsSync(targetAvatarDirectory)) return makeMissing()
let sizes = await readdir(targetAvatarDirectory, { withFileTypes: true })
const targetAvatar = sizes
.filter(e => e.isFile())
.find(s => parseInt(s.name.match(/(.*)\..*/)?.[1] || "", 10) == size)
if (targetAvatar) return join(targetAvatarDirectory, targetAvatar.name)
else if (configuration.images.output_resolutions.includes(size)) else if (configuration.images.output_resolutions.includes(size))
return makeMissing() // generate image at this size for the specified format return makeMissing() // generate image at this size for the specified format
} }
export async function getPathToAvatarForIdentifier(identifier: string, size: number = configuration.images.default_resolution, fmt?: string) { export async function getPathToAvatarForIdentifier(
let user = await prisma.user.findFirst({ identifier: string,
size: number = configuration.images.default_resolution,
fmt?: string
) {
let avatar = await prisma.avatar.findFirst({
where: { where: {
identifier usedBy: {
} identifier,
},
},
}) })
return getPathToAvatarForUid(user?.userId, size, fmt) return getPathToAvatar(avatar?.id, size, fmt)
}
export async function getPathToAvatarForUid(
uid: string,
size: number = configuration.images.default_resolution,
fmt?: string
) {
let user = await prisma.user.findFirst({
where: {
userId: uid,
},
})
return getPathToAvatar(user?.currentAvatarId || undefined, size, fmt)
} }
function sanitizeAvatar(avatar: Avatar | null) { function sanitizeAvatar(avatar: Avatar | null) {
@ -110,33 +151,35 @@ function sanitizeAvatar(avatar: Avatar | null) {
? { ? {
altText: avatar.altText || "", altText: avatar.altText || "",
source: avatar.source || "", source: avatar.source || "",
default: false default: false,
} }
: { : {
altText: "Default profile picture", altText: "Default profile picture",
source: "https://git.sucks.win/split/ava", source: "https://git.sucks.win/split/ava",
default: true default: true,
} }
} }
export async function getMetadataForIdentifier(identifier: string) { export async function getMetadataForIdentifier(identifier: string) {
let avatar = await prisma.avatar.findFirst({ let avatar = await prisma.user
.findFirst({
where: { where: {
user: { identifier,
identifier },
}
}
}) })
.currentAvatar()
return sanitizeAvatar(avatar) return sanitizeAvatar(avatar)
} }
export async function getMetadataForUserId(userId: string) { export async function getMetadataForUserId(userId: string) {
let avatar = await prisma.avatar.findFirst({ let avatar = await prisma.user
.findFirst({
where: { where: {
userId userId,
} },
}) })
.currentAvatar()
return sanitizeAvatar(avatar) return sanitizeAvatar(avatar)
} }
@ -148,16 +191,23 @@ export async function getMetadataForUserId(userId: string) {
* @param format Image target format * @param format Image target format
* @returns Avatar buffer and other information which may be useful * @returns Avatar buffer and other information which may be useful
*/ */
export async function renderAvatar(bin: ArrayBuffer|Buffer, squareSize: number, format?: keyof Sharp.FormatEnum) { export async function renderAvatar(
bin: ArrayBuffer | Buffer,
squareSize: number,
format?: keyof Sharp.FormatEnum
) {
const opBegin = Date.now() const opBegin = Date.now()
let img = Sharp(bin); let img = Sharp(bin)
let metadata = await img.metadata(); let metadata = await img.metadata()
let realSquareSize = Math.min(...[metadata.width, metadata.height].filter(e => e) as number[], squareSize) let realSquareSize = Math.min(
...([metadata.width, metadata.height].filter(e => e) as number[]),
squareSize
)
img.resize({ img.resize({
width: realSquareSize, width: realSquareSize,
height: realSquareSize, height: realSquareSize,
fit: "cover" fit: "cover",
}) })
if (format) img.toFormat(format) if (format) img.toFormat(format)
@ -167,81 +217,84 @@ export async function renderAvatar(bin: ArrayBuffer|Buffer, squareSize: number,
extension: format || metadata.format, extension: format || metadata.format,
requestedFormat: format, requestedFormat: format,
squareSize, squareSize,
time: Date.now()-opBegin time: Date.now() - opBegin,
} }
} }
export async function writeAvatar(avatarDir: string, renderedAvatar: Awaited<ReturnType<typeof renderAvatar>>) { export async function writeAvatar(
avatarDir: string,
renderedAvatar: Awaited<ReturnType<typeof renderAvatar>>
) {
const targetDir = join( const targetDir = join(
avatarDir, avatarDir,
...( ...(renderedAvatar.requestedFormat
renderedAvatar.requestedFormat ? [renderedAvatar.requestedFormat]
? [ renderedAvatar.requestedFormat ] : [])
: []
)
) )
await mkdir(targetDir, {recursive: true}) await mkdir(targetDir, { recursive: true })
const targetPath = join( const targetPath = join(
targetDir, targetDir,
`${renderedAvatar.squareSize}.${renderedAvatar.extension}` `${renderedAvatar.squareSize}.${renderedAvatar.extension}`
) )
await writeFile( await writeFile(targetPath, renderedAvatar.img)
targetPath,
renderedAvatar.img
)
return targetPath return targetPath
} }
export async function setNewAvatar(uid: string, avatar?: File) { export async function createNewAvatar(
if (uid?.includes("/")) uid: string,
throw Error("UID cannot include /") avatar: File,
metadata: { altText?: string; source?: string } = {}
// Delete current avatar directory and avatar database entry ) {
const userAvatarDirectory = join(avatarDirectory, uid) const avatarId = randomUUID()
await rm(userAvatarDirectory, { recursive: true, force: true }) const newAvatarDirectory = join(avatarDirectory, avatarId)
await prisma.avatar.deleteMany({
where: {
userId: uid
}
})
if (!avatar) return {} // we don't need to set a new one
// make a new directory // make a new directory
mkdir(userAvatarDirectory, { recursive: true }) mkdir(newAvatarDirectory, { recursive: true })
let time: Record<number, Record<"input" | keyof Sharp.FormatEnum, number>> = {} let time: Record<
number,
Record<"input" | keyof Sharp.FormatEnum, number>
> = {}
// render all images and write to disk // render all images and write to disk
let avatarData = await avatar.arrayBuffer() let avatarData = await avatar.arrayBuffer()
for (let x of configuration.images.output_resolutions) { for (let x of configuration.images.output_resolutions) {
time[x] = Object.fromEntries([ time[x] = Object.fromEntries([
["input", -1], ["input", -1],
...configuration.images.extra_output_types ...configuration.images.extra_output_types.map(e => [e, -1]),
.map( e => [ e, -1 ] )
]) ])
for (let t of [undefined, ...configuration.images.extra_output_types]) { for (let t of [undefined, ...configuration.images.extra_output_types]) {
try { try {
const rendered = await renderAvatar(avatarData, x, t) const rendered = await renderAvatar(avatarData, x, t)
await writeAvatar(userAvatarDirectory, rendered) await writeAvatar(newAvatarDirectory, rendered)
time[x][t || "input"] = rendered.time time[x][t || "input"] = rendered.time
} catch (e) { // clear pfp and throw if error encountered } catch (e) {
await rm(userAvatarDirectory, { recursive: true, force: true }) // clear pfp and throw if error encountered
await rm(newAvatarDirectory, { recursive: true, force: true })
throw e throw e
} }
} }
} }
// create new Avatar database entry // create new Avatar database entry
await prisma.avatar.create({ await prisma.avatar
.create({
data: { data: {
userId: uid id: avatarId,
} userId: uid,
...metadata,
},
}) })
.then(() =>
prisma.user.update({
where: { userId: uid },
data: { currentAvatarId: avatarId },
})
)
return time return time
} }

View file

@ -1,6 +1,10 @@
<script lang="ts"> <script lang="ts">
export let style: string = ""; export interface Props {
export let avatarUrl: string; style?: string;
avatarUrl: string;
}
let { style = "", avatarUrl }: Props = $props();
</script> </script>
<style> <style>
div { div {

View file

@ -0,0 +1,38 @@
<script lang="ts">
import back from "@fluentui/svg-icons/icons/arrow_left_32_regular.svg?raw"
import type { Snippet } from "svelte"
let {to, children, subheading}:{to: string, children: Snippet, subheading?: Snippet} = $props()
</script>
<style>
a {
text-decoration: none;
fill: var(--link);
display: flex;
}
div {
display: flex;
align-items: center;
gap: 1em;
}
.subheading {
font-size: 0.5em;
color: var(--link);
display: block;
}
</style>
<div>
<a href={to}>
{@html back.replace("svg", "svg style=\"width: 2em; height: 2em; vertical-align:middle;\"").replace(">", "><title>Go back</title>")}
</a>
<h1>
{@render children()}
{#if subheading}
<span class="subheading">
{@render subheading()}
</span>
{/if}
</h1>
</div>

View file

@ -0,0 +1,42 @@
<script lang="ts">
import type { Snippet } from "svelte";
const { status, children }: { status: "success" | "warn" | "error", children: Snippet } = $props()
</script>
<style>
div {
border-bottom: 2px solid var(--color);
background-color: color-mix(in srgb, var(--color) 20%, var(--background) 80%);
text-align: center;
padding: 10px;
}
.error {
--color: #d20f39;
}
.warn {
--color: #df8e1d;
}
.success {
--color: #40a02b;
}
@media (prefers-color-scheme: dark) {
.error {
--color: #f38ba8;
}
.warn {
--color: #f9e2af;
}
.success {
--color: #a6e3a1;
}
}
</style>
<div class={status}>
{@render children()}
</div>

View file

@ -3,7 +3,13 @@
import "@fontsource-variable/noto-sans-mono" import "@fontsource-variable/noto-sans-mono"
import ava from "../assets/ava_icon.svg?raw" import ava from "../assets/ava_icon.svg?raw"
import type { User } from "$lib/types"; import type { User } from "$lib/types";
export let data: { user?: User }; import type { Snippet } from "svelte";
interface Props {
data: { user?: User };
children?: Snippet;
}
let { data, children }: Props = $props();
const buildName = `${__APP_NAME__} ${__APP_VERSION__}` const buildName = `${__APP_NAME__} ${__APP_VERSION__}`
</script> </script>
@ -64,20 +70,17 @@
} }
</style> </style>
</svelte:head> </svelte:head>
<body> <nav>
<nav>
<a href="/">{@html ava}</a> <a href="/">{@html ava}</a>
<a href="/set">Set avatar</a> <a href="/set">Set avatar</a>
{#if data.user} {#if data.user}
<a href="/logout">Log out</a> <a href="/logout">Log out</a>
{/if} {/if}
</nav> </nav>
<slot /> {@render children?.()}
<footer> <footer>
{import.meta.env.DEV ? "[DEV]" : ""} {import.meta.env.DEV ? "[DEV]" : ""}
{buildName} {buildName}
</footer> </footer>
</body>

View file

@ -0,0 +1,42 @@
import { getRequestUser, launchLogin } from "$lib/oidc"
import configuration from "$lib/configuration.js"
import { fail, redirect } from "@sveltejs/kit"
import {
avatarDirectory,
createNewAvatar,
getMetadataForUserId,
} from "$lib/avatars.js"
import { join } from "path"
import { prisma } from "$lib/clientsingleton"
export async function load({ request, parent, url, params: { id } }) {
const { user } = await parent()
if (!user) return launchLogin(url.toString())
return {
url: url.toString(),
avatar: await prisma.avatar.findUnique({ where: { id } }),
}
}
export const actions = {
default: async ({ request, cookies, params: { id } }) => {
let user = await getRequestUser(request, cookies)
if (!user) return fail(401, { error: "unauthenticated" })
let { altText, source } = Object.fromEntries(
(await request.formData()).entries()
)
await prisma.avatar.update({
where: {
id,
},
data: {
altText: altText instanceof File ? undefined : altText,
source: source instanceof File ? undefined : source,
},
})
return redirect(302, "/set")
},
}

View file

@ -0,0 +1,87 @@
<script lang="ts">
import StatusBanner from "$lib/components/StatusBanner.svelte";
import type { User } from "$lib/types";
import FilePreviewSet from "$lib/components/FilePreviewSet.svelte";
import ReversibleHeading from "$lib/components/ReversibleHeading.svelte"
export interface Props {
data: {
user: User,
url: string,
avatar: {
id: string,
altText?: string,
source?: string
}
};
form: { success: true, message: string } | { success: false, error: string } | undefined;
}
let { data = $bindable(), form }: Props = $props();
let files: FileList | undefined = $state();
let fileSrc = $derived(files && files.length >= 0 ? URL.createObjectURL(files.item(0)!) : "")
</script>
<style>
form {
flex-direction: column;
}
form, form > .buttons, form > .metadata {
display: flex;
gap: 10px;
}
form > .metadata {
flex-wrap: wrap;
}
form > .metadata > textarea {
height: 3em;
flex-grow: 1;
min-width: 15em;
}
form > .buttons {
justify-content: flex-end;
}
form input {
font-family: "Inter Variable", "Inter", sans-serif;
}
form input[type="submit"], form textarea {
padding: 0.5em 1em;
border-radius: 8px;
border: 1px solid var(--link);
color: var(--text);
background-color: var(--crust);
}
form input[type="submit"]{
cursor: pointer;
}
form textarea:disabled {
color: var(--link);
}
</style>
<ReversibleHeading to="/set">
Edit avatar
{#snippet subheading()}
<code>{data.avatar.id}</code>
{/snippet}
</ReversibleHeading>
<FilePreviewSet avatarUrl="{data.url}/image" style="border-radius:8px;" />
<br>
<FilePreviewSet avatarUrl="{data.url}/image" style="border-radius:100%;" />
<br>
{#if form}
{#if !form.success}
<StatusBanner status="error">{form.error}</StatusBanner>
<br>
{/if}
{/if}
<form method="post" enctype="multipart/form-data">
<div class="metadata">
<textarea name="altText" placeholder="Describe your image">{data.avatar.altText}</textarea>
<textarea name="source" placeholder="Provide a source for your image">{data.avatar.source}</textarea>
</div>
<div class="buttons">
<input type="submit" name="action" value="Save">
</div>
</form>

View file

@ -0,0 +1,29 @@
import { getPathToAvatar, getPathToAvatarForIdentifier } from "$lib/avatars.js"
import { prisma } from "$lib/clientsingleton.js"
import { getRequestUser } from "$lib/oidc.js"
import { error } from "@sveltejs/kit"
import { readFile } from "fs/promises"
import mime from "mime"
export async function GET({ params: { id, size }, url }) {
let sz = size ? parseInt(size, 10) : undefined
if (sz && Number.isNaN(sz)) throw error(400, "Invalid number")
let avPath = await getPathToAvatar(
id,
sz,
url.searchParams.get("format") || undefined
)
if (!avPath)
throw error(
404,
"Avatar at this size not found, or this is an invalid format"
)
return new Response(await readFile(avPath), {
headers: {
"Content-Type": mime.getType(avPath) || "",
},
})
}

View file

@ -1,15 +0,0 @@
import { getMetadataForIdentifier } from '$lib/avatars.js';
import { prisma } from '$lib/clientsingleton.js';
import { error, redirect } from '@sveltejs/kit';
export async function load({ params : { identifier } }) {
let userInfo = await prisma.user.findFirst({ where: { identifier } })
if (!userInfo)
throw error(404, "User not found")
let metadata = await getMetadataForIdentifier(identifier)
return {
name: userInfo.name || identifier,
identifier,
...metadata
}
}

View file

@ -1,46 +0,0 @@
<script lang="ts">
import FilePreviewSet from "../../set/FilePreviewSet.svelte";
export let data: {
identifier: string,
name: string,
altText: string,
source: string
};
</script>
<style>
h5, p {
margin: 0;
}
h5 {
font-weight: normal;
color: var(--link)
}
</style>
<h1>{data.name}'s avatar</h1>
<FilePreviewSet avatarUrl="/avatar/{data.identifier}" style="border-radius:8px;" />
<br>
<FilePreviewSet avatarUrl="/avatar/{data.identifier}" style="border-radius:100%;" />
<br>
<h5>Alt text</h5>
<p>
{#if data.altText}
{data.altText}
{:else}
<em>No alt text available</em>
{/if}
</p>
<br>
<h5>Source</h5>
<p>
{#if data.source}
{data.source}
{:else}
<em>No source available</em>
{/if}
</p>

View file

@ -0,0 +1,51 @@
import { getRequestUser, launchLogin } from "$lib/oidc"
import configuration from "$lib/configuration.js"
import { fail, redirect } from "@sveltejs/kit"
import {
avatarDirectory,
createNewAvatar,
getMetadataForUserId,
} from "$lib/avatars.js"
import { join } from "path"
import { prisma } from "$lib/clientsingleton"
export async function load({ request, parent, url }) {
const { user } = await parent()
if (!user) return launchLogin(url.toString())
return {
url: url.toString(),
allowedImageTypes: configuration.images.permitted_input,
renderSizes: configuration.images.output_resolutions,
}
}
export const actions = {
default: async ({ request, cookies }) => {
let user = await getRequestUser(request, cookies)
if (!user) return fail(401, { error: "unauthenticated" })
let { newAvatar, altText, source } = Object.fromEntries(
(await request.formData()).entries()
)
if (
newAvatar === undefined ||
!(newAvatar instanceof File) ||
newAvatar.size == 0
)
return fail(400, { error: "no file was attached" })
if (!configuration.images.permitted_input.includes(newAvatar.type))
return fail(400, {
success: false,
error: `allowed types does not include ${newAvatar.type}`,
})
let timing = await createNewAvatar(user.sub, newAvatar, {
altText: altText instanceof File ? undefined : altText,
source: source instanceof File ? undefined : source,
})
return redirect(302, "/set")
},
}

View file

@ -0,0 +1,89 @@
<script lang="ts">
import StatusBanner from "$lib/components/StatusBanner.svelte";
import type { User } from "$lib/types";
import FilePreviewSet from "$lib/components/FilePreviewSet.svelte";
import ReversibleHeading from "$lib/components/ReversibleHeading.svelte"
export interface Props {
data: {
user: User,
allowedImageTypes: string[],
renderSizes: number[]
};
form: { success: true, message: string } | { success: false, error: string } | undefined;
}
let { data = $bindable(), form }: Props = $props();
let files: FileList | undefined = $state();
let fileSrc = $derived(files && files.length >= 0 ? URL.createObjectURL(files.item(0)!) : "")
</script>
<style>
form {
flex-direction: column;
}
form, form > .buttons, form > .metadata {
display: flex;
gap: 10px;
}
form > .metadata {
flex-wrap: wrap;
}
form > .metadata > textarea {
height: 3em;
flex-grow: 1;
min-width: 15em;
}
form > .buttons {
justify-content: flex-end;
}
form input {
font-family: "Inter Variable", "Inter", sans-serif;
}
form > input[type="file"] {
flex-basis: 100%;
min-height: 1em;
}
form input[type="submit"], form input[type="file"] {
cursor: pointer;
}
form input[type="submit"], form input[type="file"], form textarea {
padding: 0.5em 1em;
border-radius: 8px;
border: 1px solid var(--link);
color: var(--text);
background-color: var(--crust);
}
form textarea:disabled {
color: var(--link);
}
form > input[type="file"]::file-selector-button {
display: none;
}
</style>
<ReversibleHeading to="/set">Add a new avatar</ReversibleHeading>
<br>
{#if form}
{#if !form.success}
<StatusBanner status="error">{form.error}</StatusBanner>
<br>
{/if}
{/if}
<form method="post" enctype="multipart/form-data">
<input type="file" bind:files={files} accept={data.allowedImageTypes.join(",")} name="newAvatar">
<div class="metadata">
<textarea name="altText" placeholder="Describe your image"></textarea>
<textarea name="source" placeholder="Provide a source for your image"></textarea>
</div>
{#if fileSrc}
<br>
<FilePreviewSet avatarUrl={fileSrc} style="border-radius:8px;" />
<br>
<FilePreviewSet avatarUrl={fileSrc} style="border-radius:100%;" />
{/if}
<div class="buttons">
<input type="submit" name="action" value="Create">
</div>
</form>

View file

@ -1,64 +1,81 @@
import {getRequestUser, launchLogin} from "$lib/oidc" import { getRequestUser, launchLogin } from "$lib/oidc"
import configuration from "$lib/configuration.js"; import configuration from "$lib/configuration.js"
import { fail } from "@sveltejs/kit"; import { fail } from "@sveltejs/kit"
import { avatarDirectory, getMetadataForUserId, setNewAvatar } from "$lib/avatars.js"; import { avatarDirectory, getMetadataForUserId } from "$lib/avatars.js"
import { join } from "path"; import { join } from "path"
import { prisma } from "$lib/clientsingleton"; import { prisma } from "$lib/clientsingleton"
export async function load({ request, parent, url }) { export async function load({ request, parent, url }) {
const { user } = await parent(); const { user } = await parent()
if (!user) if (!user) return launchLogin(url.toString())
return launchLogin(url.toString())
return { return {
url: url.toString(), url: url.toString(),
avatar: await getMetadataForUserId(user.sub), avatars: (
allowedImageTypes: configuration.images.permitted_input, await prisma.avatar.findMany({
renderSizes: configuration.images.output_resolutions where: { userId: user.sub },
include: { usedBy: true },
})
)
.reverse()
.map(e => ({ ...e, inUse: Boolean(e.usedBy) })),
} }
} }
export const actions = { export const actions = {
default: async ({request, cookies}) => { default: async ({ request, cookies }) => {
let user = await getRequestUser(request, cookies); let user = await getRequestUser(request, cookies)
if (!user) if (!user) return fail(401, { error: "unauthenticated" })
return fail(401, {error: "unauthenticated"})
let submission = Object.fromEntries((await request.formData()).entries()); let submission = Object.fromEntries(
let newAvatar = submission.newAvatar (await request.formData()).entries()
let timing: Awaited<ReturnType<typeof setNewAvatar>> = {} )
let isUploadingNewFile = submission.action == "Clear"
if ( if (typeof submission.action != "string")
!isUploadingNewFile // if action isn't already clear return fail(400, { error: "bad action" })
&& newAvatar !== undefined // and avatar is defined
&& (newAvatar instanceof File && newAvatar.size > 0)
) {
if (!configuration.images.permitted_input.includes(newAvatar.type))
return fail(400, {success: false, error: `allowed types does not include ${newAvatar.type}`})
isUploadingNewFile = true
}
if (isUploadingNewFile) if (submission.action == "Clear") {
timing = await setNewAvatar(user.sub, newAvatar as File|null || undefined) await prisma.user.update({
if (await prisma.avatar.findFirst({ where: { userId: user.sub } }))
await prisma.avatar.update({
where: { where: {
userId: user.sub userId: user.sub,
}, },
data: { data: {
altText: typeof submission.altText == "string" ? submission.altText : null, currentAvatarId: null,
source: typeof submission.source == "string" ? submission.source : null, },
}
}) })
return { return {
success: true, success: true,
message: Object.entries(timing) message: "Avatar cleared successfully",
.map(([res, time]) => `render ${res}x${res}: ${ }
Object.entries(time).map(([type, t]) => `${type}: ${t}ms`).join(", ") } else if (submission.action.startsWith("Set:")) {
}`) let avatarId = submission.action.match(/Set\:(.*)/)![1]
.join("\n")
|| "No timing information available" // make sure the avatar exists and is owned by the user
let avatar = await prisma.avatar.findUnique({
where: {
id: avatarId,
userId: user.sub,
},
})
if (!avatar)
return fail(400, {
error: "This avatar does not exist or you do not own it",
})
await prisma.user.update({
where: {
userId: user.sub,
},
data: {
currentAvatarId: avatar.id,
},
})
return {
success: true,
message: "New avatar set",
} }
} }
},
} }

View file

@ -1,131 +1,98 @@
<script lang="ts"> <script lang="ts">
import StatusBanner from "$lib/components/StatusBanner.svelte";
import type { User } from "$lib/types"; import type { User } from "$lib/types";
import FilePreviewSet from "./FilePreviewSet.svelte"; import type { Avatar } from "@prisma/client";
import editIcon from "@fluentui/svg-icons/icons/pen_16_regular.svg?raw"
export let data: { export interface Props {
data: {
user: User, user: User,
url: string, url: string,
avatar: { avatars: (Avatar & {inUse: boolean})[]
altText: string,
source: string,
default: boolean
}
allowedImageTypes: string[],
renderSizes: number[]
}; };
export let form: { success: true, message: string } | { success: false, error: string } | undefined; form: { success: true, message: string } | { success: false, error: string } | undefined;
let files: FileList; }
let fileSrc = `/avatar/${data.user.identifier}/` import FilePreviewSet from "$lib/components/FilePreviewSet.svelte";
$: if (files && files.length >= 0) { let { data = $bindable(), form }: Props = $props();
data.avatar.altText = "", data.avatar.source = "", data.avatar.default = false
fileSrc = URL.createObjectURL(files.item(0)!)
} else fileSrc = `/avatar/${data.user.identifier}/`
</script> </script>
<style> <style>
form { form {
flex-direction: column;
}
form, form > .buttons, form > .metadata {
display: flex; display: flex;
gap: 10px; justify-content: center;
} gap: 1em;
form > .metadata {
flex-wrap: wrap; flex-wrap: wrap;
} }
form > .metadata > textarea { input[type="submit"] {
height: 3em; width: 7em;
flex-grow: 1; height: 7em;
min-width: 15em; aspect-ratio: 1 / 1;
}
form > .buttons {
justify-content: flex-end;
}
form input {
font-family: "Inter Variable", "Inter", sans-serif;
}
form > input[type="file"] {
flex-basis: 100%;
min-height: 1em;
}
form input[type="submit"], form input[type="file"] {
cursor: pointer;
}
form input[type="submit"], form input[type="file"], form textarea {
padding: 0.5em 1em;
border-radius: 8px; border-radius: 8px;
border: 1px solid var(--link); border: none;
color: var(--text);
background-color: var(--crust);
}
form textarea:disabled {
color: var(--link);
}
form > input[type="file"]::file-selector-button {
display: none;
}
summary::marker {
content: ""
}
summary {
color: var(--link);
cursor:pointer; cursor:pointer;
color: transparent;
}
.editButton {
border: 1px solid var(--crust);
padding: 5px;
aspect-ratio: 1 / 1;
border-radius: 100%;
cursor: pointer;
background-color: var(--background);
fill: var(--text);
width: 16px;
height: 16px;
display:flex; /* flex fixes everything! */
opacity: 0;
transition-duration: 150ms;
position: absolute;
left: 100%;
top: 5px;
transform: translateX(calc( -100% - 5px ))
}
.editButton:hover {
border: 1px solid var(--link)
}
.idiv:hover .editButton {
opacity: 1;
}
.idiv {
position: relative
}
/* keep editbutton visible on mobile */
@media (hover: none) {
.editButton {
opacity: 1
} }
details > div {
border-left: 0.25em solid var(--link);
padding: 0 1em;
overflow-x: auto;
background-color: var(--crust);
width: calc( 100% - 2em );
} }
</style> </style>
<h1>Hi, {data.user.name}</h1> <h1>Hi, {data.user.name}</h1>
<p> <br>
<details> <FilePreviewSet avatarUrl="/user/{data.user.identifier}/avatar" style="border-radius:8px;" />
<summary>View user information...</summary> <br>
<div> <FilePreviewSet avatarUrl="/user/{data.user.identifier}/avatar" style="border-radius:100%;" />
<pre>{JSON.stringify(data.user, null, 4)}</pre> <br>
</div>
</details>
<details>
<summary>Avatar URLs...</summary>
<div>
<ul>
{#each ["", ...data.renderSizes] as variant}
<li>{new URL(`/avatar/${data.user.identifier}/${variant}`, data.url)}</li>
{/each}
</ul>
</div>
</details>
</p>
<form method="post" enctype="multipart/form-data">
<input type="file" bind:files={files} accept={data.allowedImageTypes.join(",")} name="newAvatar">
<div class="metadata">
<textarea name="altText" placeholder="Describe your image" disabled={data.avatar.default}>{data.avatar.altText}</textarea>
<textarea name="source" placeholder="Provide a source for your image" disabled={data.avatar.default}>{data.avatar.source}</textarea>
</div>
<div class="buttons">
<input type="submit" name="action" value="Save">
<input type="submit" name="action" value="Clear">
</div>
</form>
{#if form} {#if form}
{#if form.success} <StatusBanner status={form.success ? "success" : "error"}>{form.success ? form.message : form.error}</StatusBanner>
<details>
<summary><small>Avatar updated successfully</small></summary>
<div>
<pre>{form.message}</pre>
</div>
</details>
{:else}
<small>An error occurred: {form.error}</small>
{/if}
{/if} {/if}
{#key fileSrc} <h2>Your avatars</h2>
<br> <form method="post">
<FilePreviewSet avatarUrl={fileSrc} style="border-radius:8px;" /> {#each data.avatars as avatar}
<br> <div class="idiv">
<FilePreviewSet avatarUrl={fileSrc} style="border-radius:100%;" /> <input type="submit" name="action" value={"Set:"+avatar.id} aria-label={avatar.altText || "No alt text set"} style:background="center / cover no-repeat url('/avatars/{avatar.id}/image')" data-in-use={avatar.inUse} />
{/key} <a href="/avatars/{avatar.id}" aria-label="Edit" class="editButton">
{@html editIcon.replace("svg", "svg style=\"width: 16px; height: 16px;\"")}
</a>
</div>
{/each}
<input type="submit" name="action" value="Clear" aria-label="Default avatar" style:background="center / cover no-repeat url('/avatars/default/image')" />
</form>
<footer>
<a href="/new">Add new avatar</a>
&bullet;
<a href="/webhooks">Webhooks</a>
&bullet;
<a href="/user/{data.user.identifier}" target="_blank">See user page</a>
</footer>

View file

@ -0,0 +1,14 @@
import { getMetadataForIdentifier } from "$lib/avatars.js"
import { prisma } from "$lib/clientsingleton.js"
import { error, redirect } from "@sveltejs/kit"
export async function load({ params: { identifier } }) {
let userInfo = await prisma.user.findFirst({ where: { identifier } })
if (!userInfo) throw error(404, "User not found")
let metadata = await getMetadataForIdentifier(identifier)
return {
name: userInfo.name || identifier,
identifier,
...metadata,
}
}

View file

@ -0,0 +1,51 @@
<script lang="ts">
import FilePreviewSet from "../../../lib/components/FilePreviewSet.svelte";
interface Props {
data: {
identifier: string,
name: string,
altText: string,
source: string
};
}
let { data }: Props = $props();
</script>
<style>
h2, p {
margin: 0;
}
h2 {
font-weight: normal;
color: var(--link);
font-size: 0.82em;
}
</style>
<h1>{data.name}'s avatar</h1>
<FilePreviewSet avatarUrl="/user/{data.identifier}/avatar" style="border-radius:8px;" />
<br>
<FilePreviewSet avatarUrl="/user/{data.identifier}/avatar" style="border-radius:100%;" />
<br>
<h2>Alt text</h2>
<p>
{#if data.altText}
{data.altText}
{:else}
<em>No alt text available</em>
{/if}
</p>
<br>
<h2>Source</h2>
<p>
{#if data.source}
{data.source}
{:else}
<em>No source available</em>
{/if}
</p>