Merge pull request #12 from BradNut/auth

Introduce Auth
This commit is contained in:
Bradley Shellnut 2023-06-17 22:08:44 +00:00 committed by GitHub
commit dbbb292bb1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
64 changed files with 3500 additions and 987 deletions

40
.vscode/launch.json vendored
View file

@ -1,17 +1,27 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Launch Vite DEV server",
"request": "launch",
"runtimeExecutable": "npx",
"runtimeArgs": ["vite"],
"type": "node",
"serverReadyAction": {
"action": "debugWithChrome",
"pattern": "Local: http://localhost:([0-9]+)",
"uriFormat": "http://localhost:%s"
}
}
]
"version": "0.2.0",
"configurations": [
{
"name": "Launch server",
"request": "launch",
"runtimeArgs": ["dev"],
"runtimeExecutable": "pnpm",
"skipFiles": ["<node_internals>/**"],
"type": "node",
"console": "integratedTerminal"
},
{
"type": "chrome",
"request": "launch",
"name": "Launch browser",
"url": "http://localhost:5173",
"webRoot": "${workspaceFolder}"
}
],
"compounds": [
{
"name": "Both",
"configurations": ["Launch server", "Launch browser"]
}
]
}

View file

@ -3,70 +3,86 @@
"version": "0.0.2",
"scripts": {
"dev": "NODE_OPTIONS=\"--inspect\" vite dev --host",
"build": "vite build",
"build": "prisma generate && vite build",
"package": "svelte-kit package",
"preview": "vite preview",
"test": "playwright test",
"postinstall": "prisma generate",
"check": "svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check --plugin-search-dir=. . && eslint .",
"format": "prettier --write --plugin-search-dir=. ."
"format": "prettier --write --plugin-search-dir=. .",
"site:update": "pnpm update -i -L",
"db:studio": "prisma studio",
"db:push": "prisma db push",
"db:seed": "prisma db seed"
},
"prisma": {
"seed": "ts-node --esm prisma/seed.ts"
},
"devDependencies": {
"@playwright/test": "^1.33.0",
"@playwright/test": "^1.35.1",
"@rgossiaux/svelte-headlessui": "1.0.2",
"@rgossiaux/svelte-heroicons": "^0.1.2",
"@sveltejs/adapter-auto": "^1.0.3",
"@sveltejs/adapter-vercel": "^1.0.6",
"@sveltejs/kit": "^1.16.3",
"@sveltejs/kit": "^1.20.2",
"@types/cookie": "^0.5.1",
"@types/node": "^18.16.9",
"@typescript-eslint/eslint-plugin": "^5.59.5",
"@typescript-eslint/parser": "^5.59.5",
"@types/node": "^18.16.18",
"@typescript-eslint/eslint-plugin": "^5.59.11",
"@typescript-eslint/parser": "^5.59.11",
"autoprefixer": "^10.4.14",
"eslint": "^8.40.0",
"eslint": "^8.42.0",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-svelte": "^2.28.0",
"eslint-plugin-svelte": "^2.30.0",
"just-clone": "^6.2.0",
"just-debounce-it": "^3.2.0",
"postcss": "^8.4.23",
"postcss-color-functional-notation": "^4.2.4",
"postcss-custom-media": "^9.1.3",
"postcss-env-function": "^4.0.6",
"postcss": "^8.4.24",
"postcss-import": "^15.1.0",
"postcss-load-config": "^4.0.1",
"postcss-media-minmax": "^5.0.0",
"postcss-nested": "^6.0.1",
"postcss-preset-env": "^8.5.0",
"prettier": "^2.8.8",
"prettier-plugin-svelte": "^2.10.0",
"sass": "^1.62.1",
"prettier-plugin-svelte": "^2.10.1",
"prisma": "^4.15.0",
"sass": "^1.63.4",
"svelte": "^3.59.1",
"svelte-check": "^2.10.3",
"svelte-preprocess": "^4.10.7",
"sveltekit-superforms": "^0.8.6",
"tslib": "^2.5.0",
"svelte-preprocess": "^5.0.4",
"sveltekit-superforms": "^1.0.0",
"ts-node": "^10.9.1",
"tslib": "^2.5.3",
"typescript": "^4.9.5",
"vite": "^4.3.5",
"vite": "^4.3.9",
"vitest": "^0.25.3",
"zod": "^3.21.4"
},
"type": "module",
"engines": {
"node": ">=18.12.1",
"pnpm": ">=8"
},
"dependencies": {
"@axiomhq/axiom-node": "^0.12.0",
"@fontsource/fira-mono": "^4.5.10",
"@iconify-icons/line-md": "^1.2.22",
"@iconify-icons/mdi": "^1.2.45",
"@iconify-icons/line-md": "^1.2.23",
"@iconify-icons/mdi": "^1.2.46",
"@leveluptuts/svelte-side-menu": "^1.0.5",
"@leveluptuts/svelte-toy": "^2.0.3",
"@lucia-auth/adapter-mysql": "^1.1.1",
"@lucia-auth/adapter-prisma": "^2.0.0",
"@lukeed/uuid": "^2.0.1",
"@prisma/client": "4.15.0",
"@types/feather-icons": "^4.29.1",
"cookie": "^0.5.0",
"feather-icons": "^4.29.0",
"iconify-icon": "^1.0.7",
"loader": "^2.1.1",
"open-props": "^1.5.8",
"lucia-auth": "^1.8.0",
"open-props": "^1.5.9",
"svelte-lazy": "^1.2.1",
"svelte-lazy-loader": "^1.0.0",
"svelte-legos": "^0.2.1",
"sveltekit-flash-message": "^0.11.3",
"zod-to-json-schema": "^3.21.1"
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,19 +1,16 @@
const autoprefixer = require('autoprefixer');
const postcssMediaMinmax = require('postcss-media-minmax');
const customMedia = require('postcss-custom-media');
const postcssPresetEnv = require('postcss-preset-env');
const atImport = require('postcss-import');
const postcssNested = require('postcss-nested');
const postcssEnvFunction = require('postcss-env-function');
const config = {
plugins: [
autoprefixer(),
postcssMediaMinmax,
customMedia,
atImport(),
postcssNested,
postcssEnvFunction({
importFrom: './src/lib/util/environmentVariables.json'
postcssPresetEnv({
stage: 2,
features: {
'nesting-rules': true,
'custom-media-queries': true,
'media-query-ranges': true,
}
})
]
};

238
prisma/schema.prisma Normal file
View file

@ -0,0 +1,238 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
relationMode = "prisma"
}
model Role {
id String @id @default(cuid())
name String @unique
userRoles UserRole[]
}
model UserRole {
id String @id @default(cuid())
user AuthUser @relation(fields: [userId], references: [id])
userId String
role Role @relation(fields: [roleId], references: [id])
roleId String
@@unique([userId, roleId])
@@index([userId])
@@index([roleId])
}
model AuthUser {
id String @id @default(cuid())
username String @unique
email String? @unique
firstName String?
lastName String?
roles UserRole[]
verified Boolean @default(false)
receiveEmail Boolean @default(false)
token String? @unique
collection Collection?
wishlist Wishlist[]
theme String @default("system")
createdAt DateTime @default(now()) @db.Timestamp(6)
updatedAt DateTime @updatedAt @db.Timestamp(6)
auth_session AuthSession[]
auth_key AuthKey[]
@@map("auth_user")
}
model AuthSession {
id String @id @unique
user_id String
active_expires BigInt
idle_expires BigInt
auth_user AuthUser @relation(references: [id], fields: [user_id], onDelete: Cascade)
@@index([user_id])
@@map("auth_session")
}
model AuthKey {
id String @id @unique
hashed_password String?
user_id String
primary_key Boolean
expires BigInt?
auth_user AuthUser @relation(references: [id], fields: [user_id], onDelete: Cascade)
@@index([user_id])
@@map("auth_key")
}
model Collection {
id String @id @default(cuid())
user_id String @unique
auth_user AuthUser @relation(references: [id], fields: [user_id])
items CollectionItem[]
@@index([user_id])
@@map("collections")
}
model CollectionItem {
id String @id @default(cuid())
collection_id String
collection Collection @relation(references: [id], fields: [collection_id])
game_id String
game Game @relation(references: [id], fields: [game_id])
times_played Int
@@index([game_id, collection_id])
@@map("collection_items")
}
model Wishlist {
id String @id @default(cuid())
name String
user_id String
auth_user AuthUser @relation(references: [id], fields: [user_id])
items WishlistItem[]
@@index([user_id])
@@map("wishlists")
}
model WishlistItem {
id String @id @default(cuid())
wishlist_id String
wishlist Wishlist @relation(references: [id], fields: [wishlist_id])
game_id String
game Game @relation(references: [id], fields: [game_id])
@@index([game_id, wishlist_id])
@@map("wishlist_items")
}
model Game {
id String @id @default(cuid())
name String
description String?
yearPublished Int?
minPlayers Int?
maxPlayers Int?
minPlaytime Int?
maxPlaytime Int?
minAge Int?
imageUrl String?
thumbUrl String?
url String?
rulesUrl String?
weightAmount Float?
weightUnits String?
bggId String?
bggUrl String?
primary_publisher_id String
primaryPublisher Publisher? @relation("PrimaryPublishers", references: [id], fields: [primary_publisher_id])
categories Category[]
mechanics Mechanic[]
designers Designer[]
publishers Publisher[]
artists Artist[]
names GameName[]
expansions Expansion[]
collection_items CollectionItem[]
wishlist_items WishlistItem[]
external_id String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([primary_publisher_id])
@@map("games")
}
model GameName {
id String @id @default(cuid())
name String
game_id String
game Game @relation(references: [id], fields: [game_id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([game_id])
@@map("game_names")
}
model Publisher {
id String @id @default(cuid())
name String
games Game[]
primaryPublisher Game[] @relation("PrimaryPublishers")
external_id String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("publishers")
}
model Category {
id String @id @default(cuid())
name String
slug String
games Game[]
external_id String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("categories")
}
model Mechanic {
id String @id @default(cuid())
name String
games Game[]
external_id String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("mechanics")
}
model Designer {
id String @id @default(cuid())
name String
games Game[]
external_id String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("designers")
}
model Artist {
id String @id @default(cuid())
name String
games Game[]
external_id String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("artists")
}
model Expansion {
id String @id @default(cuid())
name String
yearPublished Int?
baseGame Game? @relation(fields: [base_game_id], references: [id])
base_game_id String?
external_id String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([base_game_id])
@@map("expansions")
}

40
prisma/seed.ts Normal file
View file

@ -0,0 +1,40 @@
import { PrismaClient } from '@prisma/client';
// import userData from '../src/lib/data.json' assert { type: 'json' };
const prisma = new PrismaClient();
async function main() {
console.log(`Start seeding ...`);
const existingRoles = await prisma.role.findMany();
if (existingRoles.length === 0) {
await prisma.role.createMany({
data: [{ name: 'admin' }, { name: 'user' }]
});
console.log('Roles created.');
} else {
console.log('Roles already exist. No action taken.');
}
// for (const p of userData) {
// const user = await prisma.user.create({
// data: {
// firstName: p.user.firstName,
// lastName: p.user.lastName,
// email: p.user.email,
// username: p.user.username
// }
// });
// console.log(`Created user with id: ${user.id}`);
// }
console.log(`Seeding finished.`);
}
main()
.catch(async (e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View file

@ -1,109 +0,0 @@
@import '@fontsource/fira-mono';
:root {
font-family: Arial, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu,
Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
--font-mono: 'Fira Mono', monospace;
--pure-white: #ffffff;
--primary-color: #b9c6d2;
--secondary-color: #d0dde9;
--tertiary-color: #edf0f8;
--accent-color: #ff3e00;
--heading-color: rgba(0, 0, 0, 0.7);
--text-color: #444444;
--background-without-opacity: rgba(255, 255, 255, 0.7);
--column-width: 42rem;
--column-margin-top: 4rem;
--z-highest: 100;
--cardBorderRadius: 12px;
}
body {
min-height: 100vh;
margin: 0;
background-color: var(--primary-color);
background: linear-gradient(
180deg,
var(--primary-color) 0%,
var(--secondary-color) 10.45%,
var(--tertiary-color) 41.35%
);
}
body::before {
content: '';
width: 80vw;
height: 100vh;
position: absolute;
top: 0;
left: 10vw;
z-index: -1;
background: radial-gradient(
50% 50% at 50% 50%,
var(--pure-white) 0%,
rgba(255, 255, 255, 0) 100%
);
opacity: 0.05;
}
#svelte {
min-height: 100vh;
display: flex;
flex-direction: column;
}
h1,
h2,
p {
font-weight: 400;
color: var(--heading-color);
}
p {
line-height: 1.5;
}
a {
color: var(--accent-color);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
h1 {
font-size: 2rem;
text-align: center;
}
h2 {
font-size: 1rem;
}
pre {
font-size: 16px;
font-family: var(--font-mono);
background-color: rgba(255, 255, 255, 0.45);
border-radius: 3px;
box-shadow: 2px 2px 6px rgb(255 255 255 / 25%);
padding: 0.5em;
overflow-x: auto;
color: var(--text-color);
}
input,
button {
font-size: inherit;
font-family: inherit;
}
button:focus:not(:focus-visible) {
outline: none;
}
@media (min-width: 720px) {
h1 {
font-size: 2.4rem;
}
}

54
src/app.d.ts vendored
View file

@ -1,14 +1,50 @@
// See https://kit.svelte.dev/docs/types#app
// for information about these interfaces
// and what to do when importing types
declare namespace App {
interface Locals {
userid: string;
// src/app.d.ts
declare global {
namespace App {
interface PageData {
flash?: { type: 'success' | 'error'; message: string };
}
interface Locals {
auth: import('lucia-auth').AuthRequest;
prisma: PrismaClient;
user: Lucia.UserAttributes;
startTimer: number;
error: string;
errorId: string;
errorStackTrace: string;
message: unknown;
track: unknown;
}
interface Error {
code?: string;
errorId?: string;
}
}
// interface PageData {}
// interface Error {}
// interface Platform {}
}
// interface PageData {}
// interface Error {}
// interface Platform {}
/// <reference types="lucia-auth" />
declare global {
namespace Lucia {
type Auth = import('$lib/lucia').Auth;
type UserAttributes = {
email: string;
username: string;
firstName: string;
lastName: string;
role: string;
verified: boolean;
receiveEmail: boolean;
token: string;
};
}
}
// THIS IS IMPORTANT!!!
export {};

111
src/app.postcss Normal file
View file

@ -0,0 +1,111 @@
/* Write your global styles here, in PostCSS syntax */
@import '@fontsource/fira-mono';
:root {
font-family: Arial, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu,
Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
--font-mono: 'Fira Mono', monospace;
--pure-white: #ffffff;
--primary-color: #b9c6d2;
--secondary-color: #d0dde9;
--tertiary-color: #edf0f8;
--accent-color: #ff3e00;
--heading-color: rgba(0, 0, 0, 0.7);
--text-color: #444444;
--background-without-opacity: rgba(255, 255, 255, 0.7);
--column-width: 42rem;
--column-margin-top: 4rem;
--z-highest: 100;
--cardBorderRadius: 12px;
}
body {
min-height: 100vh;
margin: 0;
background-color: var(--primary-color);
background: linear-gradient(
180deg,
var(--primary-color) 0%,
var(--secondary-color) 10.45%,
var(--tertiary-color) 41.35%
);
}
body::before {
content: '';
width: 80vw;
height: 100vh;
position: absolute;
top: 0;
left: 10vw;
z-index: -1;
background: radial-gradient(
50% 50% at 50% 50%,
var(--pure-white) 0%,
rgba(255, 255, 255, 0) 100%
);
opacity: 0.05;
}
#svelte {
min-height: 100vh;
display: flex;
flex-direction: column;
}
h1,
h2,
p {
font-weight: 400;
color: var(--heading-color);
}
p {
line-height: 1.5;
}
a {
color: var(--accent-color);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
h1 {
font-size: 2rem;
text-align: center;
}
h2 {
font-size: 1rem;
}
pre {
font-size: 16px;
font-family: var(--font-mono);
background-color: rgba(255, 255, 255, 0.45);
border-radius: 3px;
box-shadow: 2px 2px 6px rgb(255 255 255 / 25%);
padding: 0.5em;
overflow-x: auto;
color: var(--text-color);
}
input,
button {
font-size: inherit;
font-family: inherit;
}
button:focus:not(:focus-visible) {
outline: none;
}
@media (min-width: 720px) {
h1 {
font-size: 2.4rem;
}
}

32
src/db/roles.ts Normal file
View file

@ -0,0 +1,32 @@
import prisma from '$lib/prisma';
export async function add_user_to_role(userId: string, roleName: string) {
// Find the role by its name
const role = await prisma.role.findUnique({
where: {
name: roleName
}
});
if (!role) {
throw new Error(`Role with name ${roleName} not found`);
}
// Create a UserRole entry linking the user and the role
const userRole = await prisma.userRole.create({
data: {
user: {
connect: {
id: userId
}
},
role: {
connect: {
id: role.id
}
}
}
});
return userRole;
}

56
src/db/users.ts Normal file
View file

@ -0,0 +1,56 @@
import { auth } from '$lib/server/lucia';
import prisma from '$lib/prisma';
import type { AuthUser } from '@prisma/client';
import { add_user_to_role } from './roles';
export function create_user(user: AuthUser) {
return prisma.authUser.create({
data: {
username: user.username
}
});
}
export async function find_or_create_user(user: AuthUser) {
const existing_user = await prisma.authUser.findUnique({
where: {
username: user.username
}
});
if (existing_user) {
return existing_user;
} else {
const new_user = await create_user(user);
add_user_to_role(new_user.id, 'user');
return new_user;
}
}
export async function find_user_with_roles(user_id: string) {
const user_with_roles = await prisma.authUser.findUnique({
where: {
id: user_id
},
include: {
roles: {
select: {
role: {
select: {
name: true
}
}
}
}
}
});
if (!user_with_roles) {
throw new Error('User not found');
}
const user = {
...user_with_roles,
roles: user_with_roles.roles.map((user_role) => user_role.role.name)
};
return user;
}

View file

@ -1,16 +0,0 @@
import type { Handle } from '@sveltejs/kit';
export const handle: Handle = async ({ event, resolve }) => {
let userid = event.cookies.get('userid');
if (!userid) {
// if this is the first time the user has visited this app,
// set a cookie so that we recognise them when they return
userid = crypto.randomUUID();
event.cookies.set('userid', userid, { path: '/' });
}
event.locals.userid = userid;
return resolve(event);
};

52
src/hooks.server.ts Normal file
View file

@ -0,0 +1,52 @@
import { sequence } from '@sveltejs/kit/hooks';
import { redirect, type HandleServerError, type Handle } from '@sveltejs/kit';
import { dev } from '$app/environment';
import { auth } from '$lib/server/lucia';
import log from '$lib/server/log';
import prisma from '$lib/config/prisma';
export const handleError: HandleServerError = async ({ error, event }) => {
const errorId = crypto.randomUUID();
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
event.locals.error = error?.toString() || undefined;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
event.locals.errorStackTrace = error?.stack || undefined;
event.locals.errorId = errorId;
if (!dev) {
log(500, event);
}
return {
message: 'An unexpected error occurred.',
errorId
};
};
// export const prismaClient: Handle = async function ({ event, resolve }) {
// event.locals.prisma = prisma;
// const response = await resolve(event);
// return response;
// };
export const authentication: Handle = async function ({ event, resolve }) {
const startTimer = Date.now();
event.locals.startTimer = startTimer;
event.locals.auth = auth.handleRequest(event);
if (event.locals?.auth) {
const { user } = await event.locals.auth.validateUser();
event.locals.user = user;
// if (event.route.id?.startsWith('/(protected)')) {
// if (!user) throw redirect(302, '/auth/sign-in');
// if (!user.verified) throw redirect(302, '/auth/verify/email');
// }
}
const response = await resolve(event);
return response;
};
export const handle = sequence(authentication);

View file

@ -23,7 +23,7 @@
text-align: start;
background-color: var(--color-btn-primary-active);
@media (min-width: env(--large-viewport)) {
@media (min-width: 1000px) {
min-width: 23.5rem;
}
}

View file

@ -6,8 +6,8 @@
DialogOverlay,
DialogTitle
} from '@rgossiaux/svelte-headlessui';
import { boredState } from '$root/lib/stores/boredState';
import { collectionStore } from '$root/lib/stores/collectionStore';
import { boredState } from '$lib/stores/boredState';
import { collectionStore } from '$lib/stores/collectionStore';
import { browser } from '$app/environment';
function clearCollection() {

View file

@ -1,7 +1,7 @@
<script lang="ts">
import { browser } from '$app/environment';
import { boredState } from '$root/lib/stores/boredState';
import { wishlistStore } from '$root/lib/stores/wishlistStore';
import { boredState } from '$lib/stores/boredState';
import { wishlistStore } from '$lib/stores/wishlistStore';
import DefaultDialog from './DefaultDialog.svelte';
function clearWishlist() {
@ -48,7 +48,7 @@
gap: 2rem;
margin: 1rem 0;
button {
& button {
display: flex;
place-content: center;
gap: 1rem;

View file

@ -7,7 +7,7 @@
DialogOverlay,
DialogTitle
} from '@rgossiaux/svelte-headlessui';
import { boredState } from '$root/lib/stores/boredState';
import { boredState } from '$lib/stores/boredState';
export let title: string;
export let description: string;

View file

@ -6,9 +6,9 @@
DialogOverlay,
DialogTitle
} from '@rgossiaux/svelte-headlessui';
import { boredState } from '$root/lib/stores/boredState';
import { collectionStore } from '$root/lib/stores/collectionStore';
import { removeFromCollection } from '$root/lib/util/manipulateCollection';
import { boredState } from '$lib/stores/boredState';
import { collectionStore } from '$lib/stores/collectionStore';
import { removeFromCollection } from '$lib/util/manipulateCollection';
import { browser } from '$app/environment';
function removeGame() {

View file

@ -6,9 +6,9 @@
DialogOverlay,
DialogTitle
} from '@rgossiaux/svelte-headlessui';
import { boredState } from '$root/lib/stores/boredState';
import { wishlistStore } from '$root/lib/stores/wishlistStore';
import { removeFromWishlist } from '$root/lib/util/manipulateWishlist';
import { boredState } from '$lib/stores/boredState';
import { wishlistStore } from '$lib/stores/wishlistStore';
import { removeFromWishlist } from '$lib/util/manipulateWishlist';
import { browser } from '$app/environment';
function removeGame() {

View file

@ -7,12 +7,12 @@
import Button from '$lib/components/button/index.svelte';
import type { GameType, SavedGameType } from '$lib/types';
import { collectionStore } from '$lib/stores/collectionStore';
import { wishlistStore } from '$root/lib/stores/wishlistStore';
import { wishlistStore } from '$lib/stores/wishlistStore';
import { addToCollection, removeFromCollection } from '$lib/util/manipulateCollection';
import { addToWishlist } from '$lib/util/manipulateWishlist';
import { browser } from '$app/environment';
import { binarySearchOnStore } from '$root/lib/util/binarySearchOnStore';
import { convertToSavedGame } from '$root/lib/util/gameMapper';
import { binarySearchOnStore } from '$lib/util/binarySearchOnStore';
import { convertToSavedGame } from '$lib/util/gameMapper';
export let game: GameType | SavedGameType;
export let detailed: boolean = false;

View file

@ -1,6 +1,10 @@
<script lang="ts">
import Profile from '../preferences/profile.svelte';
import { enhance } from '$app/forms';
// import Profile from '../preferences/profile.svelte';
import logo from './bored-game.png';
export let user: any;
console.log('User', user);
</script>
<header>
@ -11,13 +15,32 @@
</div>
<!-- <TextSearch /> -->
<nav>
<a href="/collection" title="Go to your collection" data-sveltekit-preload-data>Collection</a>
<a href="/wishlist" title="Go to your wishlist" data-sveltekit-preload-data>Wishlist</a>
<Profile />
{#if user}
<a href="/collection" title="Go to your collection" data-sveltekit-preload-data>Collection</a>
<a href="/wishlist" title="Go to your wishlist" data-sveltekit-preload-data>Wishlist</a>
<form
use:enhance
action="/auth/signout"
method="POST"
>
<button type="submit" class="btn"
><span>Sign out</span></button
>
</form>
{/if}
{#if !user}
<a href="/auth/signin">
<span class="flex-auto">Sign In</span></a
>
<a href="/auth/signup">
<span class="flex-auto">Sign Up</span></a
>
{/if}
<!-- <Profile /> -->
</nav>
</header>
<style lang="scss">
<style lang="postcss">
header {
display: flex;
justify-content: space-between;
@ -65,7 +88,6 @@
padding: 0 1em;
color: var(--heading-color);
font-weight: 700;
/* font-size: 0.8rem; */
text-transform: uppercase;
letter-spacing: 0.1em;
text-decoration: none;

View file

@ -1,8 +1,8 @@
<script lang="ts">
import { browser } from '$app/environment';
import { boredState } from '$root/lib/stores/boredState';
import { collectionStore } from '$root/lib/stores/collectionStore';
import { ToastType } from '$root/lib/types';
import { boredState } from '$lib/stores/boredState';
import { collectionStore } from '$lib/stores/collectionStore';
import { ToastType } from '$lib/types';
import { SaveIcon, ShareIcon, TrashIcon } from '@rgossiaux/svelte-heroicons/outline';
import ClearCollectionDialog from '../dialog/ClearCollectionDialog.svelte';
import { toast } from '../toast/toast';

View file

@ -1,8 +1,8 @@
<script lang="ts">
import { browser } from '$app/environment';
import { boredState } from '$root/lib/stores/boredState';
import { wishlistStore } from '$root/lib/stores/wishlistStore';
import { ToastType } from '$root/lib/types';
import { boredState } from '$lib/stores/boredState';
import { wishlistStore } from '$lib/stores/wishlistStore';
import { ToastType } from '$lib/types';
import { SaveIcon, ShareIcon, TrashIcon } from '@rgossiaux/svelte-heroicons/outline';
import ClearWishlistDialog from '../dialog/ClearWishlistDialog.svelte';
import { toast } from '../toast/toast';

View file

@ -4,7 +4,7 @@
import { collectionStore } from '$lib/stores/collectionStore';
import { toast } from '$lib/components/toast/toast';
import { ToastType, type SavedGameType } from '$lib/types';
import { mapSavedGameToGame } from '$root/lib/util/gameMapper';
import { mapSavedGameToGame } from '$lib/util/gameMapper';
async function getRandomCollectionGame() {
if ($collectionStore.length > 0) {

View file

@ -1,46 +1,46 @@
<script lang="ts">
import { applyAction, enhance } from '$app/forms';
import type { SuperValidated } from 'sveltekit-superforms/index';
import type { SearchSchema } from '$lib/zodValidation';
import { boredState } from '$lib/stores/boredState';
import { gameStore } from '$lib/stores/gameSearchStore';
import { ToastType } from '$root/lib/types';
import { ToastType } from '$lib/types';
import { superForm } from 'sveltekit-superforms/client';
import { toast } from '../../toast/toast';
export let data: SuperValidated<SearchSchema>;
const { enhance } = superForm(data, {
onSubmit: () => {
gameStore.removeAll();
boredState.update((n) => ({ ...n, loading: true }));
},
onResult: ({ result, formEl, cancel }) => {
boredState.update((n) => ({ ...n, loading: false }));
if (result.type === 'success') {
gameStore.addAll(result?.data?.searchData?.games);
} else {
cancel();
}
},
// onUpdated: ({ form }) => {
// if ($gameStore.length <= 0) {
// toast.send('No results found 😿', {
// duration: 3000,
// type: ToastType.ERROR,
// dismissible: true
// });
// }
// }
});
let submitting = $boredState?.loading;
let checked = true;
</script>
<form
action="/search?/random"
method="POST"
use:enhance={() => {
gameStore.removeAll();
boredState.update((n) => ({ ...n, loading: true }));
return async ({ result }) => {
console.log('result', result);
boredState.update((n) => ({ ...n, loading: false }));
// `result` is an `ActionResult` object
if (result.type === 'success') {
// console.log('In success');
const resultGames = result?.data?.games;
if (resultGames?.length <= 0) {
toast.send('No results found 😿', {
duration: 3000,
type: ToastType.ERROR,
dismissible: true
});
}
gameStore.addAll(resultGames);
// console.log(`Frontend result random: ${JSON.stringify(result)}`);
await applyAction(result);
} else {
// console.log('Invalid');
await applyAction(result);
}
};
}}
use:enhance
>
<fieldset aria-busy={submitting} disabled={submitting}>
<!-- <input type="checkbox" id="random" name="random" hidden {checked} /> -->
<button class="btn" type="submit" disabled={submitting}>Random Game 🎲</button>
</fieldset>
</form>

View file

@ -1,29 +1,35 @@
<script lang="ts">
import { tick } from 'svelte';
import { applyAction, enhance, type SubmitFunction } from '$app/forms';
import SuperDebug from 'sveltekit-superforms/client/SuperDebug.svelte';
import { fade } from 'svelte/transition';
import { applyAction, type SubmitFunction } from '$app/forms';
import { superForm } from 'sveltekit-superforms/client';
import SuperDebug from 'sveltekit-superforms/client/SuperDebug.svelte';
import type { SuperValidated } from 'sveltekit-superforms/index';
import { Disclosure, DisclosureButton, DisclosurePanel } from '@rgossiaux/svelte-headlessui';
import { ChevronRightIcon } from '@rgossiaux/svelte-heroicons/solid';
import { boredState } from '$lib/stores/boredState';
import AdvancedSearch from '$lib/components/search/advancedSearch/index.svelte';
import { xl, md, sm } from '$lib/stores/mediaQueryStore';
import { gameStore } from '$root/lib/stores/gameSearchStore';
import { gameStore } from '$lib/stores/gameSearchStore';
import { toast } from '../../toast/toast';
import Pagination from '$lib/components/pagination/index.svelte';
import Game from '$lib/components/game/index.svelte';
import { ToastType, type GameType, type SavedGameType } from '$root/lib/types';
import SkeletonPlaceholder from '../../SkeletonPlaceholder.svelte';
import { ToastType, type GameType, type SavedGameType } from '$lib/types';
// import SkeletonPlaceholder from '../../SkeletonPlaceholder.svelte';
import RemoveCollectionDialog from '../../dialog/RemoveCollectionDialog.svelte';
import RemoveWishlistDialog from '../../dialog/RemoveWishlistDialog.svelte';
import type { SearchSchema } from '$lib/zodValidation';
interface RemoveGameEvent extends Event {
detail: GameType | SavedGameType;
}
export let form;
export let errors;
export let constraints;
export let data: SuperValidated<SearchSchema>;
const { form, constraints, errors } = superForm(data, {
onSubmit: () => {
boredState.update((n) => ({ ...n, loading: true }));
},
});
export let showButton: boolean = false;
export let advancedSearch: boolean = false;
@ -139,22 +145,23 @@
<SuperDebug data={$form} />
{/if}
<form id="search-form" action="/search" method="GET" on:submit={() => {
skip = 0;
}}>
<form id="search-form" action="/search" method="GET">
<div class="search">
<fieldset class="text-search" aria-busy={submitting} disabled={submitting}>
<label for="q">Search</label>
<input
id="q"
name="q"
bind:value={$form.q}
data-invalid={$errors?.q}
{...$constraints.q}
type="text"
aria-label="Search board games"
placeholder="Search board games"
<label class="label" for="q">
<span>Search</span>
<input
id="q"
class="input"
name="q"
bind:value={$form.q}
data-invalid={$errors?.q}
{...$constraints.q}
type="search"
aria-label="Search board games"
placeholder="Search board games"
/>
</label>
{#if $errors?.q}<span class="invalid">{$errors?.q}</span>{/if}
<input id="skip" type="hidden" name="skip" bind:value={$form.skip} />
@ -206,11 +213,11 @@
<div class="games">
<h1>Games Found:</h1>
<div class="games-list">
{#each placeholderList as game, i}
<!-- {#each placeholderList as game, i}
<SkeletonPlaceholder
style="width: 100%; height: 500px; border-radius: var(--borderRadius);"
/>
{/each}
{/each} -->
</div>
</div>
{:else}
@ -278,7 +285,7 @@
.games {
margin: 2rem 0rem;
h1 {
& h1 {
margin-bottom: 2rem;
}
}
@ -289,15 +296,19 @@
grid-template-columns: repeat(var(--listColumns), minmax(250px, 1fr));
gap: 2rem;
@media screen and (env(--large-viewport) < width <= env(--xxlarge-viewport)) {
@media (width >= 1500px) {
--listColumns: 3;
}
@media screen and (env(--small-viewport) < width <= env(--large-viewport)) {
@media (1000px < width <= 1500px) {
--listColumns: 3;
}
@media (600px < width <= 1000px) {
--listColumns: 2;
}
@media screen and (width <= env(--small-viewport)) {
@media (width <= 600px) {
--listColumns: 1;
}
}

View file

@ -0,0 +1,82 @@
<script lang="ts">
import { ConicGradient } from '@skeletonlabs/skeleton';
import type { ConicStop } from '@skeletonlabs/skeleton';
import { superForm } from 'sveltekit-superforms/client';
//import SuperDebug from 'sveltekit-superforms/client/SuperDebug.svelte';
import { userSchema } from '$lib/config/zod-schemas';
import { AlertTriangle } from 'lucide-svelte';
import { i } from "@inlang/sdk-js";
export let data;
const signInSchema = userSchema.pick({ email: true, password: true });
const { form, errors, enhance, delayed } = superForm(data.form, {
taintedMessage: null,
validators: signInSchema,
delayMs: 0
});
const conicStops: ConicStop[] = [
{ color: 'transparent', start: 0, end: 25 },
{ color: 'rgb(var(--color-primary-900))', start: 75, end: 100 }
];
</script>
<form method="POST" action="/auth/sign-in" use:enhance>
<!--<SuperDebug data={$form} />-->
{#if $errors._errors}
<aside class="alert variant-filled-error mt-6">
<!-- Icon -->
<div><AlertTriangle size="42" /></div>
<!-- Message -->
<div class="alert-message">
<h3 class="h3">{i("signinProblem")}</h3>
<p>{$errors._errors}</p>
</div>
</aside>
{/if}
<div class="mt-6">
<label class="label">
<span class="sr-only">{i("email")}</span>
<input
id="email"
name="email"
type="email"
placeholder="{i("email")}"
autocomplete="email"
data-invalid={$errors.email}
bind:value={$form.email}
class="input"
class:input-error={$errors.email}
/>
{#if $errors.email}
<small>{$errors.email}</small>
{/if}
</label>
</div>
<div class="mt-6">
<label class="label">
<span class="sr-only">{i("password")}</span>
<input
id="password"
name="password"
type="password"
placeholder="{i("password")}"
data-invalid={$errors.password}
bind:value={$form.password}
class="input"
class:input-error={$errors.password}
/>
{#if $errors.password}
<small>{$errors.password}</small>
{/if}
</label>
</div>
<div class="mt-6">
<button type="submit" class="btn variant-filled-primary w-full"
>{#if $delayed}<ConicGradient stops={conicStops} spin width="w-6" />{:else}{i("signin")}{/if}</button
>
</div>
<div class="flex flex-row justify-center items-center mt-10">
<a href="/auth/password/reset" class="font-semibold">{i("forgotPassword")}</a>
</div>
</form>

View file

@ -0,0 +1,112 @@
<script lang="ts">
import { superForm } from 'sveltekit-superforms/client';
import { userSchema } from '$lib/config/zod-schemas';
export let data;
const signUpSchema = userSchema.pick({
firstName: true,
lastName: true,
username: true,
email: true,
password: true
});
const { form, errors, enhance, delayed } = superForm(data.form, {
taintedMessage: null,
validators: signUpSchema,
delayMs: 0
});
// $: termsValue = $form.terms as Writable<boolean>;
</script>
<form method="POST" action="/auth/signup" use:enhance>
<h1>Signup user</h1>
<label class="label">
<span class="sr-only">First Name</span>
<input
id="firstName"
name="firstName"
type="text"
placeholder="First Name"
autocomplete="given-name"
data-invalid={$errors.firstName}
bind:value={$form.firstName}
class="input"
class:input-error={$errors.firstName}
/>
{#if $errors.firstName}
<small>{$errors.firstName}</small>
{/if}
</label>
<label class="label">
<span class="sr-only">Last Name</span>
<input
id="lastName"
name="lastName"
type="text"
placeholder="Last Name"
autocomplete="family-name"
data-invalid={$errors.lastName}
bind:value={$form.lastName}
class="input"
class:input-error={$errors.lastName}
/>
{#if $errors.lastName}
<small>{$errors.lastName}</small>
{/if}
</label>
<label class="label">
<span class="sr-only">Email</span>
<input
id="email"
name="email"
type="email"
placeholder="Email"
autocomplete="email"
data-invalid={$errors.email}
bind:value={$form.email}
class="input"
class:input-error={$errors.email}
/>
{#if $errors.email}
<small>{$errors.email}</small>
{/if}
</label>
<label class="label">
<span class="sr-only">Username</span>
<input
id="username"
name="username"
type="username"
placeholder="Username"
autocomplete="uername"
data-invalid={$errors.username}
bind:value={$form.username}
class="input"
class:input-error={$errors.username}
/>
{#if $errors.username}
<small>{$errors.username}</small>
{/if}
</label>
<label class="label">
<span class="sr-only">password</span>
<input
id="password"
name="password"
type="password"
placeholder="password"
data-invalid={$errors.password}
bind:value={$form.password}
class="input"
class:input-error={$errors.password}
/>
{#if $errors.password}
<small>{$errors.password}</small>
{/if}
</label>
<button type="submit">Signup</button>
<a class="back" href="/"> or Cancel </a>
</form>

View file

@ -0,0 +1,4 @@
import { dev } from '$app/environment';
export const BASE_URL = dev ? 'http://localhost:5173' : 'https://boredgame.vercel.app';
export const APP_NAME = 'Bored Game';
export const DOMAIN = 'boredgame.vercel.app';

5
src/lib/config/prisma.ts Normal file
View file

@ -0,0 +1,5 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export default prisma;

View file

@ -0,0 +1,44 @@
import { z } from 'zod';
export const userSchema = z.object({
firstName: z.string().trim().optional(),
lastName: z.string().trim().optional(),
email: z.string().email({ message: 'Please enter a valid email address' }).optional(),
username: z
.string()
.trim()
.min(3, { message: 'Username must be at least 3 characters' })
.max(50, { message: 'Username must be less than 50 characters' }),
password: z
.string({ required_error: 'Password is required' })
.trim()
.min(8, { message: 'Password must be at least 8 characters' })
.max(128, { message: 'Password must be less than 128 characters' }),
confirmPassword: z
.string({ required_error: 'Confirm Password is required' })
.trim()
.min(8, { message: 'Confirm Password must be at least 8 characters' }),
role: z.enum(['USER', 'ADMIN'], { required_error: 'You must have a role' }).default('USER'),
verified: z.boolean().default(false),
token: z.string().optional(),
receiveEmail: z.boolean().default(false),
createdAt: z.date().optional(),
updatedAt: z.date().optional()
});
export const updateUserPasswordSchema = userSchema
.pick({ password: true, confirmPassword: true })
.superRefine(({ confirmPassword, password }, ctx) => {
if (confirmPassword !== password) {
ctx.addIssue({
code: 'custom',
message: 'Password and Confirm Password must match',
path: ['password']
});
ctx.addIssue({
code: 'custom',
message: 'Password and Confirm Password must match',
path: ['confirmPassword']
});
}
});

18
src/lib/data.json Normal file
View file

@ -0,0 +1,18 @@
[
{
"user": {
"firstName": "John",
"lastName": "Doe",
"email": "johndoe@example.com",
"username": "johndoe"
}
},
{
"user": {
"firstName": "Jane",
"lastName": "Doe",
"email": "janedoe@example.com",
"username": "janedoe"
}
}
]

4
src/lib/prisma.ts Normal file
View file

@ -0,0 +1,4 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export default prisma;

70
src/lib/server/log.ts Normal file
View file

@ -0,0 +1,70 @@
import { Client } from '@axiomhq/axiom-node';
import { AXIOM_TOKEN, AXIOM_ORG_ID, AXIOM_DATASET } from '$env/static/private';
import getAllUrlParams from '$lib/util/getAllUrlParams';
import parseTrack from '$lib/util/parseTrack';
import parseMessage from '$lib/util/parseMessage';
import { DOMAIN } from '$lib/config/constants';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
export default async function log(statusCode: number, event) {
try {
let level = 'info';
if (statusCode >= 400) {
level = 'error';
}
const error = event?.locals?.error || undefined;
const errorId = event?.locals?.errorId || undefined;
const errorStackTrace = event?.locals?.errorStackTrace || undefined;
let urlParams = {};
if (event?.url?.search) {
urlParams = await getAllUrlParams(event?.url?.search);
}
let messageEvents = {};
if (event?.locals?.message) {
messageEvents = await parseMessage(event?.locals?.message);
}
let trackEvents = {};
if (event?.locals?.track) {
trackEvents = await parseTrack(event?.locals?.track);
}
let referer = event.request.headers.get('referer');
if (referer) {
const refererUrl = await new URL(referer);
const refererHostname = refererUrl.hostname;
if (refererHostname === 'localhost' || refererHostname === DOMAIN) {
referer = refererUrl.pathname;
}
} else {
referer = undefined;
}
const logData: object = {
level: level,
method: event.request.method,
path: event.url.pathname,
status: statusCode,
timeInMs: Date.now() - event?.locals?.startTimer,
user: event?.locals?.user?.email,
userId: event?.locals?.user?.userId,
referer: referer,
error: error,
errorId: errorId,
errorStackTrace: errorStackTrace,
...urlParams,
...messageEvents,
...trackEvents
};
console.log('log: ', JSON.stringify(logData));
if (!AXIOM_TOKEN || !AXIOM_ORG_ID || !AXIOM_DATASET) {
return;
}
const client = new Client({
token: AXIOM_TOKEN,
orgId: AXIOM_ORG_ID
});
await client.ingestEvents(AXIOM_DATASET, [logData]);
} catch (err) {
throw new Error(`Error Logger: ${JSON.stringify(err)}`);
}
}

30
src/lib/server/lucia.ts Normal file
View file

@ -0,0 +1,30 @@
// lib/server/lucia.ts
import lucia from 'lucia-auth';
import { sveltekit } from 'lucia-auth/middleware';
import prisma from '@lucia-auth/adapter-prisma';
import { PrismaClient } from '@prisma/client';
import { dev } from '$app/environment';
export const auth = lucia({
adapter: prisma(new PrismaClient()),
env: dev ? 'DEV' : 'PROD',
middleware: sveltekit(),
transformDatabaseUser: (userData) => {
return {
userId: userData.id,
username: userData.username,
email: userData.email,
firstName: userData.firstName,
lastName: userData.lastName,
role: userData.role,
verified: userData.verified,
receiveEmail: userData.receiveEmail,
token: userData.token
};
},
experimental: {
debugMode: false
}
});
export type Auth = typeof auth;

View file

@ -0,0 +1,5 @@
export default function convertNameToInitials(firstName: string, lastName: string): string {
const firstInitial = Array.from(firstName)[0];
const lastInitial = Array.from(lastName)[0];
return `${firstInitial}${lastInitial}`;
}

View file

@ -0,0 +1,11 @@
export default async function getAllUrlParams(url: string): Promise<object> {
let paramsObj = {};
try {
url = url?.slice(1); // remove leading ?
if (!url) return {}; // if no params return
paramsObj = await Object.fromEntries(await new URLSearchParams(url));
} catch (error) {
console.log('error: ', error);
}
return paramsObj;
}

View file

@ -0,0 +1,15 @@
export default async function parseMessage(message: unknown): Promise<object> {
let messageObj = {};
try {
if (message) {
if (typeof message === 'string') {
messageObj = { message: message };
} else {
messageObj = message;
}
}
} catch (error) {
console.log('error: ', error);
}
return messageObj;
}

View file

@ -0,0 +1,15 @@
export default async function parseTrack(track: unknown): Promise<object> {
let trackObj = {};
try {
if (track) {
if (typeof track === 'string') {
trackObj = { track: track };
} else {
trackObj = track;
}
}
} catch (error) {
console.log('error: ', error);
}
return trackObj;
}

View file

@ -16,6 +16,13 @@ export const saved_game_schema = z.object({
playtime: IntegerString(z.number())
});
export const list_game_request_schema = z.object({
id: z.string(),
externalId: z.string()
});
export type ListGameSchema = typeof list_game_request_schema;
// https://github.com/colinhacks/zod/discussions/330
function IntegerString<schema extends ZodNumber | ZodOptional<ZodNumber>>(schema: schema) {
return z.preprocess(
@ -83,6 +90,8 @@ export const search_schema = z
}
);
export type SearchSchema = typeof search_schema;
export const search_result_schema = z.object({
client_id: z.string(),
limit: z.number(),
@ -134,6 +143,8 @@ export const search_result_schema = z.object({
fields: z.string()
});
export type SearchResultSchema = typeof search_result_schema;
export const game_schema = z.object({
id: z.string(),
handle: z.string(),
@ -156,6 +167,82 @@ export const game_schema = z.object({
playtime: z.string()
});
export const category_schema = z.object({
id: z.string(),
name: z.string()
});
export const mechanics_schema = z.object({
id: z.string(),
name: z.string(),
description: z.string().optional()
});
const gameSchema = z.object({
id: z.string(),
name: z.string(),
description: z.string().optional(),
year_published: z.number().optional(),
min_players: z.number().optional(),
max_players: z.number().optional(),
min_playtime: z.number().optional(),
max_playtime: z.number().optional(),
min_age: z.number().optional(),
image_url: z.string().optional(),
thumb_url: z.string().optional(),
url: z.string().optional(),
rules_url: z.string().optional(),
weight_amount: z.number().optional(),
weight_units: z.enum(['Medium', 'Heavy']).optional(),
categories: z.array(category_schema).optional(),
mechanics: z.array(mechanics_schema).optional(),
designers: z
.array(
z.object({
id: z.string(),
name: z.string()
})
)
.optional(),
publishers: z
.array(
z.object({
id: z.string(),
name: z.string()
})
)
.optional(),
artists: z
.array(
z.object({
id: z.string(),
name: z.string()
})
)
.optional(),
names: z.array(z.string()).optional(),
expansions: z
.array(
z.object({
id: z.string(),
name: z.string(),
year_published: z.number().optional()
})
)
.optional(),
primary_publisher: z
.object({
id: z.string(),
name: z.string()
})
.optional()
});
const searchResultSchema = z.object({
games: z.array(gameSchema),
count: z.number()
});
// export const game_raw_schema_json = zodToJsonSchema(game_schema, {
// $refStrategy: 'none',
// });

View file

@ -1,4 +1,20 @@
<h1>The page you requested doesn't exist! 🤷‍♂️</h1>
<script lang="ts">
import { page } from '$app/stores';
</script>
<div>
{#if $page.status === 404}
<h1>The page you requested doesn't exist! 🤷‍♂️</h1>
<h3 class="mt-6"><a href="/">Go Home</a></h3>
{:else}
<h1 class="h1">Unexpected Error</h1>
<h3 class="mt-6">We're investigating the issue.</h3>
{/if}
{#if $page.error?.errorId}
<p class="mt-6">Error ID: {$page.error.errorId}</p>
{/if}
</div>
<style>
h1 {

View file

@ -1,5 +1,6 @@
export async function load({ url }) {
export const load = async ({ url, locals }) => {
return {
url: url.pathname
url: url.pathname,
user: locals.user
};
}
};

View file

@ -3,23 +3,21 @@
import { navigating } from '$app/stores';
import debounce from 'just-debounce-it';
import { Toy } from '@leveluptuts/svelte-toy';
import 'iconify-icon';
import Analytics from '$lib/components/analytics.svelte';
import Header from '$root/lib/components/header/index.svelte';
import Header from '$lib/components/header/index.svelte';
import Footer from '$lib/components/footer.svelte';
import Loading from '$lib/components/loading.svelte';
import Transition from '$lib/components/transition/index.svelte';
import Portal from '$lib/Portal.svelte';
import { boredState } from '$lib/stores/boredState';
import { collectionStore } from '$lib/stores/collectionStore';
import { wishlistStore } from '$root/lib/stores/wishlistStore';
import { wishlistStore } from '$lib/stores/wishlistStore';
import { gameStore } from '$lib/stores/gameSearchStore';
import { toast } from '$lib/components/toast/toast';
import Toast from '$lib/components/toast/Toast.svelte';
import '$root/styles/styles.pcss';
import 'iconify-icon';
import type { SavedGameType } from '$root/lib/types';
import '$styles/styles.pcss';
import type { SavedGameType } from '$lib/types';
$: {
if ($navigating) {
@ -77,7 +75,7 @@
<Analytics />
{/if}
{#if dev}
<!-- {#if dev}
<Toy
register={{
boredState,
@ -87,36 +85,32 @@
toast
}}
/>
{/if}
{/if} -->
<!-- <Transition transition={{ type: 'fade', duration: 250 }}> -->
<div class="wrapper">
<Header />
<main>
<Transition url={data.url} transition={{ type: 'page' }}>
<slot />
</Transition>
</main>
<Footer />
<div class="wrapper">
<Header user={data.user} />
<main>
<Transition url={data.url} transition={{ type: 'page' }}>
<slot />
</Transition>
</main>
<Footer />
</div>
{#if $boredState?.loading}
<Portal>
<div class="loading">
<Loading />
<h3>Loading...</h3>
</div>
<div class="background" />
</Portal>
{/if}
{#if isOpen}
<div class="container">
<svelte:component this={$boredState?.dialog?.content} />
</div>
{#if $boredState?.loading}
<Portal>
<!-- <Transition transition={{ type: 'fade', duration: 0 }}> -->
<div class="loading">
<Loading />
<h3>Loading...</h3>
</div>
<!-- </Transition> -->
<div class="background" />
</Portal>
{/if}
{#if isOpen}
<div class="container">
<svelte:component this={$boredState?.dialog?.content} />
</div>
{/if}
<Toast />
<!-- </Transition> -->
{/if}
<Toast />
<style lang="postcss">
.loading {
@ -129,7 +123,7 @@
place-items: center;
gap: 1rem;
h3 {
& h3 {
color: white;
}
}

View file

@ -3,8 +3,10 @@ import { search_schema } from '$lib/zodValidation';
export const load = async ({ fetch, url }) => {
const formData = Object.fromEntries(url?.searchParams);
console.log('formData', formData);
formData.name = formData?.q;
const form = await superValidate(formData, search_schema);
console.log('form', form);
return { form };
};

View file

@ -1,11 +1,11 @@
<script lang="ts">
import { superForm } from 'sveltekit-superforms/client';
import TextSearch from '$lib/components/search/textSearch/index.svelte';
import RandomSearch from '$lib/components/search/random/index.svelte';
import Random from '$lib/components/random/index.svelte';
export let data;
const { form, errors, constraints } = superForm(data?.form);
export let formData;
console.log('formData', formData);
</script>
<svelte:head>
@ -20,11 +20,11 @@
</p>
<p>Or pick a random game!</p>
<div class="random-buttons">
<RandomSearch />
<RandomSearch data={data.form} />
<Random />
</div>
</section>
<TextSearch showButton advancedSearch {form} {errors} {constraints} />
<TextSearch showButton advancedSearch data={data.form} />
</div>
<style lang="scss">

View file

@ -0,0 +1,5 @@
import { redirect } from '@sveltejs/kit';
export const load = async function ({ locals }) {
if (!locals?.user?.role?.includes('admin')) throw redirect(302, '/');
};

View file

@ -0,0 +1 @@
<slot />

View file

View file

@ -0,0 +1,62 @@
import { fail, redirect } from '@sveltejs/kit';
import { setError, superValidate } from 'sveltekit-superforms/server';
import { auth } from '$lib/server/lucia';
import prisma from '$lib/prisma.js';
import { userSchema } from '$lib/config/zod-schemas';
import { add_user_to_role } from '$db/roles';
const signInSchema = userSchema.pick({
username: true,
password: true
});
export const load = async (event) => {
const session = await event.locals.auth.validate();
if (session) {
throw redirect(302, '/');
}
const form = await superValidate(event, signInSchema);
return {
form
};
};
export const actions = {
default: async (event) => {
const form = await superValidate(event, signInSchema);
if (!form.valid) {
form.data.password = '';
return fail(400, {
form
});
}
try {
const key = await auth.useKey('username', form.data.username, form.data.password);
const session = await auth.createSession(key.userId);
event.locals.auth.setSession(session);
const user = await prisma.authUser.findUnique({
where: {
id: session.userId
},
include: {
roles: {
select: {
role: true
}
}
}
});
} catch (e) {
// TODO: need to return error message to the client
console.error(e);
form.data.password = '';
return setError(form, '', 'The username or password is incorrect.');
}
form.data.username = '';
form.data.password = '';
return { form };
}
};

View file

@ -0,0 +1,66 @@
<script lang="ts">
import { superForm } from 'sveltekit-superforms/client';
import { userSchema } from '$lib/config/zod-schemas.js';
export let data;
const signInSchema = userSchema.pick({ username: true, password: true });
const { form, errors, enhance, delayed } = superForm(data.form, {
taintedMessage: null,
validators: signInSchema,
validationMethod: 'oninput',
delayMs: 0,
});
</script>
<form method="POST" use:enhance>
{#if $errors._errors}
<aside class="alert">
<div class="alert-message">
<h3>There was an error signing in</h3>
<p>{$errors._errors}</p>
</div>
</aside>
{/if}
<div>
<label class="label">
<span class="sr-only">Username</span>
<input
id="username"
name="username"
type="text"
placeholder="Username"
autocomplete="username"
data-invalid={$form.username}
bind:value={$form.username}
class="input"
class:input-error={$errors.username}
/>
{#if $errors.username}
<small>{$errors.username}</small>
{/if}
</label>
</div>
<div>
<label class="label">
<span class="sr-only">Password</span>
<input
id="password"
name="password"
type="password"
placeholder="Password"
data-invalid={$form.password}
bind:value={$form.password}
class="input"
class:input-error={$errors.password}
/>
{#if $errors.password}
<small>{$errors.password}</small>
{/if}
</label>
</div>
<div>
<button type="submit" class="button">Sign In</button>
</div>
</form>

View file

@ -0,0 +1,14 @@
import { auth } from '$lib/server/lucia';
import { redirect } from '@sveltejs/kit';
export const actions = {
default: async ({ locals }) => {
const session = await locals.auth.validate();
if (!session) {
throw redirect(302, '/auth/signin');
}
await auth.invalidateSession(session.sessionId); // invalidate session
locals.auth.setSession(null); // remove cookie
throw redirect(302, '/auth/signin');
}
};

View file

@ -0,0 +1,72 @@
import { fail, redirect } from '@sveltejs/kit';
import { setError, superValidate } from 'sveltekit-superforms/server';
import { auth } from '$lib/server/lucia';
import { userSchema } from '$lib/config/zod-schemas';
import { add_user_to_role } from '$db/roles';
const signUpSchema = userSchema.pick({
firstName: true,
lastName: true,
email: true,
username: true,
password: true,
terms: true
});
export const load = async (event) => {
const session = await event.locals.auth.validate();
if (session) {
throw redirect(302, '/');
}
const form = await superValidate(event, signUpSchema);
return {
form
};
};
export const actions = {
default: async (event) => {
const form = await superValidate(event, signUpSchema);
if (!form.valid) {
return fail(400, {
form
});
}
// Adding user to the db
try {
console.log('Creating user');
const token = crypto.randomUUID();
const user = await auth.createUser({
primaryKey: {
providerId: 'username',
providerUserId: form.data.username,
password: form.data.password
},
attributes: {
email: form.data.email || '',
username: form.data.username,
firstName: form.data.firstName || '',
lastName: form.data.lastName || '',
role: 'USER',
verified: false,
receiveEmail: false,
token
}
});
add_user_to_role(user.id, 'user');
console.log('User', user);
const session = await auth.createSession(user.userId);
event.locals.auth.setSession(session);
} catch (error) {
console.log(error);
return setError(form, 'email', 'Unable to create your account. Please try again.');
}
return { form };
}
};

View file

@ -0,0 +1,144 @@
<script lang="ts">
import { userSchema } from '$lib/config/zod-schemas.js';
import { superForm } from 'sveltekit-superforms/client';
export let data;
const signUpSchema = userSchema.pick({
firstName: true,
lastName: true,
username: true,
email: true,
password: true
});
const { form, errors, constraints, enhance, delayed } = superForm(data.form, {
taintedMessage: null,
validators: signUpSchema,
delayMs: 0,
});
</script>
<div class="page">
<form method="POST" action="/auth/signup" use:enhance>
<h1>Signup user</h1>
<label class="label">
<span class="sr-only">First Name</span>
<input
id="firstName"
name="firstName"
type="text"
placeholder="First Name"
autocomplete="given-name"
data-invalid={$errors.firstName}
bind:value={$form.firstName}
class="input"
class:input-error={$errors.firstName}
/>
{#if $errors.firstName}
<small>{$errors.firstName}</small>
{/if}
</label>
<label class="label">
<span class="sr-only">Last Name</span>
<input
id="lastName"
name="lastName"
type="text"
placeholder="Last Name"
autocomplete="family-name"
data-invalid={$errors.lastName}
bind:value={$form.lastName}
class="input"
class:input-error={$errors.lastName}
/>
{#if $errors.lastName}
<small>{$errors.lastName}</small>
{/if}
</label>
<label class="label">
<span class="sr-only">Email</span>
<input
id="email"
name="email"
type="email"
placeholder="Email"
autocomplete="email"
data-invalid={$errors.email}
bind:value={$form.email}
class="input"
class:input-error={$errors.email}
/>
{#if $errors.email}
<small>{$errors.email}</small>
{/if}
</label>
<label class="label">
<span class="sr-only">Username</span>
<input
id="username"
name="username"
type="username"
placeholder="Username"
autocomplete="email"
{...$constraints.username}
data-invalid={$errors.username}
bind:value={$form.username}
class="input"
class:input-error={$errors.username}
/>
{#if $errors.username}
<small>{$errors.username}</small>
{/if}
</label>
<label class="label">
<span class="sr-only">password</span>
<input
id="password"
name="password"
type="password"
placeholder="password"
{...$constraints.username}
data-invalid={$errors.password}
bind:value={$form.password}
class="input"
class:input-error={$errors.password}
/>
{#if $errors.password}
<small>{$errors.password}</small>
{/if}
</label>
<button type="submit">Signup</button>
<a class="back" href="/"> or Cancel </a>
</form>
</div>
<style scoped>
.page {
padding: 3rem;
display: flex;
justify-content: center;
}
/* input[type="text"] {
width: 100%;
padding: 0.5rem;
margin: 0.5rem 0;
border-radius: 0.25rem;
border: 0.125rem solid rgba(0, 0, 0, 0.2);
}
button[type="submit"] {
border: 0;
padding: 1rem 2rem;
} */
.back {
margin-left: 1rem;
}
.error {
color: red;
}
</style>

View file

@ -1,14 +1,65 @@
import type { PageServerLoad } from "../$types";
// import { redirect } from '@sveltejs/kit';
// import { superValidate } from 'sveltekit-superforms/server';
// import { search_schema } from '$lib/zodValidation';
export const load: PageServerLoad = async ({ fetch, url }) => {
const searchParams = Object.fromEntries(url?.searchParams);
const q = searchParams?.q;
const limit = parseInt(searchParams?.limit) || 10;
const skip = parseInt(searchParams?.skip) || 0;
export const load = async ({ fetch, url, locals }) => {
// const session = await locals.auth.validate();
// if (!session) {
// throw redirect(302, '/auth/signin');
// }
console.log('locals load', locals);
// const searchParams = Object.fromEntries(url?.searchParams);
// const q = searchParams?.q;
// const limit = parseInt(searchParams?.limit) || 10;
// const skip = parseInt(searchParams?.skip) || 0;
// const searchData = {
// q,
// limit,
// skip
// };
// const form = await superValidate(searchData, search_schema);
try {
// let collection = await locals.prisma.collection.findUnique({
// where: {
// user_id: session.userId
// }
// include: {
// collectionItems: {
// where: {
// title: {
// contains: q,
// mode: 'insensitive'
// }
// },
// take: limit,
// skip
// }
// }
// });
// console.log('collection', collection);
// if (!collection) {
// collection = await locals.prisma.collection.create({
// data: {
// user_id: session.userId
// }
// });
// }
return {
// form,
// collection
};
} catch (e) {
console.error(e);
}
return {
q,
limit,
skip
// form,
// collection: []
};
};

View file

@ -1,78 +1,78 @@
<script lang="ts">
import { tick, onDestroy } from 'svelte';
import Game from '$lib/components/game/index.svelte';
import { collectionStore } from '$lib/stores/collectionStore';
import type { GameType, SavedGameType } from '$root/lib/types';
import { boredState } from '$root/lib/stores/boredState';
import Pagination from '$root/lib/components/pagination/index.svelte';
import RemoveCollectionDialog from '$root/lib/components/dialog/RemoveCollectionDialog.svelte';
import RemoveWishlistDialog from '$root/lib/components/dialog/RemoveWishlistDialog.svelte';
import { createSearchStore, searchHandler } from '$root/lib/stores/search';
import type { PageData } from './$types';
// import { tick, onDestroy } from 'svelte';
// import Game from '$lib/components/game/index.svelte';
// import { collectionStore } from '$lib/stores/collectionStore';
// import type { GameType, SavedGameType } from '$lib/types';
// import { boredState } from '$lib/stores/boredState';
// import Pagination from '$lib/components/pagination/index.svelte';
// import RemoveCollectionDialog from '$lib/components/dialog/RemoveCollectionDialog.svelte';
// import RemoveWishlistDialog from '$lib/components/dialog/RemoveWishlistDialog.svelte';
// import { createSearchStore, searchHandler } from '$lib/stores/search';
export let data: PageData;
console.log(`Page data: ${JSON.stringify(data)}`)
export let data;
console.log(`Page data: ${JSON.stringify(data)}`);
// let collectionItems = data?.collection?.collectionItems;
let gameToRemove: GameType | SavedGameType;
let pageSize = 10;
let page = 1;
// let gameToRemove: GameType | SavedGameType;
// let pageSize = 10;
// let page = 1;
const searchStore = createSearchStore($collectionStore);
console.log('searchStore', $searchStore);
// const searchStore = createSearchStore($collectionStore);
// console.log('searchStore', $searchStore);
const unsubscribe = searchStore.subscribe((model) => searchHandler(model));
// const unsubscribe = searchStore.subscribe((model) => searchHandler(model));
onDestroy(() => {
unsubscribe();
});
// onDestroy(() => {
// unsubscribe();
// });
$: skip = (page - 1) * pageSize;
$: gamesShown = $searchStore.data.slice(skip, skip + pageSize);
$: totalItems = $searchStore.search === '' ? $collectionStore.length : $searchStore.filtered.length;
// $: skip = (page - 1) * pageSize;
// $: gamesShown = $searchStore.data.slice(skip, skip + pageSize);
// $: totalItems = $searchStore.search === '' ? $collectionStore.length : $searchStore.filtered.length;
interface RemoveGameEvent extends Event {
detail: GameType | SavedGameType;
}
// interface RemoveGameEvent extends Event {
// detail: GameType | SavedGameType;
// }
function handleRemoveCollection(event: RemoveGameEvent) {
console.log('Remove collection event handler');
console.log('event', event);
gameToRemove = event?.detail;
boredState.update((n) => ({
...n,
dialog: { isOpen: true, content: RemoveCollectionDialog, additionalData: gameToRemove }
}));
}
// function handleRemoveCollection(event: RemoveGameEvent) {
// console.log('Remove collection event handler');
// console.log('event', event);
// gameToRemove = event?.detail;
// boredState.update((n) => ({
// ...n,
// dialog: { isOpen: true, content: RemoveCollectionDialog, additionalData: gameToRemove }
// }));
// }
function handleRemoveWishlist(event: RemoveGameEvent) {
console.log('Remove wishlist event handler');
console.log('event', event);
gameToRemove = event?.detail;
boredState.update((n) => ({
...n,
dialog: { isOpen: true, content: RemoveWishlistDialog, additionalData: gameToRemove }
}));
}
// function handleRemoveWishlist(event: RemoveGameEvent) {
// console.log('Remove wishlist event handler');
// console.log('event', event);
// gameToRemove = event?.detail;
// boredState.update((n) => ({
// ...n,
// dialog: { isOpen: true, content: RemoveWishlistDialog, additionalData: gameToRemove }
// }));
// }
async function handleNextPageEvent(event: CustomEvent) {
if (+event?.detail?.page === page + 1) {
page += 1;
}
await tick();
}
// async function handleNextPageEvent(event: CustomEvent) {
// if (+event?.detail?.page === page + 1) {
// page += 1;
// }
// await tick();
// }
async function handlePreviousPageEvent(event: CustomEvent) {
if (+event?.detail?.page === page - 1) {
page -= 1;
}
await tick();
}
// async function handlePreviousPageEvent(event: CustomEvent) {
// if (+event?.detail?.page === page - 1) {
// page -= 1;
// }
// await tick();
// }
async function handlePerPageEvent(event: CustomEvent) {
page = 1;
pageSize = event.detail.pageSize;
await tick();
}
// async function handlePerPageEvent(event: CustomEvent) {
// page = 1;
// pageSize = event.detail.pageSize;
// await tick();
// }
</script>
<svelte:head>
@ -80,9 +80,9 @@
</svelte:head>
<h1>Your Collection</h1>
<input type="text" id="search" name="search" placeholder="Search Your Collection" bind:value={$searchStore.search} />
<!-- <input type="text" id="search" name="search" placeholder="Search Your Collection" bind:value={$searchStore.search} /> -->
<div class="games">
<!-- <div class="games">
<div class="games-list">
{#if $collectionStore.length === 0}
<h2>No games in your collection</h2>
@ -109,9 +109,9 @@
on:perPageEvent={handlePerPageEvent}
/>
{/if}
</div>
</div> -->
<style lang="scss">
<style lang="postcss">
h1 {
margin: 1.5rem 0rem;
width: 100%;

View file

@ -15,16 +15,16 @@
import { collectionStore } from '$lib/stores/collectionStore';
import { wishlistStore } from '$lib/stores/wishlistStore';
import Button from '$lib/components/button/index.svelte';
import RemoveCollectionDialog from '$root/lib/components/dialog/RemoveCollectionDialog.svelte';
import RemoveCollectionDialog from '$lib/components/dialog/RemoveCollectionDialog.svelte';
import { addToCollection } from '$lib/util/manipulateCollection';
import type { PageData } from './$types';
import { boredState } from '$root/lib/stores/boredState';
import { boredState } from '$lib/stores/boredState';
import { browser } from '$app/environment';
import LinkWithIcon from '$root/lib/components/LinkWithIcon.svelte';
import { addToWishlist } from '$root/lib/util/manipulateWishlist';
import RemoveWishlistDialog from '$root/lib/components/dialog/RemoveWishlistDialog.svelte';
import { binarySearchOnStore } from '$root/lib/util/binarySearchOnStore';
import { convertToSavedGame } from '$root/lib/util/gameMapper';
import LinkWithIcon from '$lib/components/LinkWithIcon.svelte';
import { addToWishlist } from '$lib/util/manipulateWishlist';
import RemoveWishlistDialog from '$lib/components/dialog/RemoveWishlistDialog.svelte';
import { binarySearchOnStore } from '$lib/util/binarySearchOnStore';
import { convertToSavedGame } from '$lib/util/gameMapper';
$: existsInCollection = $collectionStore.find((item: SavedGameType) => item.id === game.id);
$: existsInWishlist = $wishlistStore.find((item: SavedGameType) => item.id === game.id);
@ -205,7 +205,7 @@
margin: 1rem;
place-items: center;
@media (max-width: env(--medium-viewport)) {
@media (max-width: 700px) {
grid-template-columns: 1fr;
place-items: center;
}
@ -220,7 +220,7 @@
margin: 1rem;
}
@media (max-width: env(--xsmall-viewport)) {
@media (max-width: 500px) {
grid-template-columns: 1fr;
}
}

View file

@ -1,4 +1,3 @@
import type { Actions, RequestEvent } from '../$types';
import { BOARD_GAME_ATLAS_CLIENT_ID } from '$env/static/private';
import { error } from '@sveltejs/kit';
import { superValidate } from 'sveltekit-superforms/server';
@ -6,7 +5,7 @@ import type { GameType, SearchQuery } from '$lib/types';
import { mapAPIGameToBoredGame } from '$lib/util/gameMapper';
import { search_schema } from '$lib/zodValidation';
async function searchForGames(urlQueryParams) {
async function searchForGames(urlQueryParams: SearchQuery) {
try {
const url = `https://api.boardgameatlas.com/api/search${
urlQueryParams ? `?${urlQueryParams}` : ''
@ -27,7 +26,7 @@ async function searchForGames(urlQueryParams) {
let totalCount = 0;
if (response.ok) {
const gameResponse = await response.json();
const gameList = gameResponse?.games;
const gameList: GameType[] = gameResponse?.games;
totalCount = gameResponse?.count;
console.log('totalCount', totalCount);
gameList.forEach((game) => {
@ -109,8 +108,9 @@ export const load = async ({ fetch, url }) => {
};
};
export const actions: Actions = {
random: async ({ request }: RequestEvent): Promise<any> => {
export const actions = {
random: async ({ request }): Promise<any> => {
const form = await superValidate(request, search_schema);
const queryParams: SearchQuery = {
order_by: 'rank',
ascending: false,
@ -127,47 +127,9 @@ export const actions: Actions = {
const urlQueryParams = new URLSearchParams(newQueryParams);
try {
const url = `https://api.boardgameatlas.com/api/search${
urlQueryParams ? `?${urlQueryParams}` : ''
}`;
const response = await fetch(url, {
method: 'get',
headers: {
'content-type': 'application/json'
}
});
// console.log('board game response', response);
if (!response.ok) {
console.log('Status not 200', response.status);
throw error(response.status);
}
if (response.status === 200) {
const gameResponse = await response.json();
// console.log('gameResponse', gameResponse);
const gameList = gameResponse?.games;
const totalCount = gameResponse?.count;
console.log('totalCount', totalCount);
const games: GameType[] = [];
gameList.forEach((game) => {
game.players = `${game.min_players}-${game.max_players}`;
game.playtime = `${game.min_playtime}-${game.max_playtime}`;
games.push(mapAPIGameToBoredGame(game));
});
// console.log('returning from search', games)
return {
games
};
}
} catch (e) {
console.log(`Error searching board games ${e}`);
}
return {
games: []
form,
searchData: await searchForGames(urlQueryParams)
};
}
};

View file

@ -4,7 +4,6 @@
import TextSearch from '$lib/components/search/textSearch/index.svelte';
export let data;
const { form, errors, constraints } = superForm(data?.form);
$: if (data?.searchData?.games) {
gameStore.removeAll();
@ -13,5 +12,5 @@
</script>
<div class="game-search">
<TextSearch showButton advancedSearch {form} {errors} {constraints} />
<TextSearch showButton advancedSearch data={data.form} />
</div>

View file

@ -0,0 +1,95 @@
import { redirect } from '@sveltejs/kit';
import { superValidate } from 'sveltekit-superforms/server';
import prisma from '$lib/prisma.js';
import { list_game_request_schema } from '$lib/zodValidation';
export async function load({ params, locals }) {
const session = await locals.auth.validate();
if (!session) {
throw redirect(302, '/auth/signin');
}
try {
let wishlists = await prisma.wishlist.findMany({
where: {
user_id: session.userId
},
include: {
items: true
}
});
if (wishlists.length === 0) {
const wishlist = await prisma.wishlist.create({
data: {
user_id: session.userId,
name: 'My Wishlist'
}
});
wishlists.push(wishlist);
}
return {
wishlists
};
} catch (e) {
console.error(e);
}
}
export const actions = {
// Add game to a wishlist
add: async (event) => {
const { params, locals, request } = event;
const form = await superValidate(event, list_game_request_schema);
const session = await locals.auth.validate();
if (!session) {
throw redirect(302, '/auth/signin');
}
const game = await prisma.game.findUnique({
where: {
id: form.id
}
});
// if (!game) {
// throw redirect(302, '/404');
// }
if (game) {
const wishlist = await prisma.wishlist.create({
data: {
user_id: session.userId,
name: form.name
}
});
}
return {
form
};
},
// Create new wishlist
create: async ({ params, locals, request }) => {
const session = await locals.auth.validate();
if (!session) {
throw redirect(302, '/auth/signin');
}
},
// Delete a wishlist
delete: async ({ params, locals, request }) => {
const session = await locals.auth.validate();
if (!session) {
throw redirect(302, '/auth/signin');
}
},
// Remove game from a wishlist
remove: async ({ params, locals, request }) => {
const session = await locals.auth.validate();
if (!session) {
throw redirect(302, '/auth/signin');
}
}
};

View file

@ -1,70 +1,72 @@
<script lang="ts">
import { tick, onDestroy } from 'svelte';
import Game from '$lib/components/game/index.svelte';
import { wishlistStore } from '$lib/stores/wishlistStore';
import type { GameType, SavedGameType } from '$root/lib/types';
import { boredState } from '$root/lib/stores/boredState';
import Pagination from '$root/lib/components/pagination/index.svelte';
import RemoveWishlistDialog from '$root/lib/components/dialog/RemoveWishlistDialog.svelte';
import RemoveCollectionDialog from '$root/lib/components/dialog/RemoveCollectionDialog.svelte';
import { createSearchStore, searchHandler } from '$root/lib/stores/search';
// import { tick, onDestroy } from 'svelte';
// import Game from '$lib/components/game/index.svelte';
// import { wishlistStore } from '$lib/stores/wishlistStore';
// import type { GameType, SavedGameType } from '$lib/types';
// import { boredState } from '$lib/stores/boredState';
// import Pagination from '$lib/components/pagination/index.svelte';
// import RemoveWishlistDialog from '$lib/components/dialog/RemoveWishlistDialog.svelte';
// import RemoveCollectionDialog from '$lib/components/dialog/RemoveCollectionDialog.svelte';
// import { createSearchStore, searchHandler } from '$lib/stores/search';
let gameToRemove: GameType | SavedGameType;
let pageSize = 10;
let page = 1;
// let gameToRemove: GameType | SavedGameType;
// let pageSize = 10;
// let page = 1;
const searchStore = createSearchStore($wishlistStore);
console.log('searchStore', $searchStore);
// const searchStore = createSearchStore($wishlistStore);
// console.log('searchStore', $searchStore);
const unsubscribe = searchStore.subscribe((model) => searchHandler(model));
// const unsubscribe = searchStore.subscribe((model) => searchHandler(model));
onDestroy(() => {
unsubscribe();
});
// onDestroy(() => {
// unsubscribe();
// });
$: skip = (page - 1) * pageSize;
$: gamesShown = $searchStore.filtered.slice(skip, skip + pageSize);
$: totalItems = $searchStore.search === '' ? $wishlistStore.length : $searchStore.filtered.length;
// $: skip = (page - 1) * pageSize;
// $: gamesShown = $searchStore.filtered.slice(skip, skip + pageSize);
// $: totalItems = $searchStore.search === '' ? $wishlistStore.length : $searchStore.filtered.length;
interface RemoveGameEvent extends Event {
detail: GameType | SavedGameType;
}
// interface RemoveGameEvent extends Event {
// detail: GameType | SavedGameType;
// }
function handleRemoveCollection(event: RemoveGameEvent) {
gameToRemove = event?.detail;
boredState.update((n) => ({
...n,
dialog: { isOpen: true, content: RemoveCollectionDialog, additionalData: gameToRemove }
}));
}
// function handleRemoveCollection(event: RemoveGameEvent) {
// gameToRemove = event?.detail;
// boredState.update((n) => ({
// ...n,
// dialog: { isOpen: true, content: RemoveCollectionDialog, additionalData: gameToRemove }
// }));
// }
function handleRemoveWishlist(event: RemoveGameEvent) {
gameToRemove = event?.detail;
boredState.update((n) => ({
...n,
dialog: { isOpen: true, content: RemoveWishlistDialog, additionalData: gameToRemove }
}));
}
// function handleRemoveWishlist(event: RemoveGameEvent) {
// gameToRemove = event?.detail;
// boredState.update((n) => ({
// ...n,
// dialog: { isOpen: true, content: RemoveWishlistDialog, additionalData: gameToRemove }
// }));
// }
async function handleNextPageEvent(event: CustomEvent) {
if (+event?.detail?.page === page + 1) {
page += 1;
}
await tick();
}
// async function handleNextPageEvent(event: CustomEvent) {
// if (+event?.detail?.page === page + 1) {
// page += 1;
// }
// await tick();
// }
async function handlePreviousPageEvent(event: CustomEvent) {
if (+event?.detail?.page === page - 1) {
page -= 1;
}
await tick();
}
// async function handlePreviousPageEvent(event: CustomEvent) {
// if (+event?.detail?.page === page - 1) {
// page -= 1;
// }
// await tick();
// }
async function handlePerPageEvent(event: CustomEvent) {
page = 1;
pageSize = event.detail.pageSize;
await tick();
}
// async function handlePerPageEvent(event: CustomEvent) {
// page = 1;
// pageSize = event.detail.pageSize;
// await tick();
// }
export let data;
const wishlists = data.wishlists || [];
</script>
<svelte:head>
@ -72,7 +74,10 @@
</svelte:head>
<h1>Your Wishlist</h1>
<input type="text" id="search" name="search" placeholder="Search Your Wishlist" bind:value={$searchStore.search} />
{#each wishlists as wishlist}
<h2>{wishlist.name}</h2>
{/each}
<!-- <input type="text" id="search" name="search" placeholder="Search Your Wishlist" bind:value={$searchStore.search} />
<div class="games">
<div class="games-list">
@ -101,7 +106,7 @@
on:perPageEvent={handlePerPageEvent}
/>
{/if}
</div>
</div> -->
<style lang="scss">
h1 {

View file

@ -1,181 +1,181 @@
import { invalid, type RequestEvent } from '@sveltejs/kit';
import { BOARD_GAME_ATLAS_CLIENT_ID } from '$env/static/private';
import type { GameType, SearchQuery } from "$root/lib/types";
import { mapAPIGameToBoredGame } from "$root/lib/util/gameMapper";
import type { GameType, SearchQuery } from '$lib/types';
import { mapAPIGameToBoredGame } from '$lib/util/gameMapper';
interface Actions {
[key: string]: any // Action
[key: string]: any; // Action
}
export const Games: Actions = {
search: async ({ request, locals }: RequestEvent): Promise<any> => {
console.log("In search action specific")
// Do things in here
const form = await request.formData();
console.log('action form', form);
const queryParams: SearchQuery = {
order_by: 'rank',
ascending: false,
limit: 10,
skip: 0,
client_id: BOARD_GAME_ATLAS_CLIENT_ID,
fuzzy_match: true,
name: ''
};
search: async ({ request, locals }: RequestEvent): Promise<any> => {
console.log('In search action specific');
// Do things in here
const form = await request.formData();
console.log('action form', form);
const queryParams: SearchQuery = {
order_by: 'rank',
ascending: false,
limit: 10,
skip: 0,
client_id: BOARD_GAME_ATLAS_CLIENT_ID,
fuzzy_match: true,
name: ''
};
const name = form.has('name') ? form.get('name') : await request?.text();
console.log('name', name);
if (name) {
queryParams.name = `${name}`;
}
const name = form.has('name') ? form.get('name') : await request?.text();
console.log('name', name);
if (name) {
queryParams.name = `${name}`;
}
const newQueryParams: Record<string, string> = {};
for (const key in queryParams) {
console.log('key', key);
console.log('queryParams[key]', queryParams[key]);
newQueryParams[key] = `${queryParams[key]}`;
}
const newQueryParams: Record<string, string> = {};
for (const key in queryParams) {
console.log('key', key);
console.log('queryParams[key]', queryParams[key]);
newQueryParams[key] = `${queryParams[key]}`;
}
const urlQueryParams = new URLSearchParams(newQueryParams);
console.log('urlQueryParams', urlQueryParams);
const urlQueryParams = new URLSearchParams(newQueryParams);
console.log('urlQueryParams', urlQueryParams);
try {
throw new Error("test error");
// const url = `https://api.boardgameatlas.com/api/search${urlQueryParams ? `?${urlQueryParams}` : ''
// }`;
// const response = await fetch(url, {
// method: 'get',
// headers: {
// 'content-type': 'application/json'
// }
// });
// console.log('board game response', response);
// if (response.status !== 200) {
// console.log('Status not 200', response.status)
// invalid(response.status, {});
// }
try {
throw new Error('test error');
// const url = `https://api.boardgameatlas.com/api/search${urlQueryParams ? `?${urlQueryParams}` : ''
// }`;
// const response = await fetch(url, {
// method: 'get',
// headers: {
// 'content-type': 'application/json'
// }
// });
// console.log('board game response', response);
// if (response.status !== 200) {
// console.log('Status not 200', response.status)
// invalid(response.status, {});
// }
// if (response.status === 200) {
// const gameResponse = await response.json();
// console.log('gameResponse', gameResponse);
// const gameList = gameResponse?.games;
// const totalCount = gameResponse?.count;
// console.log('totalCount', totalCount);
// const games: GameType[] = [];
// gameList.forEach((game) => {
// games.push(mapAPIGameToBoredGame(game));
// });
// if (response.status === 200) {
// const gameResponse = await response.json();
// console.log('gameResponse', gameResponse);
// const gameList = gameResponse?.games;
// const totalCount = gameResponse?.count;
// console.log('totalCount', totalCount);
// const games: GameType[] = [];
// gameList.forEach((game) => {
// games.push(mapAPIGameToBoredGame(game));
// });
// console.log('returning from search')
// console.log('returning from search')
// return {
// games,
// totalCount: games.length
// };
// }
// return {
// games,
// totalCount: games.length
// };
// }
// return {
// games: [],
// totalCount: 0
// };
} catch (e) {
console.log(`Error searching board games ${e}`);
return invalid(400, { reason: 'Exception' })
}
}
// return {
// games: [],
// totalCount: 0
// };
} catch (e) {
console.log(`Error searching board games ${e}`);
return invalid(400, { reason: 'Exception' });
}
}
// const id = form.get('id');
// const ids = form.get('ids');
// const minAge = form.get('minAge');
// const minPlayers = form.get('minPlayers');
// const maxPlayers = form.get('maxPlayers');
// const exactMinAge = form.get('exactMinAge') || false;
// const exactMinPlayers = form.get('exactMinPlayers') || false;
// const exactMaxPlayers = form.get('exactMaxPlayers') || false;
// const random = form.get('random') === 'on' || false;
// const id = form.get('id');
// const ids = form.get('ids');
// const minAge = form.get('minAge');
// const minPlayers = form.get('minPlayers');
// const maxPlayers = form.get('maxPlayers');
// const exactMinAge = form.get('exactMinAge') || false;
// const exactMinPlayers = form.get('exactMinPlayers') || false;
// const exactMaxPlayers = form.get('exactMaxPlayers') || false;
// const random = form.get('random') === 'on' || false;
// if (minAge) {
// if (exactMinAge) {
// queryParams.min_age = +minAge;
// } else {
// queryParams.gt_min_age = +minAge === 1 ? 0 : +minAge - 1;
// }
// }
// if (minAge) {
// if (exactMinAge) {
// queryParams.min_age = +minAge;
// } else {
// queryParams.gt_min_age = +minAge === 1 ? 0 : +minAge - 1;
// }
// }
// if (minPlayers) {
// if (exactMinPlayers) {
// queryParams.min_players = +minPlayers;
// } else {
// queryParams.gt_min_players = +minPlayers === 1 ? 0 : +minPlayers - 1;
// }
// }
// if (minPlayers) {
// if (exactMinPlayers) {
// queryParams.min_players = +minPlayers;
// } else {
// queryParams.gt_min_players = +minPlayers === 1 ? 0 : +minPlayers - 1;
// }
// }
// if (maxPlayers) {
// if (exactMaxPlayers) {
// queryParams.max_players = +maxPlayers;
// } else {
// queryParams.lt_max_players = +maxPlayers + 1;
// }
// }
// if (maxPlayers) {
// if (exactMaxPlayers) {
// queryParams.max_players = +maxPlayers;
// } else {
// queryParams.lt_max_players = +maxPlayers + 1;
// }
// }
// if (id) {
// queryParams.ids = new Array(`${id}`);
// }
// if (id) {
// queryParams.ids = new Array(`${id}`);
// }
// if (ids) {
// // TODO: Pass in ids array from localstorage / game store
// queryParams.ids = new Array(ids);
// }
// if (ids) {
// // TODO: Pass in ids array from localstorage / game store
// queryParams.ids = new Array(ids);
// }
// queryParams.random = random;
// console.log('queryParams', queryParams);
// queryParams.random = random;
// console.log('queryParams', queryParams);
// const newQueryParams: Record<string, string> = {};
// for (const key in queryParams) {
// newQueryParams[key] = `${queryParams[key as keyof typeof queryParams]}`;
// }
// const newQueryParams: Record<string, string> = {};
// for (const key in queryParams) {
// newQueryParams[key] = `${queryParams[key as keyof typeof queryParams]}`;
// }
// const urlQueryParams = new URLSearchParams(newQueryParams);
// const urlQueryParams = new URLSearchParams(newQueryParams);
// const url = `https://api.boardgameatlas.com/api/search${urlQueryParams ? `?${urlQueryParams}` : ''
// }`;
// const response = await fetch(url, {
// method: 'get',
// headers: {
// 'content-type': 'application/json'
// }
// });
// console.log('response status', response.status);
// console.log('board game response action', response);
// if (response.status === 404) {
// // user hasn't created a todo list.
// // start with an empty array
// return {
// success: true,
// games: [],
// totalCount: 0
// };
// }
// const url = `https://api.boardgameatlas.com/api/search${urlQueryParams ? `?${urlQueryParams}` : ''
// }`;
// const response = await fetch(url, {
// method: 'get',
// headers: {
// 'content-type': 'application/json'
// }
// });
// console.log('response status', response.status);
// console.log('board game response action', response);
// if (response.status === 404) {
// // user hasn't created a todo list.
// // start with an empty array
// return {
// success: true,
// games: [],
// totalCount: 0
// };
// }
// if (response.status === 200) {
// const gameResponse = await response.json();
// console.log('gameResponse', gameResponse);
// const gameList = gameResponse?.games;
// const games: GameType[] = [];
// gameList.forEach((game: GameType) => {
// games.push(mapAPIGameToBoredGame(game));
// });
// console.log('action games', games);
// return {
// games,
// totalCount: games.length
// };
// }
// if (response.status === 200) {
// const gameResponse = await response.json();
// console.log('gameResponse', gameResponse);
// const gameList = gameResponse?.games;
// const games: GameType[] = [];
// gameList.forEach((game: GameType) => {
// games.push(mapAPIGameToBoredGame(game));
// });
// console.log('action games', games);
// return {
// games,
// totalCount: games.length
// };
// }
// return { success: false };
// }
// create: async function create({ request, locals }): Promise<any> {
// const data = await getFormDataObject<any>(request);
// return data;
// }
}
// return { success: false };
// }
// create: async function create({ request, locals }): Promise<any> {
// const data = await getFormDataObject<any>(request);
// return data;
// }
};

View file

@ -1,3 +1,3 @@
@import 'reset.pcss';
@import 'global.pcss';
@import '$root/styles/theme.pcss';
@import 'theme.pcss';

View file

@ -1,28 +1,25 @@
import preprocess from 'svelte-preprocess';
import adapter from '@sveltejs/adapter-vercel';
import { vitePreprocess } from '@sveltejs/kit/vite';
/** @type {import('@sveltejs/kit').Config} */
const config = {
// Consult https://kit.svelte.dev/docs/integrations#preprocessors
// Consult https://kit.svelte.dev/docs/integrations#preprocessors
// for more information about preprocessors
preprocess: [
vitePreprocess({
postcss: true,
}),
],
kit: {
adapter: adapter(),
alias: {
$root: './src'
},
},
vitePlugin: {
experimental: {
inspector: {
toggleKeyCombo: 'control-alt-shift',
},
},
},
preprocess: [vitePreprocess()],
vitePlugin: {
inspector: true,
},
kit: {
adapter: adapter(),
alias: {
$db: './src/db',
$assets: './src/assets',
$lib: './src/lib',
$styles: './src/styles',
$themes: './src/themes'
}
},
};
export default config;

18
tailwind.config.cjs Normal file
View file

@ -0,0 +1,18 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
// 1. Apply the dark mode class setting:
darkMode: 'class',
content: [
'./src/**/*.{html,js,svelte,ts}',
// 2. Append the path for the Skeleton NPM package and files:
require('path').join(require.resolve('@skeletonlabs/skeleton'), '../**/*.{html,js,svelte,ts}')
],
theme: {
extend: {}
},
plugins: [
require('@tailwindcss/forms'),
// 3. Append the Skeleton plugin to the end of this list
...require('@skeletonlabs/skeleton/tailwind/skeleton.cjs')()
]
};