Compare commits
3 commits
2360c2c745
...
269db33bec
Author | SHA1 | Date | |
---|---|---|---|
May | 269db33bec | ||
May | a1294b145f | ||
May | eb5b86556d |
|
@ -1,8 +1,9 @@
|
|||
{
|
||||
"useTabs": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 100,
|
||||
"singleQuote": false,
|
||||
"trailingComma": "es5",
|
||||
"arrowParens": "avoid",
|
||||
"tabWidth": 4,
|
||||
"semi": false,
|
||||
"plugins": ["prettier-plugin-svelte"],
|
||||
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
|
||||
}
|
||||
|
|
1940
package-lock.json
generated
1940
package-lock.json
generated
File diff suppressed because it is too large
Load diff
19
package.json
19
package.json
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "ava-node",
|
||||
"version": "1.3.2",
|
||||
"name": "ava",
|
||||
"version": "2.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
|
@ -13,23 +13,24 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@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",
|
||||
"prettier": "^3.1.1",
|
||||
"prettier-plugin-svelte": "^3.1.2",
|
||||
"prettier-plugin-svelte": "^3.2.6",
|
||||
"prisma": "^5.16.2",
|
||||
"svelte": "^4.2.7",
|
||||
"svelte-check": "^3.6.0",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"tslib": "^2.4.1",
|
||||
"typescript": "^5.0.0",
|
||||
"vite": "^5.0.3"
|
||||
"typescript": "^5.5.0",
|
||||
"vite": "^5.4.4"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@fluentui/svg-icons": "^1.1.265",
|
||||
"@fontsource-variable/inter": "^5.0.18",
|
||||
"@fontsource-variable/noto-sans-mono": "^5.0.20",
|
||||
"@prisma/client": "5.16.2",
|
||||
"@sveltejs/kit": "^2.0.0",
|
||||
"@sveltejs/kit": "^2.5.27",
|
||||
"mime": "^4.0.4",
|
||||
"sharp": "^0.33.4"
|
||||
},
|
||||
|
|
|
@ -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");
|
|
@ -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;
|
|
@ -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;
|
|
@ -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");
|
|
@ -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");
|
|
@ -1,3 +0,0 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "Avatar" ADD COLUMN "altText" TEXT;
|
||||
ALTER TABLE "Avatar" ADD COLUMN "source" TEXT;
|
|
@ -1,2 +0,0 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "name" TEXT;
|
38
prisma/migrations/20241120054008_2_0init/migration.sql
Normal file
38
prisma/migrations/20241120054008_2_0init/migration.sql
Normal 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");
|
20
prisma/migrations/20241120075128_oops/migration.sql
Normal file
20
prisma/migrations/20241120075128_oops/migration.sql
Normal 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;
|
34
prisma/migrations/20241120075821_flip_relation/migration.sql
Normal file
34
prisma/migrations/20241120075821_flip_relation/migration.sql
Normal 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;
|
|
@ -2,7 +2,7 @@
|
|||
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
provider = "prisma-client-js"
|
||||
binaryTargets = ["native", "debian-openssl-1.1.x"]
|
||||
}
|
||||
|
||||
|
@ -12,23 +12,29 @@ datasource db {
|
|||
}
|
||||
|
||||
model Token {
|
||||
id String @id @unique @default(uuid())
|
||||
owner String
|
||||
token String
|
||||
id String @id @unique @default(uuid())
|
||||
owner String
|
||||
token String
|
||||
refreshToken String?
|
||||
}
|
||||
|
||||
model User {
|
||||
userId String @id @unique
|
||||
userId String @id @unique
|
||||
identifier String
|
||||
name String?
|
||||
avatar Avatar?
|
||||
name String?
|
||||
avatars Avatar[]
|
||||
|
||||
currentAvatarId String? @unique
|
||||
currentAvatar Avatar? @relation("CurrentAvatar", fields: [currentAvatarId], references: [id])
|
||||
}
|
||||
|
||||
model Avatar {
|
||||
id String @id @unique @default(uuid())
|
||||
user User @relation(fields: [userId], references: [userId])
|
||||
userId String @unique
|
||||
id String @id @unique @default(uuid())
|
||||
user User @relation(fields: [userId], references: [userId])
|
||||
userId String
|
||||
|
||||
usedBy User? @relation("CurrentAvatar")
|
||||
|
||||
altText String?
|
||||
source String?
|
||||
}
|
||||
source String?
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@ import { prisma } from "./clientsingleton"
|
|||
import configuration from "./configuration"
|
||||
import Sharp, { type FormatEnum } from "sharp"
|
||||
import type { Avatar } from "@prisma/client"
|
||||
import { randomUUID } from "node:crypto"
|
||||
|
||||
// todo: make customizable
|
||||
export const avatarDirectory = "./.data/avatars"
|
||||
|
@ -12,38 +13,46 @@ export const defaultAvatarDirectory = "./.data/defaultAvatar/"
|
|||
|
||||
await mkdir(defaultAvatarDirectory, { recursive: true })
|
||||
|
||||
export const missingAvatarQueue = new Map<
|
||||
string,
|
||||
Promise<string>
|
||||
>()
|
||||
export const missingAvatarQueue = new Map<string, Promise<string>>()
|
||||
|
||||
/**
|
||||
* @description Generate an avatar at the selected size and format
|
||||
* @description Generate an avatar at the selected size and format
|
||||
* @param path Path to the avatar directory
|
||||
* @param size Avatar size
|
||||
* @param fmt Avatar format
|
||||
* @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])
|
||||
if (missingAvatarQueue.has(qid))
|
||||
return missingAvatarQueue.get(qid)!
|
||||
if (missingAvatarQueue.has(qid)) return missingAvatarQueue.get(qid)!
|
||||
|
||||
let prom = new Promise<string>(async (res, rej) => {
|
||||
// locate best quality currently available
|
||||
const av = await readdir(path)
|
||||
// this can probably be done better but I DON'T GIVE A FUCK !!!!
|
||||
const pathToBestQualityImg =
|
||||
const pathToBestQualityImg =
|
||||
path == defaultAvatarDirectory
|
||||
? "./assets/default.png"
|
||||
: join(
|
||||
path,
|
||||
av
|
||||
.map(e => [parseInt(e.match(/(.*)\..*/)?.[1] || "", 10), e] as [number, string])
|
||||
.sort(([a],[b]) => b - a)
|
||||
[0][1]
|
||||
)
|
||||
|
||||
path,
|
||||
av
|
||||
.map(
|
||||
e =>
|
||||
[
|
||||
parseInt(
|
||||
e.match(/(.*)\..*/)?.[1] || "",
|
||||
10
|
||||
),
|
||||
e,
|
||||
] as [number, string]
|
||||
)
|
||||
.sort(([a], [b]) => b - a)[0][1]
|
||||
)
|
||||
|
||||
const buf = await readFile(pathToBestQualityImg)
|
||||
res(writeAvatar(path, await renderAvatar(buf, size, fmt)))
|
||||
missingAvatarQueue.delete(qid)
|
||||
|
@ -55,88 +64,122 @@ export function generateMissingAvatar(path: string, size: number, fmt?: keyof Sh
|
|||
|
||||
/**
|
||||
* @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 fmt Avatar format
|
||||
* @returns Path to the avatar of a user
|
||||
*/
|
||||
export async function getPathToAvatarForUid(uid?: string, size: number = configuration.images.default_resolution, fmt?: string) {
|
||||
if (uid?.includes("/"))
|
||||
throw Error("UID cannot include /")
|
||||
export async function getPathToAvatar(
|
||||
avatarId?: string,
|
||||
size: number = configuration.images.default_resolution,
|
||||
fmt?: string
|
||||
) {
|
||||
if (avatarId?.includes("/")) throw Error("AvatarID cannot include /")
|
||||
|
||||
// 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
|
||||
|
||||
// if no uid / no avatar folder then default to the default avatar directory
|
||||
let userAvatarDirectory = uid ? join(avatarDirectory, uid) : defaultAvatarDirectory
|
||||
if (!existsSync(userAvatarDirectory))
|
||||
userAvatarDirectory = defaultAvatarDirectory
|
||||
let avDir = avatarId
|
||||
? join(avatarDirectory, avatarId)
|
||||
: defaultAvatarDirectory
|
||||
|
||||
if (!existsSync(avDir)) avDir = defaultAvatarDirectory
|
||||
|
||||
// bind a makeMissing function
|
||||
const makeMissing = generateMissingAvatar.bind(null, userAvatarDirectory, size, fmt as keyof FormatEnum)
|
||||
|
||||
// get directory to extract imgs from
|
||||
let targetAvatarDirectory = join(userAvatarDirectory, 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
|
||||
const makeMissing = generateMissingAvatar.bind(
|
||||
null,
|
||||
avDir,
|
||||
size,
|
||||
fmt as keyof FormatEnum
|
||||
)
|
||||
|
||||
if (targetAvatar)
|
||||
return join(targetAvatarDirectory, targetAvatar.name)
|
||||
// get directory to extract imgs from
|
||||
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))
|
||||
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) {
|
||||
let user = await prisma.user.findFirst({
|
||||
export async function getPathToAvatarForIdentifier(
|
||||
identifier: string,
|
||||
size: number = configuration.images.default_resolution,
|
||||
fmt?: string
|
||||
) {
|
||||
let avatar = await prisma.avatar.findFirst({
|
||||
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) {
|
||||
return avatar
|
||||
? {
|
||||
altText: avatar.altText || "",
|
||||
source: avatar.source || "",
|
||||
default: false
|
||||
}
|
||||
: {
|
||||
altText: "Default profile picture",
|
||||
source: "https://git.sucks.win/split/ava",
|
||||
default: true
|
||||
}
|
||||
? {
|
||||
altText: avatar.altText || "",
|
||||
source: avatar.source || "",
|
||||
default: false,
|
||||
}
|
||||
: {
|
||||
altText: "Default profile picture",
|
||||
source: "https://git.sucks.win/split/ava",
|
||||
default: true,
|
||||
}
|
||||
}
|
||||
|
||||
export async function getMetadataForIdentifier(identifier: string) {
|
||||
let avatar = await prisma.avatar.findFirst({
|
||||
where: {
|
||||
user: {
|
||||
identifier
|
||||
}
|
||||
}
|
||||
})
|
||||
let avatar = await prisma.user
|
||||
.findFirst({
|
||||
where: {
|
||||
identifier,
|
||||
},
|
||||
})
|
||||
.currentAvatar()
|
||||
|
||||
return sanitizeAvatar(avatar)
|
||||
}
|
||||
|
||||
export async function getMetadataForUserId(userId: string) {
|
||||
let avatar = await prisma.avatar.findFirst({
|
||||
where: {
|
||||
userId
|
||||
}
|
||||
})
|
||||
let avatar = await prisma.user
|
||||
.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
})
|
||||
.currentAvatar()
|
||||
|
||||
return sanitizeAvatar(avatar)
|
||||
}
|
||||
|
@ -148,100 +191,110 @@ export async function getMetadataForUserId(userId: string) {
|
|||
* @param format Image target format
|
||||
* @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()
|
||||
let img = Sharp(bin);
|
||||
let metadata = await img.metadata();
|
||||
let realSquareSize = Math.min(...[metadata.width, metadata.height].filter(e => e) as number[], squareSize)
|
||||
let img = Sharp(bin)
|
||||
let metadata = await img.metadata()
|
||||
let realSquareSize = Math.min(
|
||||
...([metadata.width, metadata.height].filter(e => e) as number[]),
|
||||
squareSize
|
||||
)
|
||||
|
||||
img.resize({
|
||||
width: realSquareSize,
|
||||
height: realSquareSize,
|
||||
fit: "cover"
|
||||
fit: "cover",
|
||||
})
|
||||
|
||||
if (format) img.toFormat(format)
|
||||
|
||||
|
||||
return {
|
||||
img,
|
||||
extension: format || metadata.format,
|
||||
requestedFormat: format,
|
||||
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(
|
||||
avatarDir,
|
||||
...(
|
||||
renderedAvatar.requestedFormat
|
||||
? [ renderedAvatar.requestedFormat ]
|
||||
: []
|
||||
)
|
||||
)
|
||||
avatarDir,
|
||||
...(renderedAvatar.requestedFormat
|
||||
? [renderedAvatar.requestedFormat]
|
||||
: [])
|
||||
)
|
||||
|
||||
await mkdir(targetDir, {recursive: true})
|
||||
await mkdir(targetDir, { recursive: true })
|
||||
|
||||
const targetPath = join(
|
||||
targetDir,
|
||||
`${renderedAvatar.squareSize}.${renderedAvatar.extension}`
|
||||
)
|
||||
|
||||
await writeFile(
|
||||
targetPath,
|
||||
renderedAvatar.img
|
||||
)
|
||||
await writeFile(targetPath, renderedAvatar.img)
|
||||
|
||||
return targetPath
|
||||
}
|
||||
|
||||
export async function setNewAvatar(uid: string, avatar?: File) {
|
||||
if (uid?.includes("/"))
|
||||
throw Error("UID cannot include /")
|
||||
export async function createNewAvatar(
|
||||
uid: string,
|
||||
avatar: File,
|
||||
metadata: { altText?: string; source?: string } = {}
|
||||
) {
|
||||
const avatarId = randomUUID()
|
||||
const newAvatarDirectory = join(avatarDirectory, avatarId)
|
||||
|
||||
// Delete current avatar directory and avatar database entry
|
||||
const userAvatarDirectory = join(avatarDirectory, uid)
|
||||
await rm(userAvatarDirectory, { recursive: true, force: true })
|
||||
await prisma.avatar.deleteMany({
|
||||
where: {
|
||||
userId: uid
|
||||
}
|
||||
})
|
||||
|
||||
if (!avatar) return {} // we don't need to set a new one
|
||||
|
||||
// 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
|
||||
let avatarData = await avatar.arrayBuffer()
|
||||
for (let x of configuration.images.output_resolutions) {
|
||||
time[x] = Object.fromEntries([
|
||||
["input", -1],
|
||||
...configuration.images.extra_output_types
|
||||
.map( e => [ e, -1 ] )
|
||||
...configuration.images.extra_output_types.map(e => [e, -1]),
|
||||
])
|
||||
for (let t of [undefined, ...configuration.images.extra_output_types]) {
|
||||
try {
|
||||
const rendered = await renderAvatar(avatarData, x, t)
|
||||
await writeAvatar(userAvatarDirectory, rendered)
|
||||
await writeAvatar(newAvatarDirectory, rendered)
|
||||
time[x][t || "input"] = rendered.time
|
||||
} catch (e) { // clear pfp and throw if error encountered
|
||||
await rm(userAvatarDirectory, { recursive: true, force: true })
|
||||
} catch (e) {
|
||||
// clear pfp and throw if error encountered
|
||||
await rm(newAvatarDirectory, { recursive: true, force: true })
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// create new Avatar database entry
|
||||
await prisma.avatar.create({
|
||||
data: {
|
||||
userId: uid
|
||||
}
|
||||
})
|
||||
|
||||
await prisma.avatar
|
||||
.create({
|
||||
data: {
|
||||
id: avatarId,
|
||||
userId: uid,
|
||||
...metadata,
|
||||
},
|
||||
})
|
||||
.then(() =>
|
||||
prisma.user.update({
|
||||
where: { userId: uid },
|
||||
data: { currentAvatarId: avatarId },
|
||||
})
|
||||
)
|
||||
|
||||
return time
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,10 @@
|
|||
<script lang="ts">
|
||||
export let style: string = "";
|
||||
export let avatarUrl: string;
|
||||
export interface Props {
|
||||
style?: string;
|
||||
avatarUrl: string;
|
||||
}
|
||||
|
||||
let { style = "", avatarUrl }: Props = $props();
|
||||
</script>
|
||||
<style>
|
||||
div {
|
38
src/lib/components/ReversibleHeading.svelte
Normal file
38
src/lib/components/ReversibleHeading.svelte
Normal 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>
|
42
src/lib/components/StatusBanner.svelte
Normal file
42
src/lib/components/StatusBanner.svelte
Normal 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>
|
|
@ -3,7 +3,13 @@
|
|||
import "@fontsource-variable/noto-sans-mono"
|
||||
import ava from "../assets/ava_icon.svg?raw"
|
||||
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__}`
|
||||
</script>
|
||||
|
@ -64,20 +70,17 @@
|
|||
}
|
||||
</style>
|
||||
</svelte:head>
|
||||
<body>
|
||||
<nav>
|
||||
<a href="/">{@html ava}</a>
|
||||
<a href="/set">Set avatar</a>
|
||||
{#if data.user}
|
||||
<a href="/logout">Log out</a>
|
||||
{/if}
|
||||
</nav>
|
||||
|
||||
<nav>
|
||||
<a href="/">{@html ava}</a>
|
||||
<a href="/set">Set avatar</a>
|
||||
{#if data.user}
|
||||
<a href="/logout">Log out</a>
|
||||
{/if}
|
||||
</nav>
|
||||
|
||||
<slot />
|
||||
{@render children?.()}
|
||||
|
||||
<footer>
|
||||
{import.meta.env.DEV ? "[DEV]" : ""}
|
||||
{buildName}
|
||||
</footer>
|
||||
</body>
|
||||
<footer>
|
||||
{import.meta.env.DEV ? "[DEV]" : ""}
|
||||
{buildName}
|
||||
</footer>
|
42
src/routes/avatars/[id]/+page.server.ts
Normal file
42
src/routes/avatars/[id]/+page.server.ts
Normal 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")
|
||||
},
|
||||
}
|
87
src/routes/avatars/[id]/+page.svelte
Normal file
87
src/routes/avatars/[id]/+page.svelte
Normal 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>
|
29
src/routes/avatars/[id]/image/[[size]]/+server.ts
Normal file
29
src/routes/avatars/[id]/image/[[size]]/+server.ts
Normal 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) || "",
|
||||
},
|
||||
})
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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>
|
51
src/routes/new/+page.server.ts
Normal file
51
src/routes/new/+page.server.ts
Normal 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")
|
||||
},
|
||||
}
|
89
src/routes/new/+page.svelte
Normal file
89
src/routes/new/+page.svelte
Normal 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>
|
|
@ -1,64 +1,81 @@
|
|||
import {getRequestUser, launchLogin} from "$lib/oidc"
|
||||
import configuration from "$lib/configuration.js";
|
||||
import { fail } from "@sveltejs/kit";
|
||||
import { avatarDirectory, getMetadataForUserId, setNewAvatar } from "$lib/avatars.js";
|
||||
import { join } from "path";
|
||||
import { prisma } from "$lib/clientsingleton";
|
||||
import { getRequestUser, launchLogin } from "$lib/oidc"
|
||||
import configuration from "$lib/configuration.js"
|
||||
import { fail } from "@sveltejs/kit"
|
||||
import { avatarDirectory, 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())
|
||||
const { user } = await parent()
|
||||
if (!user) return launchLogin(url.toString())
|
||||
|
||||
return {
|
||||
url: url.toString(),
|
||||
avatar: await getMetadataForUserId(user.sub),
|
||||
allowedImageTypes: configuration.images.permitted_input,
|
||||
renderSizes: configuration.images.output_resolutions
|
||||
avatars: (
|
||||
await prisma.avatar.findMany({
|
||||
where: { userId: user.sub },
|
||||
include: { usedBy: true },
|
||||
})
|
||||
)
|
||||
.reverse()
|
||||
.map(e => ({ ...e, inUse: Boolean(e.usedBy) })),
|
||||
}
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
default: async ({request, cookies}) => {
|
||||
let user = await getRequestUser(request, cookies);
|
||||
if (!user)
|
||||
return fail(401, {error: "unauthenticated"})
|
||||
default: async ({ request, cookies }) => {
|
||||
let user = await getRequestUser(request, cookies)
|
||||
if (!user) return fail(401, { error: "unauthenticated" })
|
||||
|
||||
let submission = Object.fromEntries((await request.formData()).entries());
|
||||
let newAvatar = submission.newAvatar
|
||||
let timing: Awaited<ReturnType<typeof setNewAvatar>> = {}
|
||||
let isUploadingNewFile = submission.action == "Clear"
|
||||
let submission = Object.fromEntries(
|
||||
(await request.formData()).entries()
|
||||
)
|
||||
|
||||
if (
|
||||
!isUploadingNewFile // if action isn't already clear
|
||||
&& 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)
|
||||
timing = await setNewAvatar(user.sub, newAvatar as File|null || undefined)
|
||||
if (await prisma.avatar.findFirst({ where: { userId: user.sub } }))
|
||||
await prisma.avatar.update({
|
||||
if (typeof submission.action != "string")
|
||||
return fail(400, { error: "bad action" })
|
||||
|
||||
if (submission.action == "Clear") {
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
userId: user.sub
|
||||
userId: user.sub,
|
||||
},
|
||||
data: {
|
||||
altText: typeof submission.altText == "string" ? submission.altText : null,
|
||||
source: typeof submission.source == "string" ? submission.source : null,
|
||||
}
|
||||
currentAvatarId: null,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: Object.entries(timing)
|
||||
.map(([res, time]) => `render ${res}x${res}: ${
|
||||
Object.entries(time).map(([type, t]) => `${type}: ${t}ms`).join(", ")
|
||||
}`)
|
||||
.join("\n")
|
||||
|| "No timing information available"
|
||||
return {
|
||||
success: true,
|
||||
message: "Avatar cleared successfully",
|
||||
}
|
||||
} else if (submission.action.startsWith("Set:")) {
|
||||
let avatarId = submission.action.match(/Set\:(.*)/)![1]
|
||||
|
||||
// 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",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
|
|
@ -1,131 +1,98 @@
|
|||
<script lang="ts">
|
||||
import type { User } from "$lib/types";
|
||||
import FilePreviewSet from "./FilePreviewSet.svelte";
|
||||
import StatusBanner from "$lib/components/StatusBanner.svelte";
|
||||
import type { User } from "$lib/types";
|
||||
import type { Avatar } from "@prisma/client";
|
||||
import editIcon from "@fluentui/svg-icons/icons/pen_16_regular.svg?raw"
|
||||
|
||||
export let data: {
|
||||
user: User,
|
||||
url: string,
|
||||
avatar: {
|
||||
altText: string,
|
||||
source: string,
|
||||
default: boolean
|
||||
}
|
||||
allowedImageTypes: string[],
|
||||
renderSizes: number[]
|
||||
};
|
||||
export let form: { success: true, message: string } | { success: false, error: string } | undefined;
|
||||
let files: FileList;
|
||||
let fileSrc = `/avatar/${data.user.identifier}/`
|
||||
|
||||
$: if (files && files.length >= 0) {
|
||||
data.avatar.altText = "", data.avatar.source = "", data.avatar.default = false
|
||||
fileSrc = URL.createObjectURL(files.item(0)!)
|
||||
} else fileSrc = `/avatar/${data.user.identifier}/`
|
||||
export interface Props {
|
||||
data: {
|
||||
user: User,
|
||||
url: string,
|
||||
avatars: (Avatar & {inUse: boolean})[]
|
||||
};
|
||||
form: { success: true, message: string } | { success: false, error: string } | undefined;
|
||||
}
|
||||
import FilePreviewSet from "$lib/components/FilePreviewSet.svelte";
|
||||
|
||||
let { data = $bindable(), form }: Props = $props();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
form {
|
||||
flex-direction: column;
|
||||
}
|
||||
form, form > .buttons, form > .metadata {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
form > .metadata {
|
||||
justify-content: center;
|
||||
gap: 1em;
|
||||
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;
|
||||
input[type="submit"] {
|
||||
width: 7em;
|
||||
height: 7em;
|
||||
aspect-ratio: 1 / 1;
|
||||
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;
|
||||
}
|
||||
summary::marker {
|
||||
content: ""
|
||||
}
|
||||
summary {
|
||||
color: var(--link);
|
||||
border: none;
|
||||
cursor:pointer;
|
||||
color: transparent;
|
||||
}
|
||||
details > div {
|
||||
border-left: 0.25em solid var(--link);
|
||||
padding: 0 1em;
|
||||
overflow-x: auto;
|
||||
background-color: var(--crust);
|
||||
width: calc( 100% - 2em );
|
||||
.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
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<h1>Hi, {data.user.name}</h1>
|
||||
<p>
|
||||
<details>
|
||||
<summary>View user information...</summary>
|
||||
<div>
|
||||
<pre>{JSON.stringify(data.user, null, 4)}</pre>
|
||||
</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>
|
||||
<br>
|
||||
<FilePreviewSet avatarUrl="/user/{data.user.identifier}/avatar" style="border-radius:8px;" />
|
||||
<br>
|
||||
<FilePreviewSet avatarUrl="/user/{data.user.identifier}/avatar" style="border-radius:100%;" />
|
||||
<br>
|
||||
{#if form}
|
||||
{#if form.success}
|
||||
<details>
|
||||
<summary><small>Avatar updated successfully</small></summary>
|
||||
<div>
|
||||
<pre>{form.message}</pre>
|
||||
</div>
|
||||
</details>
|
||||
{:else}
|
||||
<small>An error occurred: {form.error}</small>
|
||||
{/if}
|
||||
<StatusBanner status={form.success ? "success" : "error"}>{form.success ? form.message : form.error}</StatusBanner>
|
||||
{/if}
|
||||
{#key fileSrc}
|
||||
<br>
|
||||
<FilePreviewSet avatarUrl={fileSrc} style="border-radius:8px;" />
|
||||
<br>
|
||||
<FilePreviewSet avatarUrl={fileSrc} style="border-radius:100%;" />
|
||||
{/key}
|
||||
<h2>Your avatars</h2>
|
||||
<form method="post">
|
||||
{#each data.avatars as avatar}
|
||||
<div class="idiv">
|
||||
<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} />
|
||||
<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>
|
||||
•
|
||||
<a href="/webhooks">Webhooks</a>
|
||||
•
|
||||
<a href="/user/{data.user.identifier}" target="_blank">See user page</a>
|
||||
</footer>
|
14
src/routes/user/[identifier]/+page.server.ts
Normal file
14
src/routes/user/[identifier]/+page.server.ts
Normal 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,
|
||||
}
|
||||
}
|
51
src/routes/user/[identifier]/+page.svelte
Normal file
51
src/routes/user/[identifier]/+page.svelte
Normal 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>
|
Loading…
Reference in a new issue