Merge pull request 'yap' (#1) from development into master
Reviewed-on: #1
This commit is contained in:
commit
3feafdcb8b
84 changed files with 2715 additions and 1188 deletions
|
@ -0,0 +1 @@
|
|||
DATABASE_URL=
|
41
Dockerfile
41
Dockerfile
|
@ -3,33 +3,32 @@ FROM node:lts-alpine AS base
|
|||
WORKDIR /usr/src/app
|
||||
|
||||
FROM base AS install
|
||||
RUN mkdir -p /temp/dev
|
||||
COPY package.json yarn.lock /temp/dev/
|
||||
RUN cd /temp/dev && yarn install
|
||||
|
||||
RUN mkdir dev
|
||||
COPY package.json dev/
|
||||
COPY yarn.lock dev/
|
||||
RUN cd /usr/src/app/dev && yarn install
|
||||
RUN mkdir -p /temp/prod
|
||||
COPY package.json yarn.lock /temp/prod/
|
||||
RUN cd /temp/prod && yarn install --omit=dev
|
||||
|
||||
# Not needed as of now since adapter-node
|
||||
# packs all of our dependencies for us.
|
||||
|
||||
# RUN mkdir prod
|
||||
# COPY package.json prod/
|
||||
# COPY yarn.lock prod/
|
||||
# RUN cd /usr/src/app/prod && yarn install --production
|
||||
|
||||
FROM base AS build
|
||||
|
||||
COPY --from=install /usr/src/app/dev/node_modules node_modules
|
||||
FROM base AS prisma
|
||||
COPY --from=install /temp/prod/node_modules node_modules
|
||||
COPY . .
|
||||
|
||||
RUN yarn build
|
||||
RUN npx prisma generate
|
||||
|
||||
FROM base AS app
|
||||
FROM base AS prerelease
|
||||
COPY --from=install /temp/dev/node_modules node_modules
|
||||
COPY . .
|
||||
|
||||
# COPY --from=install /usr/src/app/prod/node_modules node_modules
|
||||
COPY --from=build /usr/src/app/package.json .
|
||||
COPY --from=build /usr/src/app/build/ .
|
||||
RUN npx prisma generate
|
||||
RUN npm run build
|
||||
|
||||
FROM base AS release
|
||||
COPY --from=prisma /usr/src/app/prisma prisma
|
||||
COPY --from=prisma /usr/src/app/node_modules node_modules
|
||||
COPY --from=prerelease /usr/src/app/build/ ./
|
||||
COPY --from=prerelease /usr/src/app/package.json .
|
||||
|
||||
EXPOSE 3000/tcp
|
||||
CMD [ "node", "index.js" ]
|
||||
|
||||
|
|
|
@ -5,3 +5,11 @@
|
|||
[![forthebadge](https://forthebadge.com/images/badges/license-mit.svg)](https://forthebadge.com) [![forthebadge](https://forthebadge.com/images/badges/designed-in-ms-paint.svg)](https://forthebadge.com) [![forthebadge](https://forthebadge.com/images/badges/gluten-free.svg)](https://forthebadge.com) [![forthebadge](https://forthebadge.com/images/badges/powered-by-black-magic.svg)](https://forthebadge.com)
|
||||
|
||||
A file uploading website.
|
||||
|
||||
## Developing
|
||||
|
||||
You can start a development SQL server with Docker:
|
||||
|
||||
```sh
|
||||
sudo docker run -e MYSQL_ROOT_PASSWORD=development -e MYSQL_DATABASE=default -d mysql:latest
|
||||
```
|
||||
|
|
9
docker-compose.dev.yml
Normal file
9
docker-compose.dev.yml
Normal file
|
@ -0,0 +1,9 @@
|
|||
services:
|
||||
db:
|
||||
image: mariadb
|
||||
restart: always
|
||||
environment:
|
||||
MARIADB_DATABASE: file-uploader
|
||||
MARIADB_ROOT_PASSWORD: development
|
||||
ports:
|
||||
- '3306:3306'
|
17
docker-compose.yml
Normal file
17
docker-compose.yml
Normal file
|
@ -0,0 +1,17 @@
|
|||
services:
|
||||
mariadb:
|
||||
container_name: 'file-uploader-db'
|
||||
image: 'mariadb'
|
||||
restart: always
|
||||
environment:
|
||||
MARIADB_DATABASE: file-uploader
|
||||
MARIADB_ROOT_PASSWORD: file-uploader
|
||||
file-uploader:
|
||||
container_name: 'file-uploader'
|
||||
image: file-uploader
|
||||
build: .
|
||||
restart: always
|
||||
depends_on:
|
||||
- mariadb
|
||||
environment:
|
||||
DATABASE_URL: mysql://root:file-uploader@mariadb:3306/file-uploader
|
19
jsconfig.json
Normal file
19
jsconfig.json
Normal file
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
// Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias
|
||||
// except $lib which is handled by https://kit.svelte.dev/docs/configuration#files
|
||||
//
|
||||
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
|
||||
// from the referenced tsconfig.json - TypeScript does not merge them in
|
||||
}
|
17
package.json
17
package.json
|
@ -10,24 +10,29 @@
|
|||
"format": "prettier --write ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^3.0.0",
|
||||
"@catppuccin/tailwindcss": "^0.1.6",
|
||||
"@sveltejs/adapter-node": "^5.0.1",
|
||||
"@sveltejs/kit": "^2.0.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^3.0.0",
|
||||
"autoprefixer": "^10.4.18",
|
||||
"drizzle-kit": "^0.20.14",
|
||||
"postcss": "^8.4.35",
|
||||
"prettier": "^3.1.1",
|
||||
"prettier": "^3.2.5",
|
||||
"prettier-plugin-svelte": "^3.1.2",
|
||||
"prisma": "^5.11.0",
|
||||
"svelte": "^4.2.7",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"tslib": "^2.6.3",
|
||||
"typescript": "^5.5.3",
|
||||
"vite": "^5.0.3"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"drizzle-orm": "^0.30.2",
|
||||
"@prisma/client": "5.11.0",
|
||||
"argon2": "^0.40.1",
|
||||
"lucide-svelte": "^0.358.0",
|
||||
"mysql2": "^3.9.2",
|
||||
"svelte-sonner": "^0.3.19"
|
||||
"mime": "^4.0.4",
|
||||
"minio": "^7.1.3",
|
||||
"svelte-sonner": "^0.3.19",
|
||||
"validator": "^13.11.0"
|
||||
}
|
||||
}
|
||||
|
|
57
prisma/migrations/20240402035113_init/migration.sql
Normal file
57
prisma/migrations/20240402035113_init/migration.sql
Normal file
|
@ -0,0 +1,57 @@
|
|||
-- CreateTable
|
||||
CREATE TABLE `User` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`username` VARCHAR(191) NOT NULL,
|
||||
`email` VARCHAR(191) NOT NULL,
|
||||
`password` VARCHAR(191) NOT NULL,
|
||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`lastSeen` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
|
||||
UNIQUE INDEX `User_username_key`(`username`),
|
||||
UNIQUE INDEX `User_email_key`(`email`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `Session` (
|
||||
`id` VARCHAR(191) NOT NULL,
|
||||
`userId` INTEGER NOT NULL,
|
||||
`authorized` BOOLEAN NOT NULL,
|
||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`expiresAt` DATETIME(3) NOT NULL,
|
||||
`remoteAddress` VARCHAR(191) NULL,
|
||||
|
||||
UNIQUE INDEX `Session_id_key`(`id`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `UserSettings` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`userId` INTEGER NOT NULL,
|
||||
`newPostsPublic` BOOLEAN NOT NULL DEFAULT false,
|
||||
|
||||
UNIQUE INDEX `UserSettings_userId_key`(`userId`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `Upload` (
|
||||
`id` VARCHAR(191) NOT NULL,
|
||||
`uploaderId` INTEGER NOT NULL,
|
||||
`fileName` VARCHAR(191) NOT NULL,
|
||||
`public` BOOLEAN NOT NULL DEFAULT false,
|
||||
`uploaded` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
|
||||
UNIQUE INDEX `Upload_fileName_key`(`fileName`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `Session` ADD CONSTRAINT `Session_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `UserSettings` ADD CONSTRAINT `UserSettings_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `Upload` ADD CONSTRAINT `Upload_uploaderId_fkey` FOREIGN KEY (`uploaderId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
16
prisma/migrations/20240501010657_dope/migration.sql
Normal file
16
prisma/migrations/20240501010657_dope/migration.sql
Normal file
|
@ -0,0 +1,16 @@
|
|||
/*
|
||||
Warnings:
|
||||
|
||||
- Added the required column `embedColor` to the `UserSettings` table without a default value. This is not possible if the table is not empty.
|
||||
- Added the required column `embedDescription` to the `UserSettings` table without a default value. This is not possible if the table is not empty.
|
||||
- Added the required column `embedTitle` to the `UserSettings` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE `User` ADD COLUMN `maxUploadMB` INTEGER NOT NULL DEFAULT 100;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE `UserSettings` ADD COLUMN `embedColor` INTEGER NOT NULL,
|
||||
ADD COLUMN `embedDescription` VARCHAR(191) NOT NULL,
|
||||
ADD COLUMN `embedTitle` VARCHAR(191) NOT NULL,
|
||||
ADD COLUMN `linkToRaw` BOOLEAN NOT NULL DEFAULT false;
|
7
prisma/migrations/20240501012209_fix/migration.sql
Normal file
7
prisma/migrations/20240501012209_fix/migration.sql
Normal file
|
@ -0,0 +1,7 @@
|
|||
-- DropIndex
|
||||
DROP INDEX `Upload_fileName_key` ON `Upload`;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE `UserSettings` MODIFY `embedColor` INTEGER NOT NULL DEFAULT 0,
|
||||
MODIFY `embedDescription` VARCHAR(191) NOT NULL DEFAULT 'Uploaded by {{username}} at {{time}}',
|
||||
MODIFY `embedTitle` VARCHAR(191) NOT NULL DEFAULT '{{file}}';
|
8
prisma/migrations/20240705200803_role_key/migration.sql
Normal file
8
prisma/migrations/20240705200803_role_key/migration.sql
Normal file
|
@ -0,0 +1,8 @@
|
|||
/*
|
||||
Warnings:
|
||||
|
||||
- Added the required column `role` to the `User` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE `User` ADD COLUMN `role` ENUM('ADMINISTRATOR', 'USER', 'BANNED') NOT NULL;
|
|
@ -0,0 +1,6 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE `Upload` MODIFY `fileName` LONGTEXT NOT NULL,
|
||||
MODIFY `public` BOOLEAN NOT NULL DEFAULT true;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE `UserSettings` MODIFY `newPostsPublic` BOOLEAN NOT NULL DEFAULT true;
|
2
prisma/migrations/20240706040428_defaults/migration.sql
Normal file
2
prisma/migrations/20240706040428_defaults/migration.sql
Normal file
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE `UserSettings` MODIFY `embedColor` INTEGER NOT NULL DEFAULT 3159110;
|
|
@ -0,0 +1,8 @@
|
|||
/*
|
||||
Warnings:
|
||||
|
||||
- Added the required column `internalName` to the `Upload` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE `Upload` ADD COLUMN `internalName` LONGTEXT NOT NULL;
|
8
prisma/migrations/20240706074316_size_key/migration.sql
Normal file
8
prisma/migrations/20240706074316_size_key/migration.sql
Normal file
|
@ -0,0 +1,8 @@
|
|||
/*
|
||||
Warnings:
|
||||
|
||||
- Added the required column `size` to the `Upload` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE `Upload` ADD COLUMN `size` BIGINT NOT NULL;
|
|
@ -0,0 +1,8 @@
|
|||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to alter the column `size` on the `Upload` table. The data in that column could be lost. The data in that column will be cast from `BigInt` to `Int`.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE `Upload` MODIFY `size` INTEGER NOT NULL;
|
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal file
|
@ -0,0 +1,3 @@
|
|||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (i.e. Git)
|
||||
provider = "mysql"
|
65
prisma/schema.prisma
Normal file
65
prisma/schema.prisma
Normal file
|
@ -0,0 +1,65 @@
|
|||
datasource db {
|
||||
provider = "mysql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
username String @unique
|
||||
email String @unique
|
||||
password String
|
||||
role Role
|
||||
createdAt DateTime @default(now())
|
||||
lastSeen DateTime @default(now())
|
||||
settings UserSettings?
|
||||
// STORED AS MEGABYTES !!
|
||||
maxUploadMB Int @default(100)
|
||||
uploads Upload[]
|
||||
sessions Session[]
|
||||
}
|
||||
|
||||
model Session {
|
||||
id String @id @unique
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
userId Int
|
||||
|
||||
authorized Boolean
|
||||
createdAt DateTime @default(now())
|
||||
expiresAt DateTime
|
||||
remoteAddress String?
|
||||
}
|
||||
|
||||
model UserSettings {
|
||||
id Int @id @default(autoincrement())
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
userId Int @unique
|
||||
|
||||
newPostsPublic Boolean @default(true)
|
||||
linkToRaw Boolean @default(false)
|
||||
|
||||
embedTitle String @default("{{file}}")
|
||||
embedDescription String @default("Uploaded by {{username}} at {{time}}")
|
||||
embedColor Int @default(3159110)
|
||||
}
|
||||
|
||||
model Upload {
|
||||
id String @id
|
||||
uploader User @relation(fields: [uploaderId], references: [id])
|
||||
uploaderId Int
|
||||
|
||||
fileName String @db.LongText
|
||||
internalName String @db.LongText
|
||||
size Int
|
||||
public Boolean @default(true)
|
||||
uploaded DateTime @default(now())
|
||||
}
|
||||
|
||||
enum Role {
|
||||
ADMINISTRATOR
|
||||
USER
|
||||
BANNED
|
||||
}
|
20
src/app.css
20
src/app.css
|
@ -1,14 +1,22 @@
|
|||
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@200..1000&display=swap');
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
html {
|
||||
@apply dark:bg-neutral-950;
|
||||
@apply dark:text-neutral-200;
|
||||
@apply transition-colors;
|
||||
html,
|
||||
body {
|
||||
@apply bg-base;
|
||||
@apply text-text;
|
||||
|
||||
@apply transition-all;
|
||||
|
||||
font-family: 'Nunito', sans-serif;
|
||||
font-optical-sizing: auto;
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
strong {
|
||||
@apply dark:text-white;
|
||||
* {
|
||||
@apply transition-colors;
|
||||
}
|
||||
|
|
26
src/app.d.ts
vendored
Normal file
26
src/app.d.ts
vendored
Normal file
|
@ -0,0 +1,26 @@
|
|||
import type { Role } from '@prisma/client';
|
||||
|
||||
export interface UserSafe {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
maxUploadMB: number;
|
||||
role: Role;
|
||||
}
|
||||
|
||||
declare global {
|
||||
namespace App {
|
||||
interface Error {
|
||||
status: number;
|
||||
message: string;
|
||||
}
|
||||
interface Locals {
|
||||
user: UserSafe;
|
||||
}
|
||||
interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
|
@ -28,14 +28,14 @@
|
|||
<div class="flex space-x-2">
|
||||
<button on:click="history.back();">
|
||||
<div
|
||||
class="flex p-2 space-x-2 rounded-lg border-b-2 transition-colors border-neutral-400 hover:border-neutral-500 hover:dark:border-neutral-500 dark:border-neutral-700 bg-neutral-200 dark:bg-neutral-900"
|
||||
class="flex p-2 space-x-2 rounded-lg border-b-2 border-neutral-400 hover:border-neutral-500 hover:dark:border-neutral-500 dark:border-neutral-700 bg-neutral-200 dark:bg-neutral-900"
|
||||
>
|
||||
<p>Go Back</p>
|
||||
</div>
|
||||
</button>
|
||||
<a
|
||||
href="/"
|
||||
class="flex p-2 space-x-2 rounded-lg border-b-2 transition-colors border-neutral-400 hover:border-neutral-500 hover:dark:border-neutral-500 dark:border-neutral-700 bg-neutral-200 dark:bg-neutral-900"
|
||||
class="flex p-2 space-x-2 rounded-lg border-b-2 border-neutral-400 hover:border-neutral-500 hover:dark:border-neutral-500 dark:border-neutral-700 bg-neutral-200 dark:bg-neutral-900"
|
||||
>
|
||||
<p>Go Home</p>
|
||||
</a>
|
|
@ -1,12 +1,43 @@
|
|||
import { error, redirect } from '@sveltejs/kit';
|
||||
import { getSession } from '$lib/server/database';
|
||||
import { COOKIE } from '$lib/config';
|
||||
|
||||
const PUBLIC_RESOURCES = [
|
||||
'/',
|
||||
'/api',
|
||||
'/api/auth/register',
|
||||
'/api/auth/login',
|
||||
'/terms',
|
||||
'/privacy'
|
||||
];
|
||||
|
||||
/** @type {import('@sveltejs/kit').Handle} */
|
||||
export async function handle({ event, resolve }) {
|
||||
const { cookies, locals } = event;
|
||||
const session = await getSession(cookies.get(COOKIE) || '');
|
||||
|
||||
if (session && session.user) {
|
||||
locals.user = {
|
||||
id: session.user.id,
|
||||
username: session.user.username,
|
||||
email: session.user.email,
|
||||
maxUploadMB: session.user.maxUploadMB,
|
||||
role: session.user.role
|
||||
};
|
||||
} else {
|
||||
if (event.route.id) {
|
||||
if (event.route.id.includes('(app)')) return redirect(303, '/');
|
||||
}
|
||||
}
|
||||
|
||||
return await resolve(event);
|
||||
}
|
||||
|
||||
/** @type {import('@sveltejs/kit').HandleServerError} */
|
||||
export async function handleError({ error, event, status, message }) {
|
||||
|
||||
console.log(error)
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
console.log(error);
|
||||
|
||||
return {
|
||||
id,
|
||||
status,
|
||||
message
|
||||
};
|
||||
|
|
|
@ -1,11 +0,0 @@
|
|||
<script>
|
||||
export let click;
|
||||
</script>
|
||||
|
||||
<button type="button" on:click={click}>
|
||||
<div
|
||||
class="flex p-2 space-x-2 rounded-lg border-b-2 transition-colors border-neutral-400 hover:border-neutral-500 hover:dark:border-neutral-500 dark:border-neutral-700 bg-neutral-200 dark:bg-neutral-900"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</button>
|
33
src/lib/components/Dropdown.svelte
Normal file
33
src/lib/components/Dropdown.svelte
Normal file
|
@ -0,0 +1,33 @@
|
|||
<script>
|
||||
import { slide } from 'svelte/transition';
|
||||
import { Menu } from 'lucide-svelte';
|
||||
|
||||
let visible = false;
|
||||
|
||||
function toggle() {
|
||||
visible = !visible;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:body
|
||||
on:mousedown={() => {
|
||||
if (visible)
|
||||
setTimeout(() => {
|
||||
visible = false;
|
||||
}, 150);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div class="flex relative w-fit">
|
||||
<button class="my-auto w-min hover:text-overlay2 {visible && 'text-overlay2'}" on:click={toggle}>
|
||||
<Menu size="20"></Menu>
|
||||
</button>
|
||||
{#if visible}
|
||||
<div
|
||||
transition:slide
|
||||
class="w-[12rem] z-10 h-fit translate-y-12 py-2 bg-crust rounded-lg origin-top right-0 absolute transition-all shadow-md translate-x-3.5"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
7
src/lib/components/DropdownButton.svelte
Normal file
7
src/lib/components/DropdownButton.svelte
Normal file
|
@ -0,0 +1,7 @@
|
|||
<script>
|
||||
export let href = '';
|
||||
</script>
|
||||
|
||||
<a {href} class="flex px-3 py-1.5 space-x-1 transition-all hover:bg-overlay0">
|
||||
<slot />
|
||||
</a>
|
73
src/lib/components/File.svelte
Normal file
73
src/lib/components/File.svelte
Normal file
|
@ -0,0 +1,73 @@
|
|||
<script lang="ts">
|
||||
import { bytesToHumanReadable } from '$lib/index';
|
||||
import { fileProgress, user } from '$lib/stores';
|
||||
import { CircleAlert, X } from 'lucide-svelte';
|
||||
import { get } from 'svelte/store';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
export let file: File;
|
||||
export let i: number;
|
||||
export let remove: Function;
|
||||
export let running = false;
|
||||
|
||||
let percent = 0,
|
||||
url = '',
|
||||
error = false;
|
||||
|
||||
fileProgress.subscribe((_) => {
|
||||
let fileProgress = _[file.name];
|
||||
if (!fileProgress) return;
|
||||
|
||||
if (fileProgress.percent) percent = fileProgress.percent;
|
||||
if (fileProgress.url) url = fileProgress.url;
|
||||
if (fileProgress.error) error = fileProgress.error;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="rounded-md transition-all bg-mantle">
|
||||
<div
|
||||
in:fade|global={{ delay: 100 * i }}
|
||||
class="flex place-content-between px-1.5 w-full h-14 rounded-md transition-all
|
||||
{url ? 'bg-blue/30' : ''} {error ? 'bg-red/30' : ''}"
|
||||
style={error || url
|
||||
? ''
|
||||
: `background: linear-gradient(90deg, rgb(var(--ctp-surface0)) ${percent}%, transparent ${percent}%);`}
|
||||
>
|
||||
<div class="flex overflow-x-scroll flex-col my-auto overflow-y-clip">
|
||||
{#if url}
|
||||
<a
|
||||
href={url}
|
||||
class="font-bold overflow-ellipsis whitespace-nowrap text-blue overflow-x-clip"
|
||||
>{file.name}</a
|
||||
>
|
||||
{:else}
|
||||
<p class="overflow-ellipsis whitespace-nowrap overflow-x-clip">{file.name}</p>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-0.5">
|
||||
{#if file.size > get(user).maxUploadMB * 1048576}
|
||||
<p class="font-bold text-red">
|
||||
<CircleAlert class="w-4 h-4"></CircleAlert>
|
||||
</p>
|
||||
{/if}
|
||||
<p
|
||||
class="text-xs my-auto {file.size > get(user).maxUploadMB * 1048576
|
||||
? 'text-red font-bold'
|
||||
: 'text-overlay1'}"
|
||||
>
|
||||
{bytesToHumanReadable(file.size)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{#if !running}
|
||||
<button
|
||||
class="hover:text-red-500"
|
||||
on:click={() => {
|
||||
remove(file.name);
|
||||
}}
|
||||
>
|
||||
<X size="20"></X></button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
3
src/lib/components/Footer.svelte
Normal file
3
src/lib/components/Footer.svelte
Normal file
|
@ -0,0 +1,3 @@
|
|||
<div
|
||||
class="flex place-content-between px-4 w-full h-full rounded-xl shadow-md py-auto bg-crust"
|
||||
></div>
|
|
@ -1,16 +0,0 @@
|
|||
<script>
|
||||
export let type, name, id, placeholder;
|
||||
</script>
|
||||
|
||||
<div class="flex p-2 space-x-1 rounded-lg transition-colors bg-neutral-200 dark:bg-neutral-900">
|
||||
<div class="py-0.5 pr-1 border-r-2 transition-colors border-neutral-400 dark:border-neutral-700">
|
||||
<slot />
|
||||
</div>
|
||||
<input
|
||||
class="py-0.5 transition-colors bg-neutral-200 dark:bg-neutral-900"
|
||||
{type}
|
||||
{name}
|
||||
{id}
|
||||
{placeholder}
|
||||
/>
|
||||
</div>
|
90
src/lib/components/Forms/LoginForm.svelte
Normal file
90
src/lib/components/Forms/LoginForm.svelte
Normal file
|
@ -0,0 +1,90 @@
|
|||
<script>
|
||||
import { fade } from 'svelte/transition';
|
||||
import { Mail, SquareAsterisk, LogIn, Undo } from 'lucide-svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
import Logo from '$lib/components/Logo.svelte';
|
||||
import FormInput from '$lib/components/Inputs/FormInput.svelte';
|
||||
import Button from '$lib/components/Inputs/Button.svelte';
|
||||
import ButtonText from '$lib/components/Inputs/ButtonText.svelte';
|
||||
import ButtonIcon from '$lib/components/Inputs/ButtonIcon.svelte';
|
||||
|
||||
export let callback = () => {};
|
||||
|
||||
let disabled = false;
|
||||
let email = '',
|
||||
password = '';
|
||||
|
||||
async function login() {
|
||||
disabled = true;
|
||||
|
||||
const id = toast.loading('Logging in...');
|
||||
|
||||
if (!email) {
|
||||
toast.error('Missing email.', { id });
|
||||
return (disabled = false);
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
toast.error('Missing password.', { id });
|
||||
return (disabled = false);
|
||||
}
|
||||
|
||||
const response = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, password })
|
||||
}).catch((_) => toast.error(_.message));
|
||||
const body = await response.json().catch((_) => toast.error(_.message));
|
||||
|
||||
if (!body?.success) {
|
||||
toast.error(body?.error || 'Unexpected Error', { id });
|
||||
return (disabled = false);
|
||||
}
|
||||
|
||||
toast.success('Welcome, ' + body.data.username, { id });
|
||||
goto('/dashboard');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex justify-center items-center h-full">
|
||||
<div class="flex flex-col space-y-2">
|
||||
<div class=" fill-text">
|
||||
<Logo />
|
||||
</div>
|
||||
<form on:submit|preventDefault>
|
||||
<div class="flex flex-col space-y-2">
|
||||
<FormInput
|
||||
type={'email'}
|
||||
name={'email'}
|
||||
id={'email'}
|
||||
placeholder={'user@example.com'}
|
||||
bind:value={email}
|
||||
required={true}
|
||||
>
|
||||
<Mail />
|
||||
</FormInput>
|
||||
<FormInput
|
||||
type={'password'}
|
||||
name={'password'}
|
||||
id={'password'}
|
||||
placeholder={'•'.repeat(16)}
|
||||
bind:value={password}
|
||||
required={true}
|
||||
>
|
||||
<SquareAsterisk />
|
||||
</FormInput>
|
||||
<div class="flex place-content-between">
|
||||
<Button click={callback} {disabled}>
|
||||
<ButtonIcon><Undo /></ButtonIcon>
|
||||
<ButtonText><p>Go Back</p></ButtonText>
|
||||
</Button>
|
||||
<Button click={login} {disabled} pulse={disabled}>
|
||||
<ButtonIcon><LogIn /></ButtonIcon>
|
||||
<ButtonText><p>Login</p></ButtonText>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
126
src/lib/components/Forms/RegisterForm.svelte
Normal file
126
src/lib/components/Forms/RegisterForm.svelte
Normal file
|
@ -0,0 +1,126 @@
|
|||
<script>
|
||||
import { Mail, SquareAsterisk, Undo, User, UserPlus } from 'lucide-svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
import Logo from '$lib/components/Logo.svelte';
|
||||
import FormInput from '$lib/components/Inputs/FormInput.svelte';
|
||||
import Button from '$lib/components/Inputs/Button.svelte';
|
||||
import ButtonText from '$lib/components/Inputs/ButtonText.svelte';
|
||||
import ButtonIcon from '$lib/components/Inputs/ButtonIcon.svelte';
|
||||
|
||||
export let callback = () => {};
|
||||
|
||||
let disabled = false;
|
||||
let username = '',
|
||||
email = '',
|
||||
password = '',
|
||||
cpassword = '';
|
||||
|
||||
async function register() {
|
||||
disabled = true;
|
||||
|
||||
const id = toast.loading('Registering...');
|
||||
|
||||
if (!username) {
|
||||
toast.error('Missing username.', { id });
|
||||
return (disabled = false);
|
||||
}
|
||||
|
||||
if (!email) {
|
||||
toast.error('Missing email.', { id });
|
||||
return (disabled = false);
|
||||
}
|
||||
|
||||
if (!password || !cpassword) {
|
||||
toast.error('Missing password.', { id });
|
||||
return (disabled = false);
|
||||
}
|
||||
|
||||
if (password !== cpassword) {
|
||||
toast.error('Your passwords do not match.', { id });
|
||||
return (disabled = false);
|
||||
}
|
||||
|
||||
const response = await fetch('/api/auth/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, email, password })
|
||||
}).catch((_) => toast.error(_.message));
|
||||
const body = await response.json().catch((_) => toast.error(_.message));
|
||||
|
||||
if (!body?.success) {
|
||||
toast.error(body?.error || 'Unexpected Error', { id });
|
||||
return (disabled = false);
|
||||
}
|
||||
|
||||
toast.success('Welcome, ' + body.data.username, { id });
|
||||
goto('/dashboard');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex justify-center items-center h-full">
|
||||
<div class="flex flex-col space-y-2">
|
||||
<div class=" fill-text">
|
||||
<Logo />
|
||||
</div>
|
||||
<form on:submit|preventDefault>
|
||||
<div class="flex flex-col space-y-2">
|
||||
<FormInput
|
||||
type={'username'}
|
||||
name={'username'}
|
||||
id={'username'}
|
||||
placeholder={'Username'}
|
||||
bind:value={username}
|
||||
required={true}
|
||||
>
|
||||
<User />
|
||||
</FormInput>
|
||||
<FormInput
|
||||
type={'email'}
|
||||
name={'email'}
|
||||
id={'email'}
|
||||
placeholder={'user@example.com'}
|
||||
bind:value={email}
|
||||
required={true}
|
||||
>
|
||||
<Mail />
|
||||
</FormInput>
|
||||
<FormInput
|
||||
type={'password'}
|
||||
name={'password'}
|
||||
id={'password'}
|
||||
placeholder={'•'.repeat(16)}
|
||||
bind:value={password}
|
||||
required={true}
|
||||
>
|
||||
<SquareAsterisk />
|
||||
</FormInput>
|
||||
<FormInput
|
||||
type={'password'}
|
||||
name={'cpassword'}
|
||||
id={'cpassword'}
|
||||
placeholder={'•'.repeat(16)}
|
||||
bind:value={cpassword}
|
||||
required={true}
|
||||
>
|
||||
<SquareAsterisk />
|
||||
</FormInput>
|
||||
<div class="flex place-content-between">
|
||||
<Button click={callback} {disabled}>
|
||||
<ButtonIcon><Undo /></ButtonIcon>
|
||||
<ButtonText>Go Back</ButtonText>
|
||||
</Button>
|
||||
<Button click={register} {disabled} pulse={disabled}>
|
||||
<ButtonIcon><UserPlus /></ButtonIcon>
|
||||
<ButtonText>Register</ButtonText>
|
||||
</Button>
|
||||
</div>
|
||||
<p class="text-xs text-center text-overlay1">
|
||||
By registering an account you agree to the <br />
|
||||
<a class="underline" href="/terms" data-sveltekit-reload>Terms of Service</a> and
|
||||
<a class="underline" href="/privacy" data-sveltekit-reload>Privacy Policy</a>.
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
55
src/lib/components/Header.svelte
Normal file
55
src/lib/components/Header.svelte
Normal file
|
@ -0,0 +1,55 @@
|
|||
<script>
|
||||
import { Sun, Moon } from 'lucide-svelte';
|
||||
|
||||
import { user, darkMode } from '$lib/stores';
|
||||
|
||||
import HeaderLink from '$lib/components/HeaderLink.svelte';
|
||||
import Dropdown from '$lib/components/Dropdown.svelte';
|
||||
import DropdownButton from '$lib/components/DropdownButton.svelte';
|
||||
import Logo from '$lib/components/Logo.svelte';
|
||||
</script>
|
||||
|
||||
<div class="flex place-content-between px-4 w-full h-full rounded-xl shadow-md py-auto bg-crust">
|
||||
<div class="flex my-auto md:space-x-6">
|
||||
<a
|
||||
href="/dashboard"
|
||||
class="hidden flex-none my-auto w-20 text-xl transition-all md:block fill-text hover:scale-105 focus:scale-105 active:scale-95"
|
||||
>
|
||||
<Logo></Logo>
|
||||
</a>
|
||||
<div class="flex my-auto space-x-3">
|
||||
<HeaderLink href="/dashboard">Dashboard</HeaderLink>
|
||||
<HeaderLink href="/uploads">Uploads</HeaderLink>
|
||||
<HeaderLink href="/links">Links</HeaderLink>
|
||||
{#if $user?.role === 'ADMINISTRATOR'}
|
||||
<HeaderLink href="/admin">Admin</HeaderLink>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex my-auto space-x-2">
|
||||
<p class="font-bold">{$user?.username}</p>
|
||||
<Dropdown>
|
||||
<div>
|
||||
<button
|
||||
class="flex px-3 py-1.5 space-x-1.5 w-full transition-all hover:bg-overlay0"
|
||||
on:click={() => {
|
||||
$darkMode = !$darkMode;
|
||||
}}
|
||||
>
|
||||
{#if $darkMode}
|
||||
<Sun class="my-auto w-5 h-5" />
|
||||
{:else}
|
||||
<Moon class="my-auto w-5 h-5" />
|
||||
{/if}
|
||||
<p>Theme</p>
|
||||
</button>
|
||||
</div>
|
||||
<DropdownButton href="/settings">
|
||||
<p class="my-auto">Settings</p>
|
||||
</DropdownButton>
|
||||
<DropdownButton href="/api/auth/logout">
|
||||
<p class="my-auto text-red-500">Logout</p>
|
||||
</DropdownButton>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
19
src/lib/components/HeaderLink.svelte
Normal file
19
src/lib/components/HeaderLink.svelte
Normal file
|
@ -0,0 +1,19 @@
|
|||
<script>
|
||||
import { page } from '$app/stores';
|
||||
|
||||
export let href;
|
||||
let selected = false;
|
||||
|
||||
page.subscribe((pg) => {
|
||||
selected = pg.url.pathname === href;
|
||||
});
|
||||
</script>
|
||||
|
||||
<a
|
||||
{href}
|
||||
class="my-auto md:text-lg transition-all {selected
|
||||
? 'font-bold tracking-wider'
|
||||
: 'hover:text-overlay2 focus:text-overlay2'}"
|
||||
>
|
||||
<slot />
|
||||
</a>
|
18
src/lib/components/Inputs/Button.svelte
Normal file
18
src/lib/components/Inputs/Button.svelte
Normal file
|
@ -0,0 +1,18 @@
|
|||
<script>
|
||||
export let click,
|
||||
disabled = false,
|
||||
pulse = false;
|
||||
</script>
|
||||
|
||||
<button
|
||||
class="group/button {pulse ? 'animate-pulse cursor-wait' : ''} "
|
||||
type="button"
|
||||
on:click={click}
|
||||
{disabled}
|
||||
>
|
||||
<div
|
||||
class="flex p-2 space-x-2 rounded-lg border-b-2 hadow-md bg-crust border-overlay2 hover:border-overlay0 group-disabled/button:border-overlay0 group-disabled/button:hover:border-overlay0"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</button>
|
3
src/lib/components/Inputs/ButtonIcon.svelte
Normal file
3
src/lib/components/Inputs/ButtonIcon.svelte
Normal file
|
@ -0,0 +1,3 @@
|
|||
<div class="group-disabled/button:text-overlay1">
|
||||
<slot />
|
||||
</div>
|
3
src/lib/components/Inputs/ButtonText.svelte
Normal file
3
src/lib/components/Inputs/ButtonText.svelte
Normal file
|
@ -0,0 +1,3 @@
|
|||
<p class="group-disabled/button:text-overlay1">
|
||||
<slot />
|
||||
</p>
|
72
src/lib/components/Inputs/FormInput.svelte
Normal file
72
src/lib/components/Inputs/FormInput.svelte
Normal file
|
@ -0,0 +1,72 @@
|
|||
<script>
|
||||
import { CircleAlert, Check } from 'lucide-svelte';
|
||||
|
||||
export let type, name, id, placeholder, value, required;
|
||||
</script>
|
||||
|
||||
<!-- insane that i have to do this because -->
|
||||
<!-- 'type' attribute cannot be dynamic if input uses two-way binding -->
|
||||
{#if type === 'username'}
|
||||
<div class="flex p-2 space-x-1 rounded-lg shadow-md bg-crust">
|
||||
<div class="py-0.5 pr-1 border-r-2 border-overlay2">
|
||||
<slot />
|
||||
</div>
|
||||
<input
|
||||
class="py-0.5 bg-crust peer placeholder:text-overlay1"
|
||||
type="username"
|
||||
{name}
|
||||
{id}
|
||||
{placeholder}
|
||||
{required}
|
||||
bind:value
|
||||
/>
|
||||
<div class="hidden my-auto peer-invalid:flex">
|
||||
<CircleAlert />
|
||||
</div>
|
||||
<div class="my-auto peer-invalid:hidden">
|
||||
<Check />
|
||||
</div>
|
||||
</div>
|
||||
{:else if type === 'email'}
|
||||
<div class="flex p-2 space-x-1 rounded-lg shadow-md bg-crust">
|
||||
<div class="py-0.5 pr-1 border-r-2 border-overlay2">
|
||||
<slot />
|
||||
</div>
|
||||
<input
|
||||
class="py-0.5 bg-crust peer placeholder:text-overlay1"
|
||||
type="email"
|
||||
{name}
|
||||
{id}
|
||||
{placeholder}
|
||||
{required}
|
||||
bind:value
|
||||
/>
|
||||
<div class="hidden my-auto peer-invalid:flex">
|
||||
<CircleAlert />
|
||||
</div>
|
||||
<div class="my-auto peer-invalid:hidden">
|
||||
<Check />
|
||||
</div>
|
||||
</div>
|
||||
{:else if type === 'password'}
|
||||
<div class="flex p-2 space-x-1 rounded-lg shadow-md bg-crust">
|
||||
<div class="py-0.5 pr-1 border-r-2 border-overlay2">
|
||||
<slot />
|
||||
</div>
|
||||
<input
|
||||
class="py-0.5 bg-crust peer placeholder:text-overlay1"
|
||||
type="password"
|
||||
{name}
|
||||
{id}
|
||||
{placeholder}
|
||||
{required}
|
||||
bind:value
|
||||
/>
|
||||
<div class="hidden my-auto peer-invalid:flex">
|
||||
<CircleAlert />
|
||||
</div>
|
||||
<div class="my-auto peer-invalid:hidden">
|
||||
<Check />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
17
src/lib/components/Inputs/Link.svelte
Normal file
17
src/lib/components/Inputs/Link.svelte
Normal file
|
@ -0,0 +1,17 @@
|
|||
<script>
|
||||
export let href,
|
||||
style = 'link';
|
||||
</script>
|
||||
|
||||
{#if style === 'link'}
|
||||
<a {href} class="text-blue-500">
|
||||
<slot />
|
||||
</a>
|
||||
{:else if style === 'button'}
|
||||
<a
|
||||
{href}
|
||||
class="flex p-2 space-x-2 rounded-lg border-b-2 hadow-md bg-crust border-overlay2 hover:border-overlay0 group-disabled/button:border-overlay0 group-disabled/button:hover:border-overlay0"
|
||||
>
|
||||
<slot />
|
||||
</a>
|
||||
{/if}
|
|
@ -1,17 +0,0 @@
|
|||
<script>
|
||||
export let href,
|
||||
style = 'link';
|
||||
</script>
|
||||
|
||||
{#if style === 'link'}
|
||||
<a {href} class="text-blue-500">
|
||||
<slot />
|
||||
</a>
|
||||
{:else if style === 'button'}
|
||||
<a
|
||||
{href}
|
||||
class="flex p-2 space-x-2 rounded-lg border-b-2 transition-colors border-neutral-400 hover:border-neutral-500 hover:dark:border-neutral-500 dark:border-neutral-700 bg-neutral-200 dark:bg-neutral-900"
|
||||
>
|
||||
<slot />
|
||||
</a>
|
||||
{/if}
|
|
@ -2,6 +2,7 @@
|
|||
viewBox="0 0 1200 628"
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
class=" fill-inherit"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
|
|
Before Width: | Height: | Size: 291 KiB After Width: | Height: | Size: 291 KiB |
|
@ -1,5 +1,6 @@
|
|||
<script>
|
||||
import { browser } from '$app/environment';
|
||||
import { get } from 'svelte/store';
|
||||
import { darkMode } from '../stores';
|
||||
|
||||
if (browser) {
|
||||
|
@ -8,16 +9,20 @@
|
|||
$darkMode = localStorage.getItem('darkMode') === 'true';
|
||||
|
||||
darkMode.subscribe(() => {
|
||||
console.log(`[ThemeHandler.svelte] Current theme is ${$darkMode ? 'dark' : 'light'} mode`);
|
||||
console.log(
|
||||
`[ThemeHandler.svelte] Current theme is ${get(darkMode) ? 'dark' : 'light'} mode`
|
||||
);
|
||||
|
||||
localStorage.setItem('darkMode', $darkMode);
|
||||
localStorage.setItem('darkMode', get(darkMode).toString());
|
||||
|
||||
if ($darkMode) {
|
||||
console.log('[ThemeHandler.svelte] Setting dark mode from store');
|
||||
html.classList.add('dark');
|
||||
html.classList.add('frappe');
|
||||
} else {
|
||||
console.log('[ThemeHandler.svelte] Setting light mode from store');
|
||||
html.classList.remove('dark');
|
||||
html.classList.remove('frappe');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@ -31,9 +36,11 @@
|
|||
if (darkMode) {
|
||||
console.log('[ThemeHandler.svelte] Setting dark mode from localStorage');
|
||||
html.classList.add('dark');
|
||||
html.classList.add('frappe');
|
||||
} else {
|
||||
console.log('[ThemeHandler.svelte] Setting light mode from localStorage');
|
||||
html.classList.remove('dark');
|
||||
html.classList.remove('frappe');
|
||||
}
|
||||
</script>
|
||||
</svelte:head>
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<script>
|
||||
import { Sun, Moon } from 'lucide-svelte';
|
||||
import { darkMode } from '../stores';
|
||||
import Button from './Button.svelte';
|
||||
import Button from '$lib/components/Inputs/Button.svelte';
|
||||
|
||||
function toggleTheme() {
|
||||
$darkMode = !$darkMode;
|
||||
|
|
10
src/lib/config.js
Normal file
10
src/lib/config.js
Normal file
|
@ -0,0 +1,10 @@
|
|||
export const COOKIE = '.FILE-UPLOADER-SESSION';
|
||||
|
||||
export const MAIL_WHITELIST = [
|
||||
'gmail.com',
|
||||
'outlook.com',
|
||||
'madhouselabs.net',
|
||||
'dfuser.xyz',
|
||||
'liloandstit.ch',
|
||||
'vea.st'
|
||||
];
|
|
@ -1,7 +0,0 @@
|
|||
import { browser } from '$app/environment';
|
||||
|
||||
export function goBack() {
|
||||
if (browser) {
|
||||
history.back();
|
||||
}
|
||||
}
|
43
src/lib/index.ts
Normal file
43
src/lib/index.ts
Normal file
|
@ -0,0 +1,43 @@
|
|||
import { browser } from '$app/environment';
|
||||
|
||||
export function goBack() {
|
||||
if (browser) {
|
||||
history.back();
|
||||
}
|
||||
}
|
||||
|
||||
export function bytesToHumanReadable(bytes: number) {
|
||||
let i = bytes == 0 ? 0 : Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
return (
|
||||
+(bytes / Math.pow(1024, i)).toFixed(2) * 1 + ' ' + ['Bytes', 'KiB', 'MiB', 'GiB', 'TiB'][i]
|
||||
);
|
||||
}
|
||||
|
||||
export function request(
|
||||
data: FormData,
|
||||
progress: Function
|
||||
): Promise<{ success: boolean; body: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
|
||||
xhr.addEventListener('loadend', () => {
|
||||
resolve({
|
||||
success: xhr.readyState === 4 && xhr.status === 200,
|
||||
body: xhr.responseText
|
||||
});
|
||||
});
|
||||
|
||||
xhr.addEventListener('error', (event) => {
|
||||
reject(event);
|
||||
});
|
||||
|
||||
xhr.upload.addEventListener('progress', (event) => {
|
||||
if (event.lengthComputable) {
|
||||
progress(Math.floor((event.loaded / event.total) * 100));
|
||||
}
|
||||
});
|
||||
|
||||
xhr.open('POST', '/api/upload', true);
|
||||
xhr.send(data);
|
||||
});
|
||||
}
|
27
src/lib/server/auth.ts
Normal file
27
src/lib/server/auth.ts
Normal file
|
@ -0,0 +1,27 @@
|
|||
import { COOKIE } from '$lib/config';
|
||||
import type { Cookies } from '@sveltejs/kit';
|
||||
import { getSession } from './database';
|
||||
import type { User, UserSettings } from '@prisma/client';
|
||||
|
||||
interface UserAndMaybeSettings extends User {
|
||||
settings: UserSettings | null;
|
||||
}
|
||||
|
||||
export async function authenticate(request: Request, cookies: Cookies) {
|
||||
const bearer = request.headers.get('Authorization')?.replace('Bearer ', '');
|
||||
const cookie = cookies.get(COOKIE);
|
||||
|
||||
let user: UserAndMaybeSettings | false = false;
|
||||
|
||||
if (bearer && !cookie) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (cookie && !bearer) {
|
||||
const session = await getSession(cookie);
|
||||
if (!session) return false;
|
||||
user = session.user;
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
23
src/lib/server/crypto.ts
Normal file
23
src/lib/server/crypto.ts
Normal file
|
@ -0,0 +1,23 @@
|
|||
import { hash, verify } from 'argon2';
|
||||
|
||||
export const letterIdCharacters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'.split('');
|
||||
export const catIdCharacters = ['🐱', '😻', '😿', '😹', '😽', '😾', '🙀', '😸', '😺', '😼', '🐈'];
|
||||
|
||||
export async function createHash(input: string) {
|
||||
return await hash(input);
|
||||
}
|
||||
|
||||
export async function verifyHash(hash: string, input: string) {
|
||||
return await verify(hash, input);
|
||||
}
|
||||
|
||||
export function generateId(characters: String[] = letterIdCharacters, length: number) {
|
||||
length = Math.max(length, 6);
|
||||
let id = '';
|
||||
|
||||
for (let i = 0; length > i; i++) {
|
||||
id += characters[Math.floor(Math.random() * characters.length)];
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
140
src/lib/server/database.ts
Normal file
140
src/lib/server/database.ts
Normal file
|
@ -0,0 +1,140 @@
|
|||
import { PrismaClient } from '@prisma/client';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { createHash } from './crypto';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
export default prisma;
|
||||
|
||||
interface FindUserQuery {
|
||||
username?: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
export async function createUser(username: string, email: string, password: string) {
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username,
|
||||
email,
|
||||
password: await createHash(password),
|
||||
role: 'USER'
|
||||
}
|
||||
});
|
||||
|
||||
await prisma.userSettings.create({
|
||||
data: {
|
||||
userId: user.id
|
||||
}
|
||||
});
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function createSession(userId: number, needsMfa = false) {
|
||||
const session = await prisma.session.create({
|
||||
data: {
|
||||
id: randomBytes(64).toString('base64'),
|
||||
userId,
|
||||
authorized: !needsMfa,
|
||||
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 12)
|
||||
}
|
||||
});
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function findUser({ email, username }: FindUserQuery) {
|
||||
if (!email && !username) return false;
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
email,
|
||||
username
|
||||
}
|
||||
});
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function getSession(id: string) {
|
||||
if (!id) return false;
|
||||
|
||||
const session = await prisma.session.findFirst({
|
||||
where: { id },
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
password: true,
|
||||
role: true,
|
||||
createdAt: true,
|
||||
lastSeen: true,
|
||||
maxUploadMB: true,
|
||||
settings: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function deleteSession(id: string) {
|
||||
if (!id) return false;
|
||||
|
||||
return await prisma.session.delete({
|
||||
where: { id }
|
||||
});
|
||||
}
|
||||
|
||||
export async function createUpload(
|
||||
id: string,
|
||||
uploaderId: number,
|
||||
fileName: string,
|
||||
internalName: string,
|
||||
size: number
|
||||
) {
|
||||
const settings = await prisma.userSettings.findFirst({
|
||||
where: { id: uploaderId }
|
||||
});
|
||||
|
||||
return await prisma.upload.create({
|
||||
data: {
|
||||
id,
|
||||
uploaderId,
|
||||
fileName,
|
||||
internalName,
|
||||
size,
|
||||
public: settings?.newPostsPublic
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function getUpload(id: string) {
|
||||
if (!id) return false;
|
||||
|
||||
return await prisma.upload.findFirst({
|
||||
where: {
|
||||
id
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
fileName: true,
|
||||
internalName: true,
|
||||
public: true,
|
||||
uploaded: true,
|
||||
uploader: true
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function getSettings(id: number) {
|
||||
if (!id) return false;
|
||||
|
||||
return await prisma.userSettings.findFirst({
|
||||
where: {
|
||||
userId: id
|
||||
}
|
||||
});
|
||||
}
|
|
@ -1,10 +0,0 @@
|
|||
import { drizzle } from 'drizzle-orm/mysql2';
|
||||
import mysql from 'mysql2/promise';
|
||||
|
||||
const connection = await mysql.createConnection({
|
||||
host: process.env.DATABASE_HOST,
|
||||
user: process.env.DATABASE_USER,
|
||||
password: process.env.DATABASE_PASSWORD
|
||||
});
|
||||
|
||||
export default drizzle(connection);
|
|
@ -1,7 +0,0 @@
|
|||
import { int, mysqlTable, text } from 'drizzle-orm/mysql-core';
|
||||
|
||||
export const users = mysqlTable('users', {
|
||||
id: int('id').autoincrement().primaryKey(),
|
||||
username: text('username').unique().notNull(),
|
||||
password: text('password').notNull()
|
||||
});
|
29
src/lib/server/minio.ts
Normal file
29
src/lib/server/minio.ts
Normal file
|
@ -0,0 +1,29 @@
|
|||
import { building } from '$app/environment';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import * as Minio from 'minio';
|
||||
import { get, writable } from 'svelte/store';
|
||||
|
||||
const minio = new Minio.Client({
|
||||
endPoint: building ? 'building.local' : env.MINIO_URL,
|
||||
useSSL: true,
|
||||
accessKey: building ? 'building' : env.MINIO_ACCESS_KEY,
|
||||
secretKey: building ? 'building' : env.MINIO_SECRET_KEY
|
||||
});
|
||||
|
||||
export default minio;
|
||||
|
||||
export const BUCKET = building ? 'building' : env.MINIO_BUCKET;
|
||||
|
||||
export let USAGE = writable(0);
|
||||
|
||||
function du() {
|
||||
let usage = 0;
|
||||
const stream = minio.listObjects(BUCKET, undefined, true);
|
||||
stream.on('data', (object) => (usage += object.size));
|
||||
stream.on('end', () => USAGE.set(usage));
|
||||
}
|
||||
|
||||
if (!building) {
|
||||
du();
|
||||
setTimeout(du, 1000 * 60 * 5);
|
||||
}
|
30
src/lib/server/ratelimit.js
Normal file
30
src/lib/server/ratelimit.js
Normal file
|
@ -0,0 +1,30 @@
|
|||
import { createHash } from 'node:crypto';
|
||||
import { COOKIE } from '$lib/config.js';
|
||||
|
||||
const limits = new Map();
|
||||
|
||||
const CookieLimits = {
|
||||
Minute: 100,
|
||||
Hour: 1000
|
||||
};
|
||||
|
||||
const ApiKeyLimits = {
|
||||
Minute: 0,
|
||||
Hour: 0
|
||||
};
|
||||
|
||||
setInterval(function resetMinute() {}, 1000 * 60);
|
||||
setInterval(function resetHour() {}, 1000 * 60 * 60);
|
||||
|
||||
function hash(input) {
|
||||
createHash('sha256').update(input).digest('hex');
|
||||
}
|
||||
|
||||
export async function cookie({ cookies }) {
|
||||
const hashed = hash(cookies.get(COOKIE));
|
||||
console.log(hashed);
|
||||
}
|
||||
|
||||
export async function apiKey(event) {}
|
||||
|
||||
export async function handle(event) {}
|
8
src/lib/server/validator.js
Normal file
8
src/lib/server/validator.js
Normal file
|
@ -0,0 +1,8 @@
|
|||
import validator from 'validator';
|
||||
import { MAIL_WHITELIST } from '$lib/config';
|
||||
|
||||
// https://github.com/validatorjs/validator.js?tab=readme-ov-file#validators
|
||||
|
||||
export function email(input) {
|
||||
return validator.isEmail(input, { host_whitelist: MAIL_WHITELIST });
|
||||
}
|
|
@ -1,3 +0,0 @@
|
|||
import { writable } from 'svelte/store';
|
||||
|
||||
export const darkMode = writable();
|
8
src/lib/stores.ts
Normal file
8
src/lib/stores.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
import { writable, type Writable } from 'svelte/store';
|
||||
import type { UserSafe } from '../app';
|
||||
|
||||
export const darkMode: Writable<boolean> = writable(true);
|
||||
export const user: Writable<UserSafe> = writable();
|
||||
|
||||
// too lazy to do types for this
|
||||
export const fileProgress: Writable<{ [key: string]: any }> = writable({});
|
24
src/routes/(app)/+layout.svelte
Normal file
24
src/routes/(app)/+layout.svelte
Normal file
|
@ -0,0 +1,24 @@
|
|||
<script>
|
||||
import { page } from '$app/stores';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
import Header from '$lib/components/Header.svelte';
|
||||
import Footer from '$lib/components/Footer.svelte';
|
||||
</script>
|
||||
|
||||
<div class="container pt-2 w-full min-h-screen">
|
||||
<div class="mb-2 w-full h-12">
|
||||
<Header></Header>
|
||||
</div>
|
||||
|
||||
{#key $page.url}
|
||||
<div in:fade|global class="w-full min-h-[calc(100vh-4rem)] pb-2">
|
||||
<slot />
|
||||
</div>
|
||||
{/key}
|
||||
|
||||
<!--
|
||||
<div class="w-full h-20">
|
||||
<Footer></Footer>
|
||||
</div> -->
|
||||
</div>
|
1
src/routes/(app)/admin/+page.svelte
Normal file
1
src/routes/(app)/admin/+page.svelte
Normal file
|
@ -0,0 +1 @@
|
|||
adminnn
|
11
src/routes/(app)/dashboard/+page.server.js
Normal file
11
src/routes/(app)/dashboard/+page.server.js
Normal file
|
@ -0,0 +1,11 @@
|
|||
/** @type {import("@sveltejs/kit").ServerLoad} */
|
||||
export function load({ locals, fetch }) {
|
||||
const statistics = fetch('/api/statistics').then((response) => response.json());
|
||||
|
||||
return {
|
||||
user: locals?.user,
|
||||
streamed: {
|
||||
statistics
|
||||
}
|
||||
};
|
||||
}
|
149
src/routes/(app)/dashboard/+page.svelte
Normal file
149
src/routes/(app)/dashboard/+page.svelte
Normal file
|
@ -0,0 +1,149 @@
|
|||
<script lang="ts">
|
||||
import { Check, Upload } from 'lucide-svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { fileProgress, user } from '$lib/stores';
|
||||
import { fade, slide } from 'svelte/transition';
|
||||
import FileComponent from '$lib/components/File.svelte';
|
||||
import { get } from 'svelte/store';
|
||||
import { request } from '$lib';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
export let data;
|
||||
user.set(data?.user);
|
||||
|
||||
let input: HTMLInputElement,
|
||||
files: FileList,
|
||||
fileMap: Map<string, File> = new Map();
|
||||
|
||||
let running = false;
|
||||
|
||||
onMount(() => {
|
||||
fileProgress.set({});
|
||||
});
|
||||
|
||||
// lazy again
|
||||
function progress(name: string, data: any) {
|
||||
let _ = get(fileProgress);
|
||||
fileProgress.set(
|
||||
Object.assign(_, {
|
||||
[name]: data
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function upload() {
|
||||
if (running) return;
|
||||
running = true;
|
||||
|
||||
fileMap.forEach(async (v, k) => {
|
||||
const body = new FormData();
|
||||
body.append('file', v);
|
||||
|
||||
const response = await request(body, (percent: number) => {
|
||||
progress(k, { percent });
|
||||
}).catch(() => {
|
||||
toast.error(k + ' failed to upload.');
|
||||
progress(k, { error: true });
|
||||
});
|
||||
|
||||
if (response && response.success) progress(k, { url: response.body });
|
||||
else {
|
||||
if (response && response.body)
|
||||
try {
|
||||
let body = JSON.parse(response.body);
|
||||
toast.error(k + ' failed: ' + body.message);
|
||||
} catch (_) {
|
||||
toast.error(k + ' failed to upload.');
|
||||
}
|
||||
|
||||
progress(k, { error: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function remove(name: string) {
|
||||
fileMap.delete(name);
|
||||
fileMap = fileMap;
|
||||
}
|
||||
|
||||
function change() {
|
||||
fileMap = new Map();
|
||||
|
||||
for (let i = 0; files.length > i; i++) {
|
||||
let file = files.item(i);
|
||||
if (!file) return;
|
||||
|
||||
fileMap.set(file.name, file);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<input class="hidden" type="file" multiple={true} bind:this={input} bind:files on:change={change} />
|
||||
|
||||
<div class="w-[23rem] h-[calc(100vh-4.5rem)] flex mx-auto">
|
||||
<div class="flex flex-col gap-2 my-auto w-full">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">Welcome, {data.user.username}.</h1>
|
||||
<p class="text-overlay1">
|
||||
Your max upload size is <span class="font-bold">{data.user.maxUploadMB} MiB</span>.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 p-2 mx-auto w-full rounded-lg shadow-lg bg-crust">
|
||||
{#key fileMap}
|
||||
{#each fileMap.values() as file, i}
|
||||
<FileComponent {file} {i} {remove} {running}></FileComponent>
|
||||
{/each}
|
||||
{/key}
|
||||
|
||||
{#if !running}
|
||||
<div out:slide class="flex gap-2">
|
||||
<button
|
||||
class="flex w-full {!fileMap.size
|
||||
? 'h-36'
|
||||
: 'h-14'} rounded-md outline-2 outline-dotted outline-surface2 bg-mantle group"
|
||||
on:click={() => {
|
||||
input.click();
|
||||
}}
|
||||
>
|
||||
<div class="flex m-auto text-lg text-surface2 group-hover:text-overlay1">
|
||||
<Upload></Upload>
|
||||
</div>
|
||||
</button>
|
||||
{#if fileMap.size}
|
||||
<button
|
||||
class="flex w-[25%] h-14 rounded-md transition-all outline-2 outline-dotted outline-surface2 bg-mantle group"
|
||||
on:click={upload}
|
||||
>
|
||||
<div class="flex m-auto text-lg text-surface2 group-hover:text-green">
|
||||
<Check class=""></Check>
|
||||
</div>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="p-2 mx-auto mb-auto w-full rounded-lg shadow-lg bg-crust">
|
||||
<table class="mx-auto w-full text-sm table-auto">
|
||||
<tbody>
|
||||
{#await data?.streamed?.statistics}
|
||||
<div class="h-[66px]"></div>
|
||||
{:then statistics}
|
||||
<tr in:fade={{ delay: 60 * 1 }}>
|
||||
<td class="font-bold">Registered Users</td>
|
||||
<td class="text-right">{statistics?.users}</td>
|
||||
</tr>
|
||||
<tr in:fade={{ delay: 60 * 3 }}>
|
||||
<td class="font-bold">Files Hosted</td>
|
||||
<td class="text-right">{statistics?.files}</td>
|
||||
</tr>
|
||||
<tr in:fade={{ delay: 60 * 6 }}>
|
||||
<td class="font-bold">File Storage</td>
|
||||
<td class="text-right">{statistics?.storage}</td>
|
||||
</tr>
|
||||
{/await}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
0
src/routes/(app)/settings/+page.svelte
Normal file
0
src/routes/(app)/settings/+page.svelte
Normal file
25
src/routes/(app)/uploads/+page.server.ts
Normal file
25
src/routes/(app)/uploads/+page.server.ts
Normal file
|
@ -0,0 +1,25 @@
|
|||
import prisma from '$lib/server/database.js';
|
||||
import { error } from '@sveltejs/kit';
|
||||
|
||||
export async function load({ locals, url }) {
|
||||
if (!locals.user) return error(403, { status: 403, message: 'Forbidden' });
|
||||
|
||||
if (+(url.searchParams.get('i') || 0) < 0) error(400, { status: 403, message: 'Invalid Index' });
|
||||
|
||||
const totalUploads = await prisma.upload.count();
|
||||
if (+(url.searchParams.get('i') || 0) >= Math.ceil(totalUploads / 15))
|
||||
error(400, { status: 403, message: 'Invalid Index' });
|
||||
|
||||
const uploads = prisma.upload.findMany({
|
||||
skip: +(url.searchParams.get('i') || 0) * 15,
|
||||
take: 15,
|
||||
where: {
|
||||
uploaderId: locals.user.id
|
||||
},
|
||||
orderBy: {
|
||||
uploaded: 'desc'
|
||||
}
|
||||
});
|
||||
|
||||
return { uploads, totalUploads };
|
||||
}
|
60
src/routes/(app)/uploads/+page.svelte
Normal file
60
src/routes/(app)/uploads/+page.svelte
Normal file
|
@ -0,0 +1,60 @@
|
|||
<script lang="ts">
|
||||
import { File, ChevronLeft, ChevronRight } from 'lucide-svelte';
|
||||
import { bytesToHumanReadable } from '$lib';
|
||||
import { fade } from 'svelte/transition';
|
||||
import { writable, get } from 'svelte/store';
|
||||
import { goto } from '$app/navigation';
|
||||
import { browser } from '$app/environment';
|
||||
import { page } from '$app/stores';
|
||||
|
||||
export let data;
|
||||
|
||||
let pageIndex = writable(Number($page.url.searchParams.get('i')) || 0);
|
||||
pageIndex.subscribe((_) => {
|
||||
if (browser) {
|
||||
goto('/uploads?i=' + get(pageIndex));
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#await data.uploads then uploads}
|
||||
<div class="grid grid-cols-5 grid-rows-3 gap-2">
|
||||
{#each uploads as upload, i}
|
||||
<a
|
||||
in:fade|global={{ duration: 500, delay: 100 * i }}
|
||||
href="/file/{upload.id}"
|
||||
class="flex flex-col gap-2 p-2 mx-auto w-full rounded-lg shadow-lg bg-crust"
|
||||
>
|
||||
<div class="flex w-full rounded-lg aspect-video bg-mantle">
|
||||
<File size="32" class="m-auto text-surface2"></File>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-bold overflow-ellipsis whitespace-nowrap overflow-x-clip">
|
||||
{upload.fileName}
|
||||
</p>
|
||||
<p class="text-sm text-overlay1">{bytesToHumanReadable(upload.size)}</p>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="flex mx-auto mt-2 space-x-1 rounded-md bg-crust w-fit">
|
||||
<button
|
||||
class="p-2 my-auto hover:text-overlay2"
|
||||
on:click={() => {
|
||||
if ($pageIndex <= 0) return;
|
||||
$pageIndex -= 1;
|
||||
}}
|
||||
>
|
||||
<ChevronLeft></ChevronLeft>
|
||||
</button>
|
||||
<p class="p-2 my-auto">{$pageIndex + 1} / {Math.ceil(data.totalUploads / 15)}</p>
|
||||
<button
|
||||
class="p-2 my-auto hover:text-overlay2"
|
||||
on:click={() => {
|
||||
if ($pageIndex >= Math.ceil(data.totalUploads / 15) - 1) return;
|
||||
$pageIndex += 1;
|
||||
}}
|
||||
><ChevronRight></ChevronRight>
|
||||
</button>
|
||||
</div>
|
||||
{/await}
|
10
src/routes/(landing)/+layout.svelte
Normal file
10
src/routes/(landing)/+layout.svelte
Normal file
|
@ -0,0 +1,10 @@
|
|||
<script>
|
||||
import { page } from '$app/stores';
|
||||
import { fade } from 'svelte/transition';
|
||||
</script>
|
||||
|
||||
{#key $page.url}
|
||||
<div in:fade class="container">
|
||||
<slot />
|
||||
</div>
|
||||
{/key}
|
10
src/routes/(landing)/+page.server.ts
Normal file
10
src/routes/(landing)/+page.server.ts
Normal file
|
@ -0,0 +1,10 @@
|
|||
/** @type {import("@sveltejs/kit").ServerLoad} */
|
||||
export function load({ locals, fetch }) {
|
||||
const statistics = fetch('/api/statistics').then((response) => response.json());
|
||||
|
||||
return {
|
||||
streamed: {
|
||||
statistics
|
||||
}
|
||||
};
|
||||
}
|
65
src/routes/(landing)/+page.svelte
Normal file
65
src/routes/(landing)/+page.svelte
Normal file
|
@ -0,0 +1,65 @@
|
|||
<script>
|
||||
import { fade } from 'svelte/transition';
|
||||
import { LogIn, UserPlus } from 'lucide-svelte';
|
||||
import { writable } from 'svelte/store';
|
||||
import { page } from '$app/stores';
|
||||
import { browser } from '$app/environment';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
import ThemeSwitcher from '$lib/components/ThemeSwitcher.svelte';
|
||||
import Button from '$lib/components/Inputs/Button.svelte';
|
||||
import Logo from '$lib/components/Logo.svelte';
|
||||
import LoginForm from '$lib/components/Forms/LoginForm.svelte';
|
||||
import RegisterForm from '$lib/components/Forms/RegisterForm.svelte';
|
||||
|
||||
export let data;
|
||||
|
||||
let state = writable('/landing');
|
||||
|
||||
if ($page.url.hash.replace('#', '') !== '') {
|
||||
state.set($page.url.hash.replace('#', ''));
|
||||
}
|
||||
|
||||
state.subscribe((value) => {
|
||||
if (browser) {
|
||||
goto('#' + value);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="h-[85vh] md:h-screen">
|
||||
{#if $state === '/landing'}
|
||||
<div class="flex justify-center items-center h-full">
|
||||
<div class="flex flex-col space-y-1.5">
|
||||
<div class="text-text fill-text">
|
||||
<Logo />
|
||||
{#await data.streamed.statistics}
|
||||
<p>Currently hosting <strong>...</strong> files.</p>
|
||||
{:then stats}
|
||||
<p>Currently hosting <strong>{stats.files}</strong> files.</p>
|
||||
{/await}
|
||||
|
||||
<p class="italic">The best file uploader <strong>ever!!!</strong></p>
|
||||
</div>
|
||||
|
||||
<div class="flex place-content-around mx-auto space-x-2">
|
||||
<ThemeSwitcher />
|
||||
|
||||
<Button click={() => ($state = '/login')}>
|
||||
<LogIn />
|
||||
<p>Login</p>
|
||||
</Button>
|
||||
|
||||
<Button click={() => ($state = '/register')}>
|
||||
<UserPlus />
|
||||
<p>Register</p>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if $state === '/login'}
|
||||
<LoginForm callback={() => ($state = '/landing')} />
|
||||
{:else if $state === '/register'}
|
||||
<RegisterForm callback={() => ($state = '/landing')} />
|
||||
{/if}
|
||||
</div>
|
0
src/routes/(landing)/privacy/+page.svelte
Normal file
0
src/routes/(landing)/privacy/+page.svelte
Normal file
0
src/routes/(landing)/terms/+page.svelte
Normal file
0
src/routes/(landing)/terms/+page.svelte
Normal file
|
@ -4,8 +4,8 @@
|
|||
|
||||
import { goBack } from '$lib/';
|
||||
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import Link from '$lib/components/Link.svelte';
|
||||
import Button from '$lib/components/Inputs/Button.svelte';
|
||||
import Link from '$lib/components/Inputs/Link.svelte';
|
||||
</script>
|
||||
|
||||
<div class="flex justify-center items-center h-screen">
|
||||
|
@ -16,11 +16,9 @@
|
|||
</div>
|
||||
<div class="flex space-x-2">
|
||||
<Button click={goBack}>
|
||||
<Undo />
|
||||
<p>Go Back</p>
|
||||
</Button>
|
||||
<Link style="button" href="/">
|
||||
<Home />
|
||||
<p>Go Home</p>
|
||||
</Link>
|
||||
</div>
|
||||
|
|
6
src/routes/+layout.server.js
Normal file
6
src/routes/+layout.server.js
Normal file
|
@ -0,0 +1,6 @@
|
|||
/** @type {import('@sveltejs/kit').ServerLoad} */
|
||||
export const load = async ({ locals }) => {
|
||||
return {
|
||||
user: locals.user
|
||||
};
|
||||
};
|
|
@ -2,16 +2,36 @@
|
|||
import '../app.css';
|
||||
|
||||
import { Toaster } from 'svelte-sonner';
|
||||
|
||||
import { darkMode } from '$lib/stores.js';
|
||||
import { CircleAlert, TriangleAlert, Info, Check, Loader } from 'lucide-svelte';
|
||||
import { user } from '$lib/stores';
|
||||
import ThemeHandler from '$lib/components/ThemeHandler.svelte';
|
||||
import PageMeta from '$lib/components/PageMeta.svelte';
|
||||
|
||||
export let data;
|
||||
|
||||
user.set(data?.user);
|
||||
</script>
|
||||
|
||||
<PageMeta title="cirro's file uploader" />
|
||||
<ThemeHandler />
|
||||
<Toaster theme={$darkMode ? 'dark' : 'light'} />
|
||||
<Toaster
|
||||
position={'bottom-center'}
|
||||
toastOptions={{
|
||||
classes: {
|
||||
toast: '!bg-crust !fill-text !border-crust',
|
||||
title: '!text-text',
|
||||
description: '!text-subtext0',
|
||||
actionButton: 'bg-zinc-400',
|
||||
cancelButton: 'bg-orange-400',
|
||||
closeButton: 'bg-lime-400'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Loader size="20" class="animate-spin text-text" slot="loading-icon" />
|
||||
<Check size="20" class="text-text" slot="success-icon" />
|
||||
<CircleAlert size="20" class="text-text" slot="error-icon" />
|
||||
<Info size="20" class="text-text" slot="info-icon" />
|
||||
<TriangleAlert size="20" class="text-text" slot="warning-icon" />
|
||||
</Toaster>
|
||||
|
||||
<div class="container">
|
||||
<slot />
|
||||
</div>
|
||||
|
|
|
@ -1,45 +0,0 @@
|
|||
<script>
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { LogIn, UserPlus } from 'lucide-svelte';
|
||||
|
||||
import ThemeSwitcher from '$lib/components/ThemeSwitcher.svelte';
|
||||
import Link from '$lib/components/Link.svelte';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import Logo from '$lib/components/Logo.svelte';
|
||||
|
||||
function fuckYou() {
|
||||
toast.error('Not Implemented');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex justify-center items-center h-screen">
|
||||
<div class="flex flex-col space-y-1.5">
|
||||
<div>
|
||||
<div class="transition-colors fill-black dark:fill-white">
|
||||
<Logo />
|
||||
</div>
|
||||
<p>Currently hosting <strong>0</strong> files.</p>
|
||||
<p>Elon musk <strong>found dead</strong> in a <strong>pool</strong></p>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-2">
|
||||
<ThemeSwitcher />
|
||||
|
||||
<Link style="button" href="/login">
|
||||
<LogIn />
|
||||
<p>Login</p>
|
||||
</Link>
|
||||
|
||||
<Link style="button" href="/register">
|
||||
<UserPlus />
|
||||
<p>Register</p>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col">
|
||||
<Button click={fuckYou}>
|
||||
<p class="w-full text-center">Keycloak Login</p>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
|
@ -0,0 +1,39 @@
|
|||
import { json } from '@sveltejs/kit';
|
||||
import { COOKIE } from '$lib/config';
|
||||
import { findUser, createSession } from '$lib/server/database';
|
||||
import { email } from '$lib/server/validator';
|
||||
import { verifyHash } from '$lib/server/crypto';
|
||||
|
||||
/** @type {import('./$types').RequestHandler} */
|
||||
export async function POST(event) {
|
||||
const { request, cookies } = event;
|
||||
const body = await request.json();
|
||||
|
||||
if (!body?.email || !email(body?.email))
|
||||
return json({ error: 'Invalid email.' }, { status: 400 });
|
||||
|
||||
if (!body?.password || body?.password.length > 128 || body?.password.length < 6)
|
||||
return json({ error: 'Invalid password.' }, { status: 400 });
|
||||
|
||||
const user = await findUser({ email: body?.email });
|
||||
if (!user) return json({ error: 'User record not found.' }, { status: 401 });
|
||||
|
||||
if (!(await verifyHash(user.password, body?.password)))
|
||||
return json({ error: 'User record not found.' }, { status: 401 });
|
||||
|
||||
const session = await createSession(user.id);
|
||||
|
||||
cookies.set(COOKIE, session.id, { path: '/' });
|
||||
|
||||
return json(
|
||||
{
|
||||
success: true,
|
||||
data: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email
|
||||
}
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
17
src/routes/api/auth/logout/+server.js
Normal file
17
src/routes/api/auth/logout/+server.js
Normal file
|
@ -0,0 +1,17 @@
|
|||
import { redirect } from '@sveltejs/kit';
|
||||
import { getSession, deleteSession } from '$lib/server/database';
|
||||
import { COOKIE } from '$lib/config';
|
||||
|
||||
/** @type {import('./$types').RequestHandler} */
|
||||
export async function GET({ cookies }) {
|
||||
const session = await getSession(cookies.get(COOKIE));
|
||||
if (!session) {
|
||||
cookies.delete(COOKIE, { path: '/' });
|
||||
return redirect(302, '/');
|
||||
}
|
||||
|
||||
await deleteSession(session.id);
|
||||
cookies.delete(COOKIE, { path: '/' });
|
||||
|
||||
redirect(302, '/');
|
||||
}
|
|
@ -0,0 +1,45 @@
|
|||
import { json } from '@sveltejs/kit';
|
||||
import { COOKIE } from '$lib/config';
|
||||
import { createUser, createSession, findUser } from '$lib/server/database';
|
||||
import { email } from '$lib/server/validator';
|
||||
|
||||
export async function POST(event) {
|
||||
const { request, cookies } = event;
|
||||
const body = await request.json();
|
||||
|
||||
if (!body?.username || body?.username.length > 16 || body?.username.length < 3)
|
||||
return json({ error: 'Invalid username.' }, { status: 400 });
|
||||
|
||||
if (!body?.email || !email(body?.email))
|
||||
return json({ error: 'Invalid email.' }, { status: 400 });
|
||||
|
||||
if (!body?.password || body?.password.length > 128 || body?.password.length < 6)
|
||||
return json({ error: 'Invalid password.' }, { status: 400 });
|
||||
|
||||
const usernameTaken = !!(await findUser({ username: body?.username }));
|
||||
if (usernameTaken) return json({ error: 'That username is taken.' }, { status: 400 });
|
||||
|
||||
const emailUsed = !!(await findUser({ email: body?.email }));
|
||||
if (emailUsed)
|
||||
return json({ error: 'That email has been used too many times.' }, { status: 400 });
|
||||
|
||||
const user = await createUser(body?.username, body?.email, body?.password).catch((e) => {});
|
||||
if (!user) return json({ error: 'Internal Server Error' }, { status: 500 });
|
||||
|
||||
const session = await createSession(user.id);
|
||||
if (!session) return json({ error: 'Internal Server Error' }, { status: 500 });
|
||||
|
||||
cookies.set(COOKIE, session.id, { path: '/' });
|
||||
|
||||
return json(
|
||||
{
|
||||
success: true,
|
||||
data: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email
|
||||
}
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
14
src/routes/api/statistics/+server.js
Normal file
14
src/routes/api/statistics/+server.js
Normal file
|
@ -0,0 +1,14 @@
|
|||
import { bytesToHumanReadable } from '$lib';
|
||||
import prisma from '$lib/server/database';
|
||||
import minio, { USAGE } from '$lib/server/minio';
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
/** @type {import("@sveltejs/kit").RequestHandler} */
|
||||
export async function GET() {
|
||||
return json({
|
||||
users: await prisma.user.count({}),
|
||||
files: await prisma.upload.count({}),
|
||||
storage: bytesToHumanReadable(get(USAGE))
|
||||
});
|
||||
}
|
44
src/routes/api/upload/+server.ts
Normal file
44
src/routes/api/upload/+server.ts
Normal file
|
@ -0,0 +1,44 @@
|
|||
import { authenticate } from '$lib/server/auth';
|
||||
import { catIdCharacters, generateId } from '$lib/server/crypto.js';
|
||||
import { createUpload } from '$lib/server/database';
|
||||
import minio, { BUCKET } from '$lib/server/minio';
|
||||
import { error } from '@sveltejs/kit';
|
||||
|
||||
export const POST = async ({ request, cookies }) => {
|
||||
const contentType = request.headers.get('Content-Type');
|
||||
if (!contentType || !contentType.includes('multipart/form-data'))
|
||||
return error(400, { status: 400, message: 'Improper Content-Type' });
|
||||
|
||||
const user = await authenticate(request, cookies);
|
||||
if (!user) return error(403, { status: 403, message: 'Forbidden' });
|
||||
|
||||
const data = await request.formData();
|
||||
const file = data.get('file') as File;
|
||||
|
||||
if (file.size / 1048576 >= user.maxUploadMB)
|
||||
return error(413, { status: 413, message: 'Content Too Large' });
|
||||
|
||||
let id = generateId(undefined, 10);
|
||||
let internalName = `${Date.now()}-${file.name}`;
|
||||
|
||||
const object = await minio
|
||||
.putObject(
|
||||
BUCKET,
|
||||
`${user.id}/${internalName}`,
|
||||
Buffer.from(await file.arrayBuffer()),
|
||||
file.size,
|
||||
{
|
||||
'Content-Type': file.type
|
||||
}
|
||||
)
|
||||
.catch((e) => console.log(e));
|
||||
if (!object)
|
||||
return error(500, { status: 500, message: 'Internal Server Error - Contact Administrator' });
|
||||
|
||||
const objectRecord = await createUpload(id, user.id, file.name, internalName, file.size);
|
||||
if (!objectRecord)
|
||||
return error(500, { status: 500, message: 'Internal Server Error - Contact Administrator' });
|
||||
|
||||
if (user.settings?.linkToRaw) return new Response(`/download/${id}`);
|
||||
return new Response(`/file/${id}`);
|
||||
};
|
8
src/routes/api/user/+server.js
Normal file
8
src/routes/api/user/+server.js
Normal file
|
@ -0,0 +1,8 @@
|
|||
import { json } from '@sveltejs/kit';
|
||||
|
||||
/** @type {import('./$types').RequestHandler} */
|
||||
export async function GET(event) {
|
||||
const { request, cookies, locals } = event;
|
||||
|
||||
return json(locals?.user);
|
||||
}
|
43
src/routes/download/[id]/+server.ts
Normal file
43
src/routes/download/[id]/+server.ts
Normal file
|
@ -0,0 +1,43 @@
|
|||
import { getUpload } from '$lib/server/database.js';
|
||||
import minio, { BUCKET } from '$lib/server/minio';
|
||||
import { error } from '@sveltejs/kit';
|
||||
|
||||
export const GET = async ({ params, locals }) => {
|
||||
let id: any = params.id.split('.');
|
||||
if (id.length > 1) id.pop();
|
||||
id = id.join('');
|
||||
|
||||
const file = await getUpload(id);
|
||||
if (!file) throw error(404, { status: 404, message: 'File Not Found' });
|
||||
|
||||
if (!file.public && locals?.user?.id !== file.uploader.id)
|
||||
throw error(403, { status: 403, message: 'Forbidden' });
|
||||
|
||||
const object = await minio.getObject(BUCKET, `${file.uploader.id}/${file.internalName}`);
|
||||
const metadata = await minio.statObject(BUCKET, `${file.uploader.id}/${file.internalName}`);
|
||||
|
||||
const ac = new AbortController();
|
||||
ac.signal.onabort = () => object.destroy;
|
||||
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
object.on('data', (chunk) => {
|
||||
controller.enqueue(chunk);
|
||||
});
|
||||
object.on('end', () => {
|
||||
controller.close();
|
||||
});
|
||||
},
|
||||
cancel() {
|
||||
ac.abort();
|
||||
}
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Disposition': `attachment; filename="${file.fileName}"`,
|
||||
'Content-Type': metadata.metaData['content-type'],
|
||||
'Content-Length': metadata.size.toString()
|
||||
}
|
||||
});
|
||||
};
|
43
src/routes/file/[id]/+page.server.ts
Normal file
43
src/routes/file/[id]/+page.server.ts
Normal file
|
@ -0,0 +1,43 @@
|
|||
import { getSettings, getUpload } from '$lib/server/database';
|
||||
import minio, { BUCKET } from '$lib/server/minio';
|
||||
import { error } from '@sveltejs/kit';
|
||||
|
||||
export async function load({ params, locals }) {
|
||||
const file = await getUpload(params.id);
|
||||
if (!file) throw error(404, { status: 404, message: 'File Not Found' });
|
||||
|
||||
if (!file.public && locals?.user?.id !== file.uploader.id)
|
||||
throw error(403, { status: 403, message: 'Forbidden' });
|
||||
|
||||
const settings = await getSettings(file.uploader.id);
|
||||
if (!settings) throw error(500, { status: 500, message: 'Internal Server Error' });
|
||||
|
||||
const metadata = await minio.statObject(BUCKET, `${file.uploader.id}/${file.internalName}`);
|
||||
|
||||
function formatString(input: string) {
|
||||
if (file && metadata)
|
||||
return input
|
||||
.replaceAll('{{file}}', file.fileName)
|
||||
.replaceAll('{{username}}', file.uploader.username)
|
||||
.replaceAll('{{time}}', file.uploaded.toUTCString());
|
||||
}
|
||||
|
||||
return {
|
||||
file: {
|
||||
id: file.id,
|
||||
fileName: file.fileName,
|
||||
uploaded: file.uploaded,
|
||||
size: metadata.size,
|
||||
type: metadata.metaData['content-type']
|
||||
},
|
||||
uploader: {
|
||||
username: file.uploader.username
|
||||
},
|
||||
settings: {
|
||||
title: formatString(settings.embedTitle),
|
||||
description: formatString(settings.embedDescription),
|
||||
color: '#' + settings.embedColor.toString(16),
|
||||
large: true
|
||||
}
|
||||
};
|
||||
}
|
56
src/routes/file/[id]/+page.svelte
Normal file
56
src/routes/file/[id]/+page.svelte
Normal file
|
@ -0,0 +1,56 @@
|
|||
<script>
|
||||
import { page } from '$app/stores';
|
||||
import { bytesToHumanReadable } from '$lib';
|
||||
import mime from 'mime';
|
||||
import Link from '$lib/components/Inputs/Link.svelte';
|
||||
|
||||
export let data;
|
||||
|
||||
const ext = `.${mime.getExtension(data.file.type)}`;
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.file.fileName}</title>
|
||||
<meta property="og:title" content={data.settings.title} />
|
||||
<meta property="og:description" content={data.settings.description} />
|
||||
<meta property="og:url" content="{$page.url.origin}/file/{data.file.id}" />
|
||||
<meta property="og:site_name" content="cirro's file uploader" />
|
||||
<meta name="theme-color" content={data.settings.color} />
|
||||
{#if data.file.type.includes('video')}
|
||||
<meta property="og:type" content="video.other" />
|
||||
<meta property="og:video:url" content="{$page.url.origin}/download/{data.file.id}{ext}" />
|
||||
{:else if data.file.type.includes('image')}
|
||||
{#if data.settings.large}
|
||||
<meta property="twitter:card" content="summary_large_image" />
|
||||
{/if}
|
||||
<meta property="og:image" content="{$page.url.origin}/download/{data.file.id}{ext}" />
|
||||
{/if}
|
||||
</svelte:head>
|
||||
|
||||
<div class="container">
|
||||
<div class="h-[85vh] md:h-screen">
|
||||
<div class="flex justify-center items-center h-full">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">{data.file.fileName}</h1>
|
||||
<p class="text-overlay1">
|
||||
Uploaded by <span class="font-bold">{data.uploader.username}</span>.
|
||||
</p>
|
||||
</div>
|
||||
<div class="p-2.5 mx-auto rounded-lg shadow-lg w-fit bg-crust">
|
||||
{#if data.file.type.includes('video')}
|
||||
<!-- svelte-ignore a11y-media-has-caption -->
|
||||
<video class="h-[36rem]" src="/download/{data.file.id}{ext}" controls></video>
|
||||
{:else if data.file.type.includes('image')}
|
||||
<img class="h-[36rem]" src="/download/{data.file.id}{ext}" alt={data.file.id} />
|
||||
{/if}
|
||||
</div>
|
||||
<Link style="button" href="/download/{data.file.id}{ext}">
|
||||
<p class="w-full font-bold text-center">
|
||||
Download ({bytesToHumanReadable(data.file.size)})
|
||||
</p>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
|
@ -1,36 +0,0 @@
|
|||
<script>
|
||||
import { Mail, SquareAsterisk, LogIn, Undo } from 'lucide-svelte';
|
||||
import { goBack } from '$lib/';
|
||||
|
||||
import Logo from '$lib/components/Logo.svelte';
|
||||
import FormInput from '$lib/components/FormInput.svelte';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
</script>
|
||||
|
||||
<div class="flex justify-center items-center h-screen">
|
||||
<div class="flex flex-col space-y-2">
|
||||
<div class="transition-colors fill-black dark:fill-white">
|
||||
<Logo />
|
||||
</div>
|
||||
<form action="">
|
||||
<div class="flex flex-col space-y-2">
|
||||
<FormInput type={'email'} name={'email'} id={'email'} placeholder={'user@example.com'}>
|
||||
<Mail />
|
||||
</FormInput>
|
||||
<FormInput type={'password'} name={'password'} id={'password'} placeholder={'•'.repeat(16)}>
|
||||
<SquareAsterisk />
|
||||
</FormInput>
|
||||
<div class="flex place-content-between">
|
||||
<Button click={goBack}>
|
||||
<Undo />
|
||||
<p>Go Back</p>
|
||||
</Button>
|
||||
<Button>
|
||||
<LogIn />
|
||||
<p>Login</p>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
|
@ -1,47 +0,0 @@
|
|||
<script>
|
||||
import { Mail, SquareAsterisk, LogIn, Undo, User, UserPlus } from 'lucide-svelte';
|
||||
import { goBack } from '$lib/';
|
||||
|
||||
import Logo from '$lib/components/Logo.svelte';
|
||||
import FormInput from '$lib/components/FormInput.svelte';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
</script>
|
||||
|
||||
<div class="flex justify-center items-center h-screen">
|
||||
<div class="flex flex-col space-y-2">
|
||||
<div class="transition-colors fill-black dark:fill-white">
|
||||
<Logo />
|
||||
</div>
|
||||
<form action="">
|
||||
<div class="flex flex-col space-y-2">
|
||||
<FormInput type={'username'} name={'username'} id={'username'} placeholder={'Username'}>
|
||||
<User />
|
||||
</FormInput>
|
||||
<FormInput type={'email'} name={'email'} id={'email'} placeholder={'user@example.com'}>
|
||||
<Mail />
|
||||
</FormInput>
|
||||
<FormInput type={'password'} name={'password'} id={'password'} placeholder={'•'.repeat(16)}>
|
||||
<SquareAsterisk />
|
||||
</FormInput>
|
||||
<FormInput
|
||||
type={'password'}
|
||||
name={'cpassword'}
|
||||
id={'cpassword'}
|
||||
placeholder={'•'.repeat(16)}
|
||||
>
|
||||
<SquareAsterisk />
|
||||
</FormInput>
|
||||
<div class="flex place-content-between">
|
||||
<Button click={goBack}>
|
||||
<Undo />
|
||||
<p>Go Back</p>
|
||||
</Button>
|
||||
<Button>
|
||||
<UserPlus />
|
||||
<p>Register</p>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
|
@ -6,5 +6,9 @@ export default {
|
|||
extend: {},
|
||||
container: { center: true }
|
||||
},
|
||||
plugins: []
|
||||
plugins: [
|
||||
require('@catppuccin/tailwindcss')({
|
||||
defaultFlavour: 'latte'
|
||||
})
|
||||
]
|
||||
};
|
||||
|
|
Loading…
Reference in a new issue