Merge remote-tracking branch 'origin/master' into lucia_addition

This commit is contained in:
Dan Mihailescu 2024-12-15 12:18:49 -05:00
commit c7e69b99f2
31 changed files with 559 additions and 94 deletions

View file

@ -31,6 +31,7 @@
"@sveltejs/adapter-auto": "^3.0.0", "@sveltejs/adapter-auto": "^3.0.0",
"@sveltejs/kit": "^2.9.0", "@sveltejs/kit": "^2.9.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0", "@sveltejs/vite-plugin-svelte": "^5.0.0",
"@types/bun": "^1.1.14",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"eslint": "^9.7.0", "eslint": "^9.7.0",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",
@ -41,6 +42,7 @@
"prettier-plugin-tailwindcss": "^0.6.5", "prettier-plugin-tailwindcss": "^0.6.5",
"prisma": "^6.0.1", "prisma": "^6.0.1",
"storybook": "^8.4.7", "storybook": "^8.4.7",
"storybook-dark-mode": "^4.0.2",
"svelte": "^5.0.0", "svelte": "^5.0.0",
"svelte-check": "^4.0.0", "svelte-check": "^4.0.0",
"tailwindcss": "^3.4.9", "tailwindcss": "^3.4.9",
@ -56,14 +58,14 @@
"@prisma/client": "6.0.1", "@prisma/client": "6.0.1",
"@supabase/supabase-js": "^2.47.6", "@supabase/supabase-js": "^2.47.6",
"@tailwindcss/typography": "^0.5.15", "@tailwindcss/typography": "^0.5.15",
"@types/bun": "^1.1.14", "dayjs": "^1.11.13",
"graphql": "^16.9.0", "graphql": "^16.9.0",
"graphql-yoga": "^5.10.4", "graphql-yoga": "^5.10.4",
"lucia": "^3.2.2", "lucia": "^3.2.2",
"oslo": "^1.2.1", "oslo": "^1.2.1",
"pino": "^9.5.0", "pino": "^9.5.0",
"pino-pretty": "^13.0.0", "pino-pretty": "^13.0.0",
"storybook-dark-mode": "^4.0.2", "tailwind-merge": "^2.5.5",
"zod": "^3.24.0" "zod": "^3.24.0"
} }
} }

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,37 @@
/*
Warnings:
- Made the column `content` on table `Post` required. This step will fail if there are existing NULL values in that column.
- Made the column `name` on table `User` required. This step will fail if there are existing NULL values in that column.
*/
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Post" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"title" TEXT NOT NULL,
"content" TEXT NOT NULL,
"published" BOOLEAN DEFAULT false,
"authorId" INTEGER NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Post_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
INSERT INTO "new_Post" ("authorId", "content", "id", "published", "title") SELECT "authorId", "content", "id", "published", "title" FROM "Post";
DROP TABLE "Post";
ALTER TABLE "new_Post" RENAME TO "Post";
CREATE TABLE "new_User" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"email" TEXT,
"name" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO "new_User" ("email", "id", "name") SELECT "email", "id", "name" FROM "User";
DROP TABLE "User";
ALTER TABLE "new_User" RENAME TO "User";
CREATE UNIQUE INDEX "User_id_key" ON "User"("id");
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View file

@ -16,10 +16,13 @@ datasource db {
model User { model User {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
email String @unique email String? @unique
name String? name String
posts Post[] posts Post[]
sessions Session[] sessions Session[]
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
} }
model Session { model Session {
@ -37,8 +40,10 @@ model Session {
model Post { model Post {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
title String title String
content String? content String
published Boolean @default(false) published Boolean? @default(false)
author User @relation(fields: [authorId], references: [id]) author User @relation(fields: [authorId], references: [id])
authorId Int authorId Int
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
} }

View file

@ -1,3 +1,19 @@
@import 'tailwindcss/base'; @import 'tailwindcss/base';
@import 'tailwindcss/components'; @import 'tailwindcss/components';
@import 'tailwindcss/utilities'; @import 'tailwindcss/utilities';
:root {
@apply text-slate-800;
}
h1 {
@apply font-display text-4xl;
}
h2 {
@apply font-display text-3xl;
}
h3 {
@apply font-display text-2xl;
}

View file

@ -9,12 +9,12 @@
backgroundColor, backgroundColor,
primary = false primary = false
}: { }: {
type: HTMLButtonAttributes['type']; type?: HTMLButtonAttributes['type'];
onClick: () => void; onClick?: () => void;
label: string; label: string;
size: 'small' | 'normal' | 'large'; size?: 'small' | 'normal' | 'large';
backgroundColor: string; backgroundColor?: string;
primary: boolean; primary?: boolean;
} = $props(); } = $props();
</script> </script>
@ -31,7 +31,7 @@
<style> <style>
.button { .button {
@apply inline-block cursor-pointer rounded-full border-0 font-semibold leading-none transition-colors hover:opacity-80 hover:shadow-lg; @apply inline-block cursor-pointer rounded-lg border-0 font-semibold leading-none transition-colors hover:opacity-80 hover:shadow-lg;
} }
.button--small { .button--small {
@apply px-2 py-0.5 text-sm; @apply px-2 py-0.5 text-sm;
@ -40,7 +40,7 @@
@apply px-2 py-1 text-base; @apply px-2 py-1 text-base;
} }
.button--large { .button--large {
@apply px-2.5 py-1 text-xl; @apply px-3 py-1.5 text-lg;
} }
.button--primary { .button--primary {
@apply bg-red-600 text-white; @apply bg-red-600 text-white;

View file

@ -0,0 +1,43 @@
<script module lang="ts">
import { defineMeta } from '@storybook/addon-svelte-csf';
import Input from './Input.svelte';
const { Story } = defineMeta({
title: 'Input',
component: Input,
tags: ['autodocs'],
argTypes: {
type: {
control: 'select',
options: [
'number',
'button',
'checkbox',
'color',
'date',
'datetime-local',
'email',
'file',
'hidden',
'image',
'month',
'password',
'radio',
'range',
'reset',
'search',
'submit',
'tel',
'text',
'time',
'url',
'week'
]
}
}
});
</script>
<Story name="Text" args={{ label: 'Text', name: 'text', type: 'text' }} />
<Story name="Password" args={{ label: 'Password', name: 'pass', type: 'password' }} />
<Story name="Email" args={{ label: 'Email', name: 'email', type: 'email' }} />

View file

@ -0,0 +1,36 @@
<script lang="ts">
import type { HTMLAttributes, HTMLInputTypeAttribute } from 'svelte/elements';
import { twMerge } from 'tailwind-merge';
type InputProps = {
label?: string;
name: string;
type?: HTMLInputTypeAttribute;
} & HTMLAttributes<HTMLDivElement>;
let { label, name, type = 'text', ...props }: InputProps = $props();
</script>
<div {...props} class={twMerge('hestia-input', props.class)}>
{#if label}
<label for={name}>{label}</label>
<div class="line"></div>
{/if}
<input {name} id={name} {type} />
</div>
<style>
.line {
@apply h-8 border border-l-slate-200;
}
.hestia-input {
@apply flex w-fit items-center gap-2 rounded-lg bg-slate-100 p-2 text-slate-700 outline-blue-400 focus-within:outline;
}
.hestia-input > label {
@apply font-display text-lg;
}
.hestia-input > input {
all: unset;
@apply text-lg;
}
</style>

View file

@ -18,7 +18,7 @@
<style> <style>
.navbar { .navbar {
@apply flex items-center justify-between border-slate-200 bg-slate-100 px-6 py-2 font-display drop-shadow; @apply flex items-center justify-between border-b border-slate-200 bg-slate-50 px-6 py-2 font-display drop-shadow;
} }
.navbar h1 { .navbar h1 {

View file

@ -1,65 +0,0 @@
import { prisma } from '$lib/prisma';
import { Context } from '$lib/yoga/context';
import SchemaBuilder from '@pothos/core';
import PrismaPlugin, { type PrismaTypesFromClient } from '@pothos/plugin-prisma';
type ContextType = ReturnType<typeof Context>;
export const builder = new SchemaBuilder<{
Context: ContextType;
PrismaTypes: PrismaTypesFromClient<typeof prisma>;
}>({
plugins: [PrismaPlugin],
prisma: {
client: prisma,
// defaults to false, uses /// comments from prisma schema as descriptions
// for object types, relations and exposed fields.
// descriptions can be omitted by setting description to false
exposeDescriptions: false,
// use where clause from prismaRelatedConnection for totalCount (defaults to true)
filterConnectionTotalCount: true,
// warn when not using a query parameter correctly
onUnusedQuery: process.env.NODE_ENV === 'production' ? null : 'warn'
}
});
const User = builder.prismaObject('User', {
fields: (t) => ({
id: t.exposeID('id'),
email: t.exposeString('email'),
name: t.exposeString('name'),
posts: t.relation('posts')
})
});
const Post = builder.prismaObject('Post', {
fields: (t) => ({
id: t.exposeID('id'),
title: t.exposeString('title'),
content: t.exposeString('content'),
published: t.exposeBoolean('published'),
author: t.relation('author')
})
});
builder.queryType({
fields: (t) => ({
version: t.string({
resolve: (parent, args, context) => context.config.app_version
}),
users: t.prismaField({
type: [User],
resolve: async () => {
return await prisma.user.findMany();
}
}),
posts: t.prismaField({
type: [Post],
resolve: async () => {
return await prisma.post.findMany();
}
})
})
});
export const Schema = builder.toSchema();

View file

@ -1,4 +1,4 @@
import { logger } from '$lib/logger'; import { logger } from '$lib/server/logger';
import { z } from 'zod'; import { z } from 'zod';
export interface Configuration { export interface Configuration {

View file

@ -0,0 +1,28 @@
import { prisma } from '$lib/server/prisma';
import type { Context } from '$lib/server/yoga';
import SchemaBuilder from '@pothos/core';
import PrismaPlugin, { type PrismaTypesFromClient } from '@pothos/plugin-prisma';
import type { Scalars } from './schema/Scalars';
type PothosType = {
Context: ReturnType<typeof Context>;
PrismaTypes: PrismaTypesFromClient<typeof prisma>;
Scalars: Scalars;
};
SchemaBuilder.allowPluginReRegistration = true;
export const builder = new SchemaBuilder<PothosType>({
plugins: [PrismaPlugin],
prisma: {
client: prisma,
// defaults to false, uses /// comments from prisma schema as descriptions
// for object types, relations and exposed fields.
// descriptions can be omitted by setting description to false
exposeDescriptions: false,
// use where clause from prismaRelatedConnection for totalCount (defaults to true)
filterConnectionTotalCount: true,
// warn when not using a query parameter correctly
onUnusedQuery: process.env.NODE_ENV === 'production' ? null : 'warn'
}
});

View file

@ -0,0 +1 @@
export * from './schema';

View file

@ -0,0 +1,14 @@
import { builder } from '../../builder';
export const DateScalar = builder.scalarType('Date', {
description: 'Date Scalar in ISO format',
serialize: (date) => {
return date.toISOString();
},
parseValue: (date) => {
if (typeof date !== 'string') {
throw new Error('Cyka blyat');
}
return new Date(date);
}
});

View file

@ -0,0 +1,8 @@
export * from './Date';
export type Scalars = {
Date: {
Input: Date;
Output: Date;
};
};

View file

@ -0,0 +1,18 @@
import { builder } from '../builder';
builder.queryType({});
builder.queryField('version', (t) =>
t.string({
description: 'Application version',
resolve: (parent, args, context) => context.config.app_version
})
);
builder.mutationType({});
import './Scalars';
import './posts';
import './users';
export const Schema = builder.toSchema();

View file

@ -0,0 +1,110 @@
import { prisma } from '$lib/server/prisma';
import { builder } from '../builder';
export const Post = builder.prismaObject('Post', {
fields: (t) => ({
id: t.exposeID('id'),
title: t.exposeString('title'),
content: t.exposeString('content'),
published: t.exposeBoolean('published'),
author: t.relation('author'),
createdAt: t.expose('createdAt', {
type: 'Date'
}),
updatedAt: t.expose('updatedAt', {
type: 'Date'
})
})
});
const CreatePost = builder.inputType('CreatePost', {
fields: (t) => ({
title: t.string({
required: true
}),
content: t.string({
required: true
}),
published: t.boolean(),
authorId: t.id({
required: true
})
})
});
const UpdatePost = builder.inputType('UpdatePost', {
fields: (t) => ({
id: t.id({
required: true
}),
title: t.string(),
content: t.string(),
published: t.boolean(),
authorId: t.id()
})
});
builder.queryFields((t) => ({
posts: t.prismaField({
type: [Post],
resolve: async () => {
return await prisma.post.findMany();
}
})
}));
builder.mutationFields((t) => ({
createPost: t.field({
type: Post,
args: {
input: t.arg({ required: true, type: CreatePost })
},
resolve: async (parent, args) => {
const author = await prisma.user.findUnique({
where: { id: Number(args.input.authorId) }
});
if (!author) {
throw new Error('Author does not exist!');
}
const post = await prisma.post.create({
data: {
title: args.input.title,
content: args.input.content,
published: args.input.published,
author: {
connect: {
id: author.id
}
}
}
});
return post;
}
}),
updatePost: t.field({
type: Post,
args: {
input: t.arg({ required: true, type: UpdatePost })
},
resolve: async (parent, args) => {
const post = await prisma.post.update({
where: {
id: Number(args.input.id)
},
data: {
title: args.input.title ?? undefined,
content: args.input.content ?? undefined,
published: args.input.published,
...(args.input.authorId && {
author: {
connect: {
id: Number(args.input.authorId)
}
}
})
}
});
return post;
}
})
}));

View file

@ -0,0 +1,83 @@
import { prisma } from '$lib/server/prisma';
import { builder } from '../builder';
export const User = builder.prismaObject('User', {
fields: (t) => ({
id: t.exposeID('id'),
email: t.exposeString('email'),
name: t.exposeString('name'),
posts: t.relation('posts'),
createdAt: t.expose('createdAt', {
type: 'Date'
}),
updatedAt: t.expose('updatedAt', {
type: 'Date'
})
})
});
const CreateUser = builder.inputType('CreateUser', {
fields: (t) => ({
email: t.string({
required: true
}),
name: t.string({
required: true
})
})
});
const UpdateUser = builder.inputType('UpdateUser', {
fields: (t) => ({
id: t.id({
required: true
}),
email: t.string(),
name: t.string()
})
});
builder.queryFields((t) => ({
users: t.prismaField({
type: [User],
resolve: async () => {
return await prisma.user.findMany();
}
})
}));
builder.mutationFields((t) => ({
createUser: t.field({
type: User,
args: {
input: t.arg({ required: true, type: CreateUser })
},
resolve: async (parent, args) => {
const post = await prisma.user.create({
data: {
email: args.input.email,
name: args.input.name
}
});
return post;
}
}),
updateUser: t.field({
type: User,
args: {
input: t.arg({ required: true, type: UpdateUser })
},
resolve: async (parent, args) => {
const post = await prisma.user.update({
where: {
id: Number(args.input.id)
},
data: {
email: args.input.email,
name: args.input.name ?? undefined
}
});
return post;
}
})
}));

View file

@ -1,4 +1,4 @@
import { Config } from '$lib/config'; import { Config } from '$lib/server/config';
import type { YogaInitialContext } from 'graphql-yoga'; import type { YogaInitialContext } from 'graphql-yoga';
export const Context = (initialContext: YogaInitialContext) => ({ export const Context = (initialContext: YogaInitialContext) => ({

View file

@ -0,0 +1,2 @@
export * from './context';
export * from './server';

View file

@ -1,5 +1,5 @@
import { yogaLogger } from '$lib/logger'; import { yogaLogger } from '$lib/server/logger';
import { Schema } from '$lib/pothos'; import { Schema } from '$lib/server/pothos';
import type { RequestEvent } from '@sveltejs/kit'; import type { RequestEvent } from '@sveltejs/kit';
import { createYoga } from 'graphql-yoga'; import { createYoga } from 'graphql-yoga';
import { Context } from './context'; import { Context } from './context';

View file

@ -3,4 +3,12 @@
let { children } = $props(); let { children } = $props();
</script> </script>
<div class="layout">
{@render children()} {@render children()}
</div>
<style>
.layout {
@apply h-screen w-screen animate-fade bg-slate-100;
}
</style>

View file

@ -0,0 +1,18 @@
import { prisma } from '$lib/server/prisma';
export async function load(event) {
const userId = event.cookies.get('user');
if (!userId && isNaN(Number(userId))) {
return {
authenticated: false
};
}
const user = await prisma.user.findUnique({
where: {
id: Number(userId)
}
});
return {
authenticated: !!user
};
}

View file

@ -2,10 +2,10 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import Loader from '$lib/components/Loader.svelte'; import Loader from '$lib/components/Loader.svelte';
let { data } = $props();
$effect(() => { $effect(() => {
const id = setTimeout(() => { const id = setTimeout(() => (data.authenticated ? goto('/app') : goto('/login')), 1500);
goto('/app');
}, 1500);
return () => { return () => {
clearTimeout(id); clearTimeout(id);
}; };
@ -19,9 +19,6 @@
<style> <style>
.site-loader { .site-loader {
@apply flex h-screen w-screen flex-col items-center justify-center gap-6 bg-slate-100; @apply flex h-screen w-screen flex-col items-center justify-center gap-6;
}
.site-loader h1 {
@apply font-display text-4xl;
} }
</style> </style>

View file

@ -1,3 +1,3 @@
import { Yoga } from '$lib/yoga'; import { Yoga } from '$lib/server/yoga';
export { Yoga as GET, Yoga as POST }; export { Yoga as GET, Yoga as POST };

View file

@ -0,0 +1,46 @@
import { logger } from '$lib/server/logger';
import { prisma } from '$lib/server/prisma';
import { error, redirect, type Actions } from '@sveltejs/kit';
export const actions = {
login: async (event) => {
const form = await event.request.formData();
if (!form.has('email')) {
return error(400, 'Email is a required form field!');
}
const user = await prisma.user.findUnique({
where: {
email: form.get('email') as string
}
});
if (!user) {
logger.error('User not found! ${user}');
return error(401);
}
event.cookies.set('user', String(user.id), {
path: '/',
maxAge: 120
});
redirect(302, '/');
},
register: async (event) => {
const form = await event.request.formData();
if (!form.has('email') || !form.has('name')) {
return error(400);
}
const user = await prisma.user.create({
data: {
email: form.get('email') as string,
name: form.get('name') as string
}
});
if (!user) {
return error(500);
}
event.cookies.set('user', String(user.id), {
path: '/',
maxAge: 120
});
redirect(302, '/');
}
} satisfies Actions;

View file

@ -0,0 +1,49 @@
<script lang="ts">
import Button from '$lib/components/Button.svelte';
import Input from '$lib/components/Input.svelte';
import { fade, scale } from 'svelte/transition';
let mode: 'register' | 'login' = $state('login');
let action = $derived(mode === 'login' ? '?/login' : '?/register');
function onViewToggle() {
mode = mode === 'login' ? 'register' : 'login';
}
</script>
<div class="page">
<h1 class="underline">Hestia</h1>
<div class="login">
<form method="POST" {action} transition:scale>
<h2 transition:fade>{mode === 'login' ? 'Login' : 'Register'}</h2>
{#if mode === 'register'}
<div transition:fade>
<Input label="Name" name="name" />
</div>
{/if}
<Input label="Email" name="email" type="email" />
<Input label="Password" name="password" type="password" />
<div class="flex gap-2">
<Button
onClick={onViewToggle}
label={mode === 'login' ? 'Register' : 'Login'}
size="large"
primary
/>
<Button type="submit" label="Submit" size="large" />
</div>
</form>
</div>
</div>
<style>
.page {
@apply flex flex-col items-center justify-around gap-24 py-[10%];
}
.login {
@apply w-fit max-w-lg animate-fade rounded-lg bg-white p-8;
}
.login > form {
@apply flex w-full flex-col items-center gap-8 rounded-lg;
}
</style>

View file

@ -8,6 +8,15 @@ export default {
extend: { extend: {
fontFamily: { fontFamily: {
display: ['Baskervville SC'] display: ['Baskervville SC']
},
animation: {
fade: 'fadeIn .5s ease-in-out'
},
keyframes: {
fadeIn: {
from: { opacity: '0' },
to: { opacity: '1' }
}
} }
} }
}, },