diff --git a/README.md b/README.md index 04e2524..2e5005c 100644 --- a/README.md +++ b/README.md @@ -13,11 +13,21 @@ > Relax, have a satsuma and enjoy the next trip to the supermarket. > - That's at least the vision for this little helper app. Try it out live 👉 [here](https://main--satsuma-shopping.netlify.app) 👈 +## Roadmap + +- [ ] Add Sharing-feature +- [ ] Add email-service for password reset and email verification +- [ ] Protect signup and signin with ReCaptcha +- [ ] Add GoatCounter analytics +- [ ] Improve DevOps and hosting +- [ ] Launch Party 🚀 + +After that, we'll focus on improvements, new ideas and possibly explore a paid-plan. We welcome your suggestions. + ## Setup 1. Run `nvm use` in the project root to ensure the right node version is used. @@ -34,3 +44,4 @@ The pocketbase executable should be run in a separate folder. - 🎨 [Color Scheme](https://coolors.co/cdf0ea-f9f9f9-f7dbf0-beaee2-513956) - 🛢️ [Host PB for free on Fly.io](https://github.com/pocketbase/pocketbase/discussions/537) - 📱 [Icons](https://icones.js.org/) + diff --git a/backend/fly.toml b/backend/fly.toml index efe2482..36800eb 100644 --- a/backend/fly.toml +++ b/backend/fly.toml @@ -1,45 +1,41 @@ -# fly.toml file generated for satsuma on 2023-03-31T08:51:17+02:00 +# fly.toml app configuration file generated for satsuma on 2023-08-21T08:44:04+02:00 +# +# See https://fly.io/docs/reference/configuration/ for information about how to use this file. +# app = "satsuma" +primary_region = "arn" kill_signal = "SIGINT" -kill_timeout = 5 -processes = [] - -[env] +kill_timeout = "5s" [experimental] auto_rollback = true -[mounts] - destination = "/pb/pb_data" +[[mounts]] source = "pb_data" - -# optional if you want to change the PocketBase version -[build.args] - PB_VERSION="0.16.0" + destination = "/pb/pb_data" + processes = ["app"] [[services]] - http_checks = [] + protocol = "tcp" internal_port = 8080 processes = ["app"] - protocol = "tcp" - script_checks = [] - [services.concurrency] - hard_limit = 25 - soft_limit = 20 - type = "connections" [[services.ports]] - force_https = true - handlers = ["http"] port = 80 + handlers = ["http"] + force_https = true [[services.ports]] - handlers = ["tls", "http"] port = 443 + handlers = ["tls", "http"] + [services.concurrency] + type = "connections" + hard_limit = 25 + soft_limit = 20 [[services.tcp_checks]] - grace_period = "1s" interval = "15s" - restart_limit = 0 timeout = "2s" + grace_period = "1s" + restart_limit = 0 diff --git a/backend/pb_schema.json b/backend/pb_schema.json index dee1388..f1e61c5 100644 --- a/backend/pb_schema.json +++ b/backend/pb_schema.json @@ -1,100 +1,86 @@ [ { - "id": "_pb_users_auth_", - "name": "users", - "type": "auth", + "id": "945dqudy3xbgwei", + "name": "items", + "type": "base", "system": false, "schema": [ { - "id": "users_name", + "id": "wosysey5", "name": "name", "type": "text", "system": false, - "required": false, + "required": true, "options": { "min": null, "max": null, "pattern": "" } - } - ], - "indexes": [], - "listRule": "id = @request.auth.id", - "viewRule": "id = @request.auth.id", - "createRule": "", - "updateRule": "id = @request.auth.id", - "deleteRule": "id = @request.auth.id", - "options": { - "allowEmailAuth": false, - "allowOAuth2Auth": false, - "allowUsernameAuth": true, - "exceptEmailDomains": null, - "manageRule": null, - "minPasswordLength": 8, - "onlyEmailDomains": null, - "requireEmail": false - } - }, - { - "id": "pcgfoipcvldtrjb", - "name": "categories", - "type": "base", - "system": false, - "schema": [ + }, { - "id": "ytn1hobj", - "name": "name", - "type": "text", + "id": "pxvwelcb", + "name": "quantity", + "type": "number", "system": false, "required": true, "options": { "min": null, - "max": null, - "pattern": "" + "max": null } }, { - "id": "ckwtmrxr", - "name": "order", - "type": "number", + "id": "1o8z4fwo", + "name": "category", + "type": "relation", "system": false, "required": false, "options": { - "min": null, - "max": null + "collectionId": "pcgfoipcvldtrjb", + "cascadeDelete": false, + "minSelect": null, + "maxSelect": 1, + "displayFields": [] } }, { - "id": "lvscdvnh", - "name": "user", + "id": "wioktqu4", + "name": "list", "type": "relation", "system": false, "required": true, "options": { - "collectionId": "_pb_users_auth_", + "collectionId": "7hl5j2inxaqx1by", "cascadeDelete": false, "minSelect": null, "maxSelect": 1, "displayFields": [] } + }, + { + "id": "hp9nitep", + "name": "picked", + "type": "bool", + "system": false, + "required": false, + "options": {} } ], "indexes": [], - "listRule": "@request.auth.id = user", - "viewRule": "@request.auth.id = user", - "createRule": "@request.auth.id = user", - "updateRule": "@request.auth.id = user", - "deleteRule": "@request.auth.id = user", + "listRule": "", + "viewRule": "", + "createRule": "", + "updateRule": "", + "deleteRule": "", "options": {} }, { - "id": "945dqudy3xbgwei", - "name": "items", + "id": "7hl5j2inxaqx1by", + "name": "lists", "type": "base", "system": false, "schema": [ { - "id": "wosysey5", + "id": "8maejv7f", "name": "name", "type": "text", "system": false, @@ -106,24 +92,87 @@ } }, { - "id": "pxvwelcb", - "name": "quantity", - "type": "number", + "id": "rqjs1ran", + "name": "isTemplate", + "type": "bool", + "system": false, + "required": false, + "options": {} + }, + { + "id": "icnarmhj", + "name": "owner", + "type": "relation", "system": false, "required": true, "options": { - "min": null, - "max": null + "collectionId": "_pb_users_auth_", + "cascadeDelete": false, + "minSelect": null, + "maxSelect": 1, + "displayFields": [] } }, { - "id": "1o8z4fwo", - "name": "category", + "id": "idfzoynb", + "name": "sharedWith", "type": "relation", "system": false, "required": false, "options": { - "collectionId": "pcgfoipcvldtrjb", + "collectionId": "_pb_users_auth_", + "cascadeDelete": false, + "minSelect": null, + "maxSelect": null, + "displayFields": [] + } + } + ], + "indexes": [], + "listRule": "@request.auth.id = owner || @request.auth.id ?= sharedWith.id", + "viewRule": "@request.auth.id = owner || @request.auth.id ?= sharedWith.id", + "createRule": "@request.auth.id = owner", + "updateRule": "@request.auth.id = owner", + "deleteRule": "@request.auth.id = owner", + "options": {} + }, + { + "id": "_pb_users_auth_", + "name": "users", + "type": "auth", + "system": false, + "schema": [], + "indexes": [], + "listRule": "", + "viewRule": "", + "createRule": "", + "updateRule": "id = @request.auth.id", + "deleteRule": "id = @request.auth.id", + "options": { + "allowEmailAuth": true, + "allowOAuth2Auth": false, + "allowUsernameAuth": true, + "exceptEmailDomains": null, + "manageRule": null, + "minPasswordLength": 8, + "onlyEmailDomains": null, + "requireEmail": true + } + }, + { + "id": "2jwei9mpvwduqyj", + "name": "invitations", + "type": "base", + "system": false, + "schema": [ + { + "id": "xwzxywuu", + "name": "owner", + "type": "relation", + "system": false, + "required": false, + "options": { + "collectionId": "_pb_users_auth_", "cascadeDelete": false, "minSelect": null, "maxSelect": 1, @@ -131,11 +180,11 @@ } }, { - "id": "garp0pqf", - "name": "user", + "id": "01se4eh7", + "name": "guest", "type": "relation", "system": false, - "required": true, + "required": false, "options": { "collectionId": "_pb_users_auth_", "cascadeDelete": false, @@ -145,11 +194,11 @@ } }, { - "id": "wioktqu4", + "id": "plw0rv47", "name": "list", "type": "relation", "system": false, - "required": true, + "required": false, "options": { "collectionId": "7hl5j2inxaqx1by", "cascadeDelete": false, @@ -159,30 +208,49 @@ } }, { - "id": "hp9nitep", - "name": "picked", - "type": "bool", + "id": "hoyaw1qe", + "name": "state", + "type": "select", "system": false, "required": false, - "options": {} + "options": { + "maxSelect": 1, + "values": [ + "Pending", + "Accepted", + "Declined" + ] + } + }, + { + "id": "fp4wauvn", + "name": "ownerName", + "type": "text", + "system": false, + "required": false, + "options": { + "min": null, + "max": null, + "pattern": "" + } } ], "indexes": [], - "listRule": "@request.auth.id = user", - "viewRule": "@request.auth.id = user", - "createRule": "@request.auth.id = user", - "updateRule": "@request.auth.id = user", - "deleteRule": "@request.auth.id = user", + "listRule": "@request.auth.id = owner || @request.auth.id = guest", + "viewRule": "@request.auth.id = owner || @request.auth.id = guest", + "createRule": "@request.auth.id = owner", + "updateRule": "@request.auth.id = owner || @request.auth.id = guest", + "deleteRule": "@request.auth.id = owner ", "options": {} }, { - "id": "7hl5j2inxaqx1by", - "name": "lists", + "id": "pcgfoipcvldtrjb", + "name": "categories", "type": "base", "system": false, "schema": [ { - "id": "8maejv7f", + "id": "ytn1hobj", "name": "name", "type": "text", "system": false, @@ -194,16 +262,19 @@ } }, { - "id": "rqjs1ran", - "name": "isTemplate", - "type": "bool", + "id": "ckwtmrxr", + "name": "order", + "type": "number", "system": false, "required": false, - "options": {} + "options": { + "min": null, + "max": null + } }, { - "id": "icnarmhj", - "name": "user", + "id": "lvscdvnh", + "name": "owner", "type": "relation", "system": false, "required": true, @@ -217,11 +288,11 @@ } ], "indexes": [], - "listRule": "@request.auth.id = user", - "viewRule": "@request.auth.id = user", - "createRule": "@request.auth.id = user", - "updateRule": "@request.auth.id = user", - "deleteRule": "@request.auth.id = user", + "listRule": "", + "viewRule": "", + "createRule": "@request.auth.id = owner", + "updateRule": "@request.auth.id = owner", + "deleteRule": "@request.auth.id = owner", "options": {} } ] \ No newline at end of file diff --git a/src/lib/components/Divider.svelte b/src/lib/components/Divider.svelte new file mode 100644 index 0000000..7045baf --- /dev/null +++ b/src/lib/components/Divider.svelte @@ -0,0 +1 @@ +
diff --git a/src/lib/components/Footer.svelte b/src/lib/components/Footer.svelte index b536fe9..f74d74f 100644 --- a/src/lib/components/Footer.svelte +++ b/src/lib/components/Footer.svelte @@ -9,9 +9,7 @@
- -
diff --git a/src/lib/components/Icon.svelte b/src/lib/components/Icon.svelte index e8fcb0a..11933a0 100644 --- a/src/lib/components/Icon.svelte +++ b/src/lib/components/Icon.svelte @@ -97,5 +97,23 @@ fill={stroke} d="M2.5 11h19a.5.5 0 0 0 0-1h-19a.5.5 0 0 0 0 1zm19 3h-19a.5.5 0 0 0 0 1h19a.5.5 0 0 0 0-1z" /> + {:else if type === IconType.Accept} + + {:else if type === IconType.Decline} + {/if} diff --git a/src/lib/components/Item.svelte b/src/lib/components/Item.svelte index d2ac06a..78567c4 100644 --- a/src/lib/components/Item.svelte +++ b/src/lib/components/Item.svelte @@ -32,10 +32,10 @@ {item.picked && 'opacity-50'}" >
- {#if $isPlanModeActive} -
- - - -
+
+ + + +
{/if}
{/if} diff --git a/src/lib/components/List.svelte b/src/lib/components/List.svelte deleted file mode 100644 index 6cc717c..0000000 --- a/src/lib/components/List.svelte +++ /dev/null @@ -1,24 +0,0 @@ - - - -
-

- {list.name} -

-
-
diff --git a/src/lib/components/ListButton.svelte b/src/lib/components/ListButton.svelte new file mode 100644 index 0000000..4ddaf7d --- /dev/null +++ b/src/lib/components/ListButton.svelte @@ -0,0 +1,86 @@ + + +{#if (invitation && invitation.state !== InvitationState.Declined && isShared) || !isShared} +
+ {#if invitation && invitation.state === InvitationState.Pending} +
+
+
+

Invitation to

+ +

+ {list.name} +

+ +

+ from {invitation.ownerName} +

+
+
+
+ +
+ + +
+
+
+ {:else} + +
+

+ {list.name} +

+
+
+ {#if invitation && invitation.state === InvitationState.Accepted && isShared} +
+ +
+ {/if} + {/if} +
+{/if} diff --git a/src/lib/components/SubTitle.svelte b/src/lib/components/SubTitle.svelte index df6db2b..28e9579 100644 --- a/src/lib/components/SubTitle.svelte +++ b/src/lib/components/SubTitle.svelte @@ -2,7 +2,7 @@ export let title: string; -
+
{title}
diff --git a/src/lib/models/Category.ts b/src/lib/models/Category.ts index e08e0bb..fb2b2e4 100644 --- a/src/lib/models/Category.ts +++ b/src/lib/models/Category.ts @@ -1,6 +1,6 @@ export type Category = { id: string; name: string; - user: string; + owner: string; order: number; }; diff --git a/src/lib/models/Invitation.ts b/src/lib/models/Invitation.ts new file mode 100644 index 0000000..175d44a --- /dev/null +++ b/src/lib/models/Invitation.ts @@ -0,0 +1,10 @@ +import type { InvitationState } from '$lib/types/InvitationState'; + +export type Invitation = { + id: string; + list: string; + owner: string; + ownerName: string; + guest: string; + state: InvitationState; +}; diff --git a/src/lib/models/Item.ts b/src/lib/models/Item.ts index 470175f..c7e0316 100644 --- a/src/lib/models/Item.ts +++ b/src/lib/models/Item.ts @@ -5,5 +5,4 @@ export type Item = { picked: boolean; category: string | null; list: string; - user: string; }; diff --git a/src/lib/models/List.ts b/src/lib/models/List.ts index a54dae5..1366d87 100644 --- a/src/lib/models/List.ts +++ b/src/lib/models/List.ts @@ -2,5 +2,6 @@ export type List = { id: string; name: string; isTemplate: boolean; - user: string; + owner: string; + sharedWith: string[]; }; diff --git a/src/lib/pocketbase.ts b/src/lib/pocketbase.ts index e109a50..998724b 100644 --- a/src/lib/pocketbase.ts +++ b/src/lib/pocketbase.ts @@ -5,6 +5,9 @@ import type { Item } from './models/Item'; import type { Category } from './models/Category'; import { deepClone } from './util'; import type { List } from './models/List'; +import type { User } from './models/User'; +import { InvitationState } from './types/InvitationState'; +import type { Invitation } from './models/Invitation'; export const pb = new PocketBase(PUBLIC_POCKETBASE_URL); @@ -21,8 +24,7 @@ export async function getItemsInListQuery(listId: string, picked: boolean = fals picked: item.picked, quantity: item.quantity, category: item.category ? item.category : null, - list: item.list, - user: item.user + list: item.list } as Item; }); } @@ -32,7 +34,7 @@ export async function getCategoryQuery(id: string) { return { id: category.id, name: category.name, - user: category.user, + owner: category.owner, order: category.order } as Category; } @@ -52,39 +54,62 @@ export async function getItemsPerCategory(id: string) { picked: item.picked, quantity: item.quantity, category: item.category ? item.category : null, - list: item.list, - user: item.user + list: item.list } as Item; }); } -export async function getCategoriesQuery() { - const categories = deepClone(await pb.collection('categories').getFullList()); +export async function getMyCategoriesQuery() { + const userId = pb.authStore.model?.id; + const categories = deepClone( + await pb.collection('categories').getFullList({ filter: `owner = "${userId}"` }) + ); return categories .map((category) => { return { id: category.id, name: category.name, - user: category.user, + owner: category.owner, order: category.order } as Category; }) .sort((a, b) => a.order - b.order); } +export async function createCategoryQuery(name: string) { + const userId = pb.authStore.model?.id; + + // check if category already exists + const categories = await pb.collection('categories').getFullList({ + filter: `owner = "${userId}" && name = "${name}"` + }); + if (categories.length > 0) { + return categories[0]; + } + + const cat = await pb.collection('categories').create({ + name: name, + owner: userId, + order: 0 + }); + return { + id: cat.id, + name: cat.name, + owner: cat.owner, + order: cat.order + } as Category; +} + export async function getListsQuery() { - const lists = deepClone( - await pb.collection('lists').getFullList({ - filter: 'created >= "2022-01-01 00:00:00"' - }) - ); + const lists = deepClone(await pb.collection('lists').getFullList()); return lists.map((list) => { return { id: list.id, name: list.name, isTemplate: list.isTemplate, - user: list.user + owner: list.owner, + sharedWith: list.sharedWith } as List; }); } @@ -92,7 +117,7 @@ export async function getListsQuery() { export async function getTemplatesQuery() { const templates = deepClone( await pb.collection('lists').getFullList({ - filter: 'created >= "2022-01-01 00:00:00" && isTemplate = true' + filter: 'isTemplate = true' }) ); return templates.map((template) => { @@ -100,7 +125,8 @@ export async function getTemplatesQuery() { id: template.id, name: template.name, isTemplate: template.isTemplate, - user: template.user + owner: template.owner, + sharedWith: template.sharedWith } as List; }); } @@ -111,7 +137,8 @@ export async function getListQuery(listId: string) { id: list.id, name: list.name, isTemplate: list.isTemplate, - user: list.user + owner: list.owner, + sharedWith: list.sharedWith } as List; } @@ -167,3 +194,118 @@ export async function deleteCategoryAndAllItemsQuery(categoryId: string) { export async function updateItemQuery(item: Item) { await pb.collection('items').update(item.id, item); } + +export async function getUserByUsernameOrEmailQuery(usernameEmail: string) { + const users = await pb + .collection('users') + .getList(1, 50, { filter: `username = "${usernameEmail}" || email = "${usernameEmail}"` }); + return users.items.length > 0 ? users.items[0] : undefined; +} + +export async function getUserByIdQuery(id: string) { + const user = await pb.collection('users').getOne(id); + return { + id: user.id, + username: user.username, + email: user.email + } as User; +} + +export async function inviteUserToListQuery( + owner: string, + guest: string, + list: string, + ownerName: string +) { + const invitations = await pb + .collection('invitations') + .getFullList({ filter: `guest = "${guest}" && list = "${list}"` }); + if (invitations.length > 0) { + // if any invitation has InvitationState.Accepted, return + if (invitations.some((i) => i.state === InvitationState.Accepted)) { + return; + } + + // if all invitations have InvitationState.Pending, return + if (invitations.every((i) => i.state === InvitationState.Pending)) { + return; + } + } + + const invite = { + owner, + guest, + list, + ownerName, + state: InvitationState.Pending as string + }; + await pb.collection('invitations').create(invite); + + await pb + .collection('lists') + .update(list, { sharedWith: [...(await getListQuery(list)).sharedWith, guest] }); +} + +export async function removeGuestFromListQuery(guest: string, listId: string) { + const list = await getListQuery(listId); + await pb + .collection('lists') + .update(listId, { sharedWith: list.sharedWith.filter((g) => g !== guest) }); +} + +export async function removeInvitationsForListAndGuestQuery(guest: string, list: string) { + const invitations = await pb + .collection('invitations') + .getFullList({ filter: `guest = "${guest}" && list = "${list}"` }); + + await Promise.all( + invitations.map(async (invitation) => await pb.collection('invitations').delete(invitation.id)) + ); +} + +export async function updateInvitationStateQuery(invitationId: string, state: InvitationState) { + await pb.collection('invitations').update(invitationId, { state: state as string }); +} + +export async function getInvitationsQuery() { + const userId = pb.authStore.model?.id; + const invitations = await pb + .collection('invitations') + .getList(1, 50, { filter: `guest = "${userId}"`, sort: '-created' }); + + return invitations.items.map((invitation) => { + return { + id: invitation.id, + owner: invitation.owner, + ownerName: invitation.ownerName, + guest: invitation.guest, + list: invitation.list, + state: invitation.state as InvitationState + } as Invitation; + }); +} + +export async function updateListSharedWithBasedOnInvitationsQuery(listId: string) { + const invitations = await pb.collection('invitations').getList(1, 50, { + filter: `list = "${listId}" && state = "${InvitationState.Accepted}" || state = "${InvitationState.Pending}"` + }); + + const sharedWith = invitations.items.map((invitation) => invitation.guest); + + await pb.collection('lists').update(listId, { sharedWith }); +} + +export async function unfollowListQuery(listId: string) { + const invitations = await pb.collection('invitations').getList(1, 50, { + filter: `list = "${listId}" && state = "${InvitationState.Accepted}" || state = "${InvitationState.Pending}"` + }); + + await Promise.all( + invitations.items.map( + async (invitation) => + await pb + .collection('invitations') + .update(invitation.id, { state: InvitationState.Declined as string }) + ) + ); +} diff --git a/src/lib/types/IconType.ts b/src/lib/types/IconType.ts index b179c6c..3689887 100644 --- a/src/lib/types/IconType.ts +++ b/src/lib/types/IconType.ts @@ -9,5 +9,7 @@ export enum IconType { Template = 'template', // https://api.iconify.design/octicon:stack-24.svg Invite = 'invite', // https://api.iconify.design/iconamoon:send-light.svg Shared = 'shared', // https://api.iconify.design/iconamoon:link-light.svg - Drag = 'drag' // https://api.iconify.design/material-symbols:drag-handle-rounded.svg + Drag = 'drag', // https://api.iconify.design/material-symbols:drag-handle-rounded.svg + Accept = 'accept', // https://api.iconify.design/iconamoon:check-light.svg + Decline = 'decline' // https://api.iconify.design/iconamoon:close-light.svg } diff --git a/src/lib/types/InvitationState.ts b/src/lib/types/InvitationState.ts new file mode 100644 index 0000000..cbf0a68 --- /dev/null +++ b/src/lib/types/InvitationState.ts @@ -0,0 +1,5 @@ +export enum InvitationState { + Pending = 'Pending', + Accepted = 'Accepted', + Declined = 'Declined' +} diff --git a/src/routes/categories/+page.server.ts b/src/routes/categories/+page.server.ts index 98e97e1..e03b92a 100644 --- a/src/routes/categories/+page.server.ts +++ b/src/routes/categories/+page.server.ts @@ -1,4 +1,4 @@ -import { getCategoriesQuery } from '$lib/pocketbase'; +import { getMyCategoriesQuery } from '$lib/pocketbase'; import { redirect, type Actions } from '@sveltejs/kit'; export const load = ({ locals }) => { @@ -8,7 +8,7 @@ export const load = ({ locals }) => { const getCategories = async () => { try { - const categories = getCategoriesQuery(); + const categories = getMyCategoriesQuery(); return categories; } catch (err) { console.error(err); diff --git a/src/routes/lists/+page.server.ts b/src/routes/lists/+page.server.ts index f56fa1b..d28084b 100644 --- a/src/routes/lists/+page.server.ts +++ b/src/routes/lists/+page.server.ts @@ -1,5 +1,6 @@ -import { getListsQuery } from '$lib/pocketbase'; -import { redirect } from '@sveltejs/kit'; +import { getInvitationsQuery, getListsQuery, updateInvitationStateQuery } from '$lib/pocketbase'; +import { InvitationState } from '$lib/types/InvitationState.js'; +import { redirect, type Actions } from '@sveltejs/kit'; export const load = ({ locals }) => { if (!locals.pb.authStore.isValid) { @@ -14,8 +15,42 @@ export const load = ({ locals }) => { throw err; } }; + const getInvitations = async () => { + try { + return await getInvitationsQuery(); + } catch (err) { + console.error(err); + throw err; + } + }; return { - lists: getLists() + lists: getLists(), + invitations: getInvitations() }; }; + +export const actions: Actions = { + acceptInvitation: async ({ request }) => { + const values = await request.formData(); + const id = String(values.get('invitationId')); + try { + await updateInvitationStateQuery(id, InvitationState.Accepted); + } catch (e) { + console.error(e); + throw e; + } + return { success: true }; + }, + declineInvitation: async ({ request }) => { + const values = await request.formData(); + const id = String(values.get('invitationId')); + try { + await updateInvitationStateQuery(id, InvitationState.Declined); + } catch (e) { + console.error(e); + throw e; + } + return { success: true }; + } +}; diff --git a/src/routes/lists/+page.svelte b/src/routes/lists/+page.svelte index d1ecafe..e2e3c85 100644 --- a/src/routes/lists/+page.svelte +++ b/src/routes/lists/+page.svelte @@ -1,6 +1,6 @@
@@ -22,24 +30,96 @@
+
+ {#if list.owner === user?.id} +
+
+ - -
- + - +
- -
-
+ + + + +
+
+
+ + +
+
+ + {#if data.guests.length > 0} + + {#each data.guests as guest} +
+
+ + +
+
{guest.username}
+
+ +
+
+ + {#if form?.message && form?.type === 'removeGuest'} +
{form.message}
+ {:else if form?.success && form?.type === 'removeGuest'} +
+ Removed {guest.username} from the list. +
+ {/if} +
+
+ {/each} + {/if} +
+ + + +
{#if openModal} diff --git a/src/routes/lists/new/+page.server.ts b/src/routes/lists/new/+page.server.ts index 51cde51..a1f4ed5 100644 --- a/src/routes/lists/new/+page.server.ts +++ b/src/routes/lists/new/+page.server.ts @@ -19,7 +19,7 @@ export const actions: Actions = { const newList = { name, isTemplate, - user + owner: user }; try { diff --git a/src/routes/login/+page.server.ts b/src/routes/login/+page.server.ts index 0e685a0..a2fa4cb 100644 --- a/src/routes/login/+page.server.ts +++ b/src/routes/login/+page.server.ts @@ -8,11 +8,14 @@ export const actions: Actions = { } const data = Object.fromEntries(await request.formData()) as { - username: string; + usernameOrEmail: string; password: string; }; + + data.usernameOrEmail = data.usernameOrEmail.trim(); + try { - await locals.pb.collection('users').authWithPassword(data.username, data.password); + await locals.pb.collection('users').authWithPassword(data.usernameOrEmail, data.password); } catch (e) { return fail(400, { data, incorrect: true }); } diff --git a/src/routes/login/+page.svelte b/src/routes/login/+page.svelte index f387283..5e18bea 100644 --- a/src/routes/login/+page.svelte +++ b/src/routes/login/+page.svelte @@ -10,6 +10,10 @@ +
+ + </div> + <form method="POST" class="card" @@ -20,15 +24,11 @@ }; }} > - <div class="w-full mb-4"> - <Title title="Log in" /> - </div> - <div class="flex flex-col gap-4"> <input type="text" - name="username" - placeholder="Username" + name="usernameOrEmail" + placeholder="Username or Email" class="bg-neutral px-4 text-md text-gray-700 border-2 border-gray-700 font-semibold rounded h-12 shadow-sm" class:bg-red-100={form?.incorrect} /> @@ -39,15 +39,21 @@ class="bg-neutral px-4 text-md text-gray-700 border-2 border-gray-700 font-semibold rounded h-12 shadow-sm" class:bg-red-100={form?.incorrect} /> + + <Button text={'Log in'} backgroundColor={'primary'} /> + {#if form?.incorrect} <p class="text-center text-red-500">Invalid credentials</p> {/if} - <Button text={'Log in'} backgroundColor={'primary'} /> <p class="text-center"> Don't have an account? <a href="/register" class="underline">Register now!</a> </p> + <p class="text-center text-sm"> + Forgot your password? + <a href="/password-reset" class="underline">Reset it</a> + </p> </div> </form> </LayoutContainer> diff --git a/src/routes/password-reset/+page.server.ts b/src/routes/password-reset/+page.server.ts new file mode 100644 index 0000000..3e78679 --- /dev/null +++ b/src/routes/password-reset/+page.server.ts @@ -0,0 +1,24 @@ +import { fail, redirect } from '@sveltejs/kit'; +import type { Actions } from './$types'; + +export const actions: Actions = { + default: async ({ locals, request }) => { + if (locals.user) { + throw redirect(303, '/lists'); + } + + const data = Object.fromEntries(await request.formData()) as { + email: string; + }; + + data.email = data.email.trim(); + + try { + await locals.pb.admins.requestPasswordReset(data.email); + } catch (e) { + return fail(400, { data, incorrect: true }); + } + + return { success: true, email: data.email }; + } +}; diff --git a/src/routes/password-reset/+page.svelte b/src/routes/password-reset/+page.svelte new file mode 100644 index 0000000..097d8c5 --- /dev/null +++ b/src/routes/password-reset/+page.svelte @@ -0,0 +1,53 @@ +<script lang="ts"> + import { applyAction, enhance } from '$app/forms'; + import Button from '$lib/components/Button.svelte'; + import LayoutContainer from '$lib/components/LayoutContainer.svelte'; + import Title from '$lib/components/Title.svelte'; + import { pb } from '$lib/pocketbase'; + import type { ActionData } from './$types'; + + export let form: ActionData; +</script> + +<LayoutContainer> + <div class="w-full mb-4"> + <Title title="Password reset" /> + </div> + + <form + method="POST" + use:enhance={() => { + return async ({ result }) => { + pb.authStore.loadFromCookie(document.cookie); + await applyAction(result); + }; + }} + > + <div class="flex flex-col gap-4"> + <input + type="text" + name="email" + placeholder="Email" + class="bg-neutral px-4 text-md text-gray-700 border-2 border-gray-700 font-semibold rounded h-12 shadow-sm" + class:bg-red-100={form?.incorrect} + /> + + <Button text={'Send reset email'} backgroundColor={'primary'} /> + + {#if form?.incorrect} + <p class="text-center text-lg text-red-500">Could not reset</p> + {/if} + + {#if form?.success} + <p class="text-center text-lg text-green-400 "> + Email sent to <strong> {form.email} </strong> + </p> + {/if} + + <p class="text-center"> + Back to + <a href="/login" class="underline">login</a> + </p> + </div> + </form> +</LayoutContainer> diff --git a/src/routes/profile/+page.server.ts b/src/routes/profile/+page.server.ts index baafbe1..b71df2e 100644 --- a/src/routes/profile/+page.server.ts +++ b/src/routes/profile/+page.server.ts @@ -1,4 +1,4 @@ -import { redirect } from '@sveltejs/kit'; +import { redirect, type Actions, fail } from '@sveltejs/kit'; export const load = ({ locals }) => { if (!locals.pb.authStore.isValid) { @@ -7,6 +7,25 @@ export const load = ({ locals }) => { return { email: locals.pb.authStore.model.email, - username: locals.pb.authStore.model.username + username: locals.pb.authStore.model.username, + id: locals.pb.authStore.model.id }; }; + +export const actions: Actions = { + updateProfile: async ({ request, locals }) => { + const values = await request.formData(); + const uname = values.get('username') as string | null; + const id = values.get('id') as string | null; + + if (!id || !uname) return; + const username = uname.trim(); + + try { + await locals.pb.collection('users').update(id, { username }); + } catch (err) { + return fail(400, { incorrect: true }); + } + return { success: true }; + } +}; diff --git a/src/routes/profile/+page.svelte b/src/routes/profile/+page.svelte index dde50a5..c5f9f5c 100644 --- a/src/routes/profile/+page.svelte +++ b/src/routes/profile/+page.svelte @@ -2,12 +2,13 @@ import { goto } from '$app/navigation'; import Button from '$lib/components/Button.svelte'; import LayoutContainer from '$lib/components/LayoutContainer.svelte'; + import SubTitle from '$lib/components/SubTitle.svelte'; import Title from '$lib/components/Title.svelte'; import { pb } from '$lib/pocketbase'; - import type { PageData } from './$types'; + import type { ActionData, PageData } from './$types'; export let data: PageData; - + export let form: ActionData; const logout = () => { pb.authStore.clear(); goto('/'); @@ -22,18 +23,41 @@ </div> </div> - <div class="flex-col justify-between flex-wrap gap-2"> - <div class="flex gap-4"> - <div class="font-bold text-xl text-primary">Email:</div> - <div class="text-lg"> - {data.email} - </div> + <div class="flex gap-4 my-4"> + <div class="font-bold text-xl text-primary">Email:</div> + <div class="text-xl"> + {data.email} </div> - <div class="flex gap-4"> - <div class="font-bold text-xl text-primary">Username:</div> - <div class="text-lg"> - {data.username} - </div> + </div> + + <form action="?/updateProfile" method="POST"> + <div class="flex gap-4 my-4 items-center"> + <div class="font-bold text-xl text-primary">Username</div> + + <input + type="text" + name="username" + placeholder={data.username || 'Username'} + value={data.username} + class="bg-neutral w-full px-4 text-md text-gray-700 border-2 border-gray-700 font-semibold rounded h-12 shadow-sm" + /> </div> + + <input type="hidden" name="id" value={data.id} /> + + <Button text="Update Username" /> + </form> + {#if form?.success} + <div class="text-green-400 text-center mt-2">Username updated!</div> + {/if} + {#if form?.incorrect} + <div class="text-red-400 text-center mt-2">Something went wrong: {form?.incorrect}</div> + {/if} + + <div class="w-full"> + <SubTitle title="Categories" /> + <a href="/categories"> + <Button text="MANAGE CATEGORIES" /> + </a> </div> </LayoutContainer> diff --git a/src/routes/register/+page.server.ts b/src/routes/register/+page.server.ts index 107a61d..f81a5c4 100644 --- a/src/routes/register/+page.server.ts +++ b/src/routes/register/+page.server.ts @@ -8,7 +8,7 @@ export const actions: Actions = { } const data = Object.fromEntries(await request.formData()) as { - username: string; + email: string; password: string; passwordConfirm: string; }; @@ -17,9 +17,11 @@ export const actions: Actions = { return fail(400, { passwordMatchError: true }); } + data.email = data.email.trim(); + try { await locals.pb.collection('users').create(data); - await locals.pb.collection('users').authWithPassword(data.username, data.password); + await locals.pb.collection('users').authWithPassword(data.email, data.password); } catch (e) { return fail(400, { incorrect: true }); } diff --git a/src/routes/register/+page.svelte b/src/routes/register/+page.svelte index 1d9aba7..aaa7ae0 100644 --- a/src/routes/register/+page.svelte +++ b/src/routes/register/+page.svelte @@ -10,6 +10,10 @@ </script> <LayoutContainer> + <div class="w-full mb-4"> + <Title title="Register new account" /> + </div> + <form method="POST" class="card" @@ -20,15 +24,11 @@ }; }} > - <div class="w-full mb-4"> - <Title title="Register new account" /> - </div> - <div class="flex flex-col gap-4"> <input type="text" - name="username" - placeholder="Username" + name="email" + placeholder="Email" class="bg-neutral px-4 text-md text-gray-700 border-2 border-gray-700 font-semibold rounded h-12 shadow-sm" /> <input @@ -43,12 +43,14 @@ placeholder="Confirm Password" class="bg-neutral px-4 text-md text-gray-700 border-2 border-gray-700 font-semibold rounded h-12 shadow-sm" /> + + <Button text="Register" /> + {#if form?.incorrect} <p class="text-center text-red-500">We had trouble creating your account.</p> {:else if form?.passwordMatchError} <p class="text-center text-red-500">Passwords don't match.</p> {/if} - <Button text="Register" /> <p class="text-center"> Already have an account? <a href="/login" class="underline">Log in!</a> diff --git a/src/routes/templates/+page.svelte b/src/routes/templates/+page.svelte deleted file mode 100644 index 88df6b4..0000000 --- a/src/routes/templates/+page.svelte +++ /dev/null @@ -1,8 +0,0 @@ -<script> - import LayoutContainer from "$lib/components/LayoutContainer.svelte"; -import Title from "$lib/components/Title.svelte"; -</script> - -<LayoutContainer> - <Title title="Templates" /> -</LayoutContainer> \ No newline at end of file