diff --git a/apps/api/migrations/0006_chilly_scream.sql b/apps/api/migrations/0006_chilly_scream.sql new file mode 100644 index 00000000..240e4247 --- /dev/null +++ b/apps/api/migrations/0006_chilly_scream.sql @@ -0,0 +1,3 @@ +ALTER TABLE "toasts" ADD COLUMN "id" bigserial NOT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS "toast_unq" ON "toasts" ("tasting_id","created_by_id"); +ALTER TABLE "toasts" DROP CONSTRAINT "toasts_tasting_id_created_by_id"; \ No newline at end of file diff --git a/apps/api/migrations/meta/0006_snapshot.json b/apps/api/migrations/meta/0006_snapshot.json new file mode 100644 index 00000000..664d23f8 --- /dev/null +++ b/apps/api/migrations/meta/0006_snapshot.json @@ -0,0 +1,899 @@ +{ + "version": "5", + "dialect": "pg", + "id": "4d9ed8e9-e31c-4ba1-b7be-32df8f6402d9", + "prevId": "26d3ec70-5451-4b6e-9bb7-3458550ce958", + "tables": { + "bottle": { + "name": "bottle", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "category", + "primaryKey": false, + "notNull": false + }, + "brand_id": { + "name": "brand_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "stated_age": { + "name": "stated_age", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "total_tastings": { + "name": "total_tastings", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by_id": { + "name": "created_by_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "bottle_brand_unq": { + "name": "bottle_brand_unq", + "columns": [ + "name", + "brand_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "bottle_brand_id_entity_id_fk": { + "name": "bottle_brand_id_entity_id_fk", + "tableFrom": "bottle", + "tableTo": "entity", + "columnsFrom": [ + "brand_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "bottle_created_by_id_user_id_fk": { + "name": "bottle_created_by_id_user_id_fk", + "tableFrom": "bottle", + "tableTo": "user", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {} + }, + "bottle_distiller": { + "name": "bottle_distiller", + "schema": "", + "columns": { + "bottle_id": { + "name": "bottle_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "distiller_id": { + "name": "distiller_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "bottle_distiller_bottle_id_bottle_id_fk": { + "name": "bottle_distiller_bottle_id_bottle_id_fk", + "tableFrom": "bottle_distiller", + "tableTo": "bottle", + "columnsFrom": [ + "bottle_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "bottle_distiller_distiller_id_entity_id_fk": { + "name": "bottle_distiller_distiller_id_entity_id_fk", + "tableFrom": "bottle_distiller", + "tableTo": "entity", + "columnsFrom": [ + "distiller_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "bottle_distiller_bottle_id_distiller_id": { + "name": "bottle_distiller_bottle_id_distiller_id", + "columns": [ + "bottle_id", + "distiller_id" + ] + } + } + }, + "change": { + "name": "change", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "object_id": { + "name": "object_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "object_type": { + "name": "object_type", + "type": "object_type", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by_id": { + "name": "created_by_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "change_created_by_id_user_id_fk": { + "name": "change_created_by_id_user_id_fk", + "tableFrom": "change", + "tableTo": "user", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {} + }, + "edition": { + "name": "edition", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "barrel": { + "name": "barrel", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "bottle_id": { + "name": "bottle_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by_id": { + "name": "created_by_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "edition_unq": { + "name": "edition_unq", + "columns": [ + "bottle_id", + "name", + "barrel" + ], + "isUnique": true + } + }, + "foreignKeys": { + "edition_bottle_id_bottle_id_fk": { + "name": "edition_bottle_id_bottle_id_fk", + "tableFrom": "edition", + "tableTo": "bottle", + "columnsFrom": [ + "bottle_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "edition_created_by_id_user_id_fk": { + "name": "edition_created_by_id_user_id_fk", + "tableFrom": "edition", + "tableTo": "user", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {} + }, + "entity": { + "name": "entity", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "country": { + "name": "country", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "entity_type[]", + "primaryKey": false, + "notNull": true + }, + "total_bottles": { + "name": "total_bottles", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_tastings": { + "name": "total_tastings", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by_id": { + "name": "created_by_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "entity_name_unq": { + "name": "entity_name_unq", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": { + "entity_created_by_id_user_id_fk": { + "name": "entity_created_by_id_user_id_fk", + "tableFrom": "entity", + "tableTo": "user", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {} + }, + "follow": { + "name": "follow", + "schema": "", + "columns": { + "from_user_id": { + "name": "from_user_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "to_user_id": { + "name": "to_user_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "follow_status", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "follow_from_user_id_user_id_fk": { + "name": "follow_from_user_id_user_id_fk", + "tableFrom": "follow", + "tableTo": "user", + "columnsFrom": [ + "from_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "follow_to_user_id_user_id_fk": { + "name": "follow_to_user_id_user_id_fk", + "tableFrom": "follow", + "tableTo": "user", + "columnsFrom": [ + "to_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "follow_from_user_id_to_user_id": { + "name": "follow_from_user_id_to_user_id", + "columns": [ + "from_user_id", + "to_user_id" + ] + } + } + }, + "identity": { + "name": "identity", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "identity_provider", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "identity_unq": { + "name": "identity_unq", + "columns": [ + "provider", + "external_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "identity_user_id_user_id_fk": { + "name": "identity_user_id_user_id_fk", + "tableFrom": "identity", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {} + }, + "notifications": { + "name": "notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "from_user_id": { + "name": "from_user_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "object_id": { + "name": "object_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "object_type": { + "name": "object_type", + "type": "object_type", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "read": { + "name": "read", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "notifications_unq": { + "name": "notifications_unq", + "columns": [ + "user_id", + "object_id", + "object_type", + "created_at" + ], + "isUnique": true + } + }, + "foreignKeys": { + "notifications_user_id_user_id_fk": { + "name": "notifications_user_id_user_id_fk", + "tableFrom": "notifications", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "notifications_from_user_id_user_id_fk": { + "name": "notifications_from_user_id_user_id_fk", + "tableFrom": "notifications", + "tableTo": "user", + "columnsFrom": [ + "from_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {} + }, + "tasting": { + "name": "tasting", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "bottle_id": { + "name": "bottle_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "edition_id": { + "name": "edition_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "comments": { + "name": "comments", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "rating": { + "name": "rating", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by_id": { + "name": "created_by_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "tasting_bottle_id_bottle_id_fk": { + "name": "tasting_bottle_id_bottle_id_fk", + "tableFrom": "tasting", + "tableTo": "bottle", + "columnsFrom": [ + "bottle_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "tasting_edition_id_bottle_id_fk": { + "name": "tasting_edition_id_bottle_id_fk", + "tableFrom": "tasting", + "tableTo": "bottle", + "columnsFrom": [ + "edition_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "tasting_created_by_id_user_id_fk": { + "name": "tasting_created_by_id_user_id_fk", + "tableFrom": "tasting", + "tableTo": "user", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {} + }, + "toasts": { + "name": "toasts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "tasting_id": { + "name": "tasting_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by_id": { + "name": "created_by_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "toast_unq": { + "name": "toast_unq", + "columns": [ + "tasting_id", + "created_by_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "toasts_tasting_id_tasting_id_fk": { + "name": "toasts_tasting_id_tasting_id_fk", + "tableFrom": "toasts", + "tableTo": "tasting", + "columnsFrom": [ + "tasting_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "toasts_created_by_id_user_id_fk": { + "name": "toasts_created_by_id_user_id_fk", + "tableFrom": "toasts", + "tableTo": "user", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {} + }, + "user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "picture_url": { + "name": "picture_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "admin": { + "name": "admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_email_unq": { + "name": "user_email_unq", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {} + } + }, + "enums": { + "category": { + "name": "category", + "values": { + "blend": "blend", + "bourbon": "bourbon", + "rye": "rye", + "single_grain": "single_grain", + "single_malt": "single_malt", + "spirit": "spirit" + } + }, + "entity_type": { + "name": "entity_type", + "values": { + "brand": "brand", + "distiller": "distiller" + } + }, + "follow_status": { + "name": "follow_status", + "values": { + "none": "none", + "pending": "pending", + "following": "following" + } + }, + "identity_provider": { + "name": "identity_provider", + "values": { + "google": "google" + } + }, + "object_type": { + "name": "object_type", + "values": { + "bottle": "bottle", + "edition": "edition", + "entity": "entity", + "tasting": "tasting", + "toast": "toast", + "follow": "follow" + } + } + }, + "schemas": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + } +} \ No newline at end of file diff --git a/apps/api/migrations/meta/_journal.json b/apps/api/migrations/meta/_journal.json index 4a088a6b..e7afcb94 100644 --- a/apps/api/migrations/meta/_journal.json +++ b/apps/api/migrations/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1684106948837, "tag": "0005_thin_jasper_sitwell", "breakpoints": false + }, + { + "idx": 6, + "version": "5", + "when": 1684129572637, + "tag": "0006_chilly_scream", + "breakpoints": false } ] } \ No newline at end of file diff --git a/apps/api/src/bin/mocks.ts b/apps/api/src/bin/mocks.ts index 871e7272..f8078814 100644 --- a/apps/api/src/bin/mocks.ts +++ b/apps/api/src/bin/mocks.ts @@ -1,6 +1,6 @@ import { eq, ne, sql } from "drizzle-orm"; import { db } from "../db"; -import { bottles, follows, users } from "../db/schema"; +import { bottles, follows, tastings, toasts, users } from "../db/schema"; import * as Fixtures from "../lib/test/fixtures"; import { program } from "commander"; @@ -15,13 +15,13 @@ program "--bottles ", "number of bottles", (v: string) => parseInt(v, 10), - 25, + 5, ) .option( "--tastings ", "number of tastings", (v: string) => parseInt(v, 10), - 25, + 5, ) .action(async (email, options) => { for (let i = 0; i < options.bottles; i++) { @@ -69,6 +69,35 @@ program }); console.log(`Created follow request from ${fromUserId} -> ${toUserId}`); } + + const [lastTasting] = await db + .insert(tastings) + .values({ + bottleId: ( + await db + .select() + .from(bottles) + .orderBy(sql`RANDOM()`) + .limit(1) + )[0].id, + rating: 4.5, + createdById: toUserId, + }) + .returning(); + for (const { id: fromUserId } of userList) { + const toast = await Fixtures.Toast({ + tastingId: lastTasting.id, + createdById: fromUserId, + }); + await createNotification(db, { + fromUserId: fromUserId, + objectType: objectTypeFromSchema(toasts), + objectId: toast.id, + userId: toUserId, + createdAt: toast.createdAt, + }); + console.log(`Created toast from ${fromUserId}`); + } } }); diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index 161e1b52..f3fb244e 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -251,6 +251,7 @@ export type NewTasting = InferModel; export const toasts = pgTable( "toasts", { + id: bigserial("id", { mode: "number" }).primaryKey(), tastingId: bigint("tasting_id", { mode: "number" }) .references(() => tastings.id) .notNull(), @@ -261,7 +262,10 @@ export const toasts = pgTable( }, (toasts) => { return { - toastId: primaryKey(toasts.tastingId, toasts.createdById), + toastId: uniqueIndex("toast_unq").on( + toasts.tastingId, + toasts.createdById, + ), }; }, ); diff --git a/apps/api/src/lib/serializers/tasting.ts b/apps/api/src/lib/serializers/tasting.ts index d3a7b379..bb343f84 100644 --- a/apps/api/src/lib/serializers/tasting.ts +++ b/apps/api/src/lib/serializers/tasting.ts @@ -31,3 +31,29 @@ export const serializeTasting = ( }; return data; }; + +export const serializeTastingRef = ( + tasting: { + id: number; + bottle: { + id: number; + name: string; + brand: { + id: number; + name: string; + }; + }; + }, + currentUser?: User, +) => { + return { + id: tasting.id, + bottle: { + name: tasting.bottle.name, + brand: { + id: tasting.bottle.brand.id, + name: tasting.bottle.brand.name, + }, + }, + }; +}; diff --git a/apps/api/src/routes/deleteNotification.ts b/apps/api/src/routes/deleteNotification.ts new file mode 100644 index 00000000..4ef9732e --- /dev/null +++ b/apps/api/src/routes/deleteNotification.ts @@ -0,0 +1,53 @@ +import { eq } from "drizzle-orm"; +import type { RouteOptions } from "fastify"; +import { IncomingMessage, Server, ServerResponse } from "http"; +import { db } from "../db"; +import { notifications } from "../db/schema"; +import { requireAuth } from "../middleware/auth"; + +export default { + method: "DELETE", + url: "/notifications/:notificationId", + schema: { + params: { + type: "object", + required: ["notificationId"], + properties: { + notificationId: { type: "number" }, + }, + }, + }, + preHandler: [requireAuth], + handler: async (req, res) => { + const [notification] = await db + .select() + .from(notifications) + .where(eq(notifications.id, req.params.notificationId)); + + if (!notification) { + return res.status(404).send({ error: "Not found" }); + } + + if (notification.userId !== req.user.id) { + return res.status(403).send({ error: "Forbidden " }); + } + + await db + .update(notifications) + .set({ + read: true, + }) + .where(eq(notifications.id, notification.id)); + + res.status(200).send({}); + }, +} as RouteOptions< + Server, + IncomingMessage, + ServerResponse, + { + Params: { + notificationId: number; + }; + } +>; diff --git a/apps/api/src/routes/deleteTasting.ts b/apps/api/src/routes/deleteTasting.ts index 573c477a..ef2a5dc0 100644 --- a/apps/api/src/routes/deleteTasting.ts +++ b/apps/api/src/routes/deleteTasting.ts @@ -1,8 +1,9 @@ -import { eq } from "drizzle-orm"; +import { and, eq, inArray, sql } from "drizzle-orm"; import type { RouteOptions } from "fastify"; import { IncomingMessage, Server, ServerResponse } from "http"; import { db } from "../db"; -import { tastings } from "../db/schema"; +import { notifications, tastings, toasts } from "../db/schema"; +import { objectTypeFromSchema } from "../lib/notifications"; import { requireAuth } from "../middleware/auth"; export default { @@ -32,8 +33,21 @@ export default { return res.status(403).send({ error: "Forbidden" }); } - await db.delete(tastings).where(eq(tastings.id, tasting.id)); - + await db.transaction(async (tx) => { + await tx + .delete(notifications) + .where( + and( + eq(notifications.objectType, objectTypeFromSchema(toasts)), + inArray( + notifications.objectId, + sql`(SELECT ${toasts.id} FROM ${toasts} WHERE ${toasts.tastingId} = ${tasting.id})`, + ), + ), + ); + await tx.delete(toasts).where(eq(toasts.tastingId, tasting.id)); + await tx.delete(tastings).where(eq(tastings.id, tasting.id)); + }); res.status(204).send(); }, } as RouteOptions< diff --git a/apps/api/src/routes/index.ts b/apps/api/src/routes/index.ts index 287b562b..97c8f342 100644 --- a/apps/api/src/routes/index.ts +++ b/apps/api/src/routes/index.ts @@ -8,6 +8,7 @@ import addUserFollow from "./addUserFollow"; import authBasic from "./authBasic"; import authDetails from "./authDetails"; import authGoogle from "./authGoogle"; +import deleteNotification from "./deleteNotification"; import deleteTasting from "./deleteTasting"; import deleteUserFollow from "./deleteUserFollow"; import getBottle from "./getBottle"; @@ -61,6 +62,7 @@ export const router: FastifyPluginCallback = ( fastify.route(getEntity); fastify.route(listNotifications); + fastify.route(deleteNotification); fastify.route(listTastings); fastify.route(addTasting); diff --git a/apps/api/src/routes/listFollowers.ts b/apps/api/src/routes/listFollowers.ts index 977307a5..f0cc6cd7 100644 --- a/apps/api/src/routes/listFollowers.ts +++ b/apps/api/src/routes/listFollowers.ts @@ -1,4 +1,4 @@ -import { and, asc, eq } from "drizzle-orm"; +import { and, asc, desc, eq } from "drizzle-orm"; import { alias } from "drizzle-orm/pg-core"; import type { RouteOptions } from "fastify"; import { IncomingMessage, Server, ServerResponse } from "http"; @@ -64,7 +64,7 @@ export default { .leftJoin(followsBack, eq(users.id, followsBack.toUserId)) .limit(limit + 1) .offset(offset) - .orderBy(asc(follows.status), asc(follows.createdAt)); + .orderBy(desc(follows.status), asc(follows.createdAt)); res.send({ results: results.slice(0, limit).map(({ user, follow, followsBack }) => diff --git a/apps/api/src/routes/listNotifications.ts b/apps/api/src/routes/listNotifications.ts index a1992a69..10db1015 100644 --- a/apps/api/src/routes/listNotifications.ts +++ b/apps/api/src/routes/listNotifications.ts @@ -1,9 +1,20 @@ -import { SQL, and, desc, eq } from "drizzle-orm"; +import { SQL, and, desc, eq, inArray } from "drizzle-orm"; +import { alias } from "drizzle-orm/pg-core"; import type { RouteOptions } from "fastify"; import { IncomingMessage, Server, ServerResponse } from "http"; import { db } from "../db"; -import { notifications, users } from "../db/schema"; +import { + bottles, + entities, + follows, + notifications, + tastings, + toasts, + users, +} from "../db/schema"; import { buildPageLink } from "../lib/paging"; +import { serializeFollow } from "../lib/serializers/follow"; +import { serializeTastingRef } from "../lib/serializers/tasting"; import { requireAuth } from "../middleware/auth"; export default { @@ -44,10 +55,85 @@ export default { .offset(offset) .orderBy(desc(notifications.createdAt)); + // follow requests need more details + const followFromUserIdList = results + .filter(({ notification }) => notification.objectType === "follow") + .map(({ notification }) => notification.objectId); + const followsBack = alias(follows, "follows_back"); + const followResults = followFromUserIdList.length + ? await db + .select({ + follow: follows, + followsBack: followsBack, + user: users, + }) + .from(follows) + .where( + and( + eq(follows.toUserId, req.user.id), + inArray(follows.fromUserId, followFromUserIdList), + ), + ) + .innerJoin(users, eq(users.id, follows.fromUserId)) + .leftJoin(followsBack, eq(follows.fromUserId, followsBack.toUserId)) + : []; + + const followResultsByObjectId = Object.fromEntries( + followResults.map((r) => [ + r.follow.fromUserId, + { + ...r.follow, + followsBack: r.followsBack, + user: r.user, + }, + ]), + ); + + // toasts need more details + const toastIdList = results + .filter(({ notification }) => notification.objectType === "toast") + .map(({ notification }) => notification.objectId); + const toastResults = toastIdList.length + ? ( + await db + .select({ + toastId: toasts.id, + tasting: tastings, + bottle: bottles, + brand: entities, + }) + .from(tastings) + .innerJoin(bottles, eq(tastings.bottleId, bottles.id)) + .innerJoin(entities, eq(bottles.brandId, entities.id)) + .innerJoin(toasts, eq(tastings.id, toasts.tastingId)) + .where(inArray(toasts.id, toastIdList)) + ).map((r) => ({ + toastId: r.toastId, + ...r.tasting, + bottle: { + ...r.bottle, + brand: r.brand, + }, + })) + : []; + + const toastResultsByObjectId = Object.fromEntries( + toastResults.map((r) => [r.toastId, r]), + ); + res.send({ results: results.map(({ fromUser, notification }) => ({ ...notification, fromUser, + ref: + notification.objectType === "follow" + ? serializeFollow( + followResultsByObjectId[notification.objectId], + req.user, + ) + : notification.objectType === "toast" + ? serializeTastingRef(toastResultsByObjectId[notification.objectId]) + : undefined, })), rel: { nextPage: results.length > limit ? page + 1 : null, diff --git a/apps/api/src/routes/listTastings.ts b/apps/api/src/routes/listTastings.ts index b4ce5528..c38e46b4 100644 --- a/apps/api/src/routes/listTastings.ts +++ b/apps/api/src/routes/listTastings.ts @@ -102,22 +102,23 @@ export default { else distillersByBottleId[d.bottleId].push(d.distiller); }); - const userToastsList: number[] = req.user - ? ( - await db - .select({ tastingId: toasts.tastingId }) - .from(toasts) - .where( - and( - inArray( - toasts.tastingId, - results.map((t) => t.tasting.id), + const userToastsList: number[] = + req.user && results.length + ? ( + await db + .select({ tastingId: toasts.tastingId }) + .from(toasts) + .where( + and( + inArray( + toasts.tastingId, + results.map((t) => t.tasting.id), + ), + eq(toasts.createdById, req.user.id), ), - eq(toasts.createdById, req.user.id), - ), - ) - ).map((t) => t.tastingId) - : []; + ) + ).map((t) => t.tastingId) + : []; res.send({ results: results diff --git a/apps/web/index.html b/apps/web/index.html index 1d437023..751fc79e 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -1,5 +1,5 @@ - + Peated - +
diff --git a/apps/web/src/components/appHeader.tsx b/apps/web/src/components/appHeader.tsx index a6db4837..6496d16b 100644 --- a/apps/web/src/components/appHeader.tsx +++ b/apps/web/src/components/appHeader.tsx @@ -1,25 +1,24 @@ import { Menu, Transition } from "@headlessui/react"; -import { Fragment, useEffect, useState } from "react"; +import { Fragment, useState } from "react"; import { Link, useNavigate } from "react-router-dom"; import { ReactComponent as PeatedGlyph } from "../assets/glyph.svg"; import { ReactComponent as PeatedLogo } from "../assets/logo.svg"; import useAuth from "../hooks/useAuth"; -import api from "../lib/api"; -import { Notification } from "../types"; +import NotificationsPanel from "./notifications/panel"; import UserAvatar from "./userAvatar"; const HeaderLogo = () => { return ( <> -
+
- +
-
+
- +
@@ -32,28 +31,11 @@ export default function AppHeader() { const [query, setQuery] = useState(""); - const [notificationCount, setNotificationCount] = useState(0); - const [followRequestCount, setFollowRequestCount] = useState(0); - - useEffect(() => { - (async () => { - const { results } = await api.get("/notifications", { - query: { - filter: "unread", - }, - }); - setNotificationCount(results.length); - setFollowRequestCount( - results.filter((f: Notification) => f.objectType == "follow").length, - ); - })(); - }); - return ( <>
{ e.preventDefault(); navigate(`/search?q=${encodeURIComponent(query)}`); @@ -64,25 +46,19 @@ export default function AppHeader() { onChange={(e) => setQuery(e.target.value)} placeholder="Search for a bottle" autoComplete="off" - className="focus:outline-peated-light bg-peated-darker w-full transform rounded px-2 py-1.5 text-white transition-all duration-500 focus:outline sm:px-3 sm:py-2" + className="w-full transform rounded bg-slate-900 px-2 py-1.5 text-white placeholder:text-slate-700 focus:outline focus:outline-slate-700 sm:px-3 sm:py-2" />
{user && ( -
- -
- - Open user menu - - - - {notificationCount > 0 && ( -
- {notificationCount.toLocaleString()} -
- )} -
-
+
+ + + + Open user menu + + + + - - - - Profile - - + - - Friends - {followRequestCount > 0 && ( -
- {followRequestCount} -
- )} - + Profile
- - Notifications - {notificationCount > 0 && ( -
- {notificationCount} -
- )} - + Friends
- - Bottles - + Bottles - - Brands - + Brands - - Distillers - + Distillers +
+
+
+
+ + ); +} + +const getLink = ({ notification }: { notification: Notification }) => { + switch (notification.objectType) { + case "follow": + return `/users/${notification.objectId}`; + case "toast": + return `/users/${notification.ref.id}`; + default: + return null; + } +}; + +const getStatusMessage = ({ notification }: { notification: Notification }) => { + switch (notification.objectType) { + case "follow": + return <>wants to follow you; + case "toast": + return ( + <> + toasted your + + {notification.ref.bottle.brand.name} + + tasting + + ); + default: + return null; + } +}; + +const NotificationEntryRef = ({ + notification, +}: { + notification: Notification; +}) => { + switch (notification.objectType) { + case "follow": + return ; + default: + return null; + } +}; diff --git a/apps/web/src/components/notifications/followEntry.tsx b/apps/web/src/components/notifications/followEntry.tsx new file mode 100644 index 00000000..55ccc1fa --- /dev/null +++ b/apps/web/src/components/notifications/followEntry.tsx @@ -0,0 +1,64 @@ +import { useState } from "react"; +import api from "../../lib/api"; +import { FollowNotification, FollowStatus } from "../../types"; +import Button from "../button"; + +export default ({ + notification: { ref }, +}: { + notification: FollowNotification; +}) => { + const [theirFollowStatus, setTheirFollowStatus] = useState( + ref.status, + ); + const [myFollowStatus, setMyFollowStatus] = useState( + ref.followsBack, + ); + + const acceptRequest = async (id: string) => { + const data = await api.put(`/users/me/followers/${id}`, { + data: { action: "accept" }, + }); + setTheirFollowStatus(data.status); + }; + + const followUser = async (toUserId: string, follow: boolean) => { + const data = await api[follow ? "post" : "delete"]( + `/users/${toUserId}/follow`, + ); + setMyFollowStatus(data.status); + }; + + const followLabel = (status: FollowStatus) => { + switch (status) { + case "following": + return "Unfollow"; + case "pending": + return "Request Sent"; + case "none": + default: + return "Follow Back"; + } + }; + + return ( +
+ +
+ ); +}; diff --git a/apps/web/src/components/notifications/list.tsx b/apps/web/src/components/notifications/list.tsx new file mode 100644 index 00000000..a43eb1bd --- /dev/null +++ b/apps/web/src/components/notifications/list.tsx @@ -0,0 +1,35 @@ +import { useState } from "react"; +import api from "../../lib/api"; +import { Notification } from "../../types"; +import NotificationEntry from "./entry"; + +export default function NotificationList({ + values, +}: { + values: Notification[]; +}) { + const [archiveList, setArchiveList] = useState([]); + + const archive = async (notification: Notification) => { + await api.delete(`/notifications/${notification.id}`); + setArchiveList((results) => [...results, notification.id]); + }; + + const activeValues = values.filter((n) => archiveList.indexOf(n.id) === -1); + + return ( +
    + {activeValues.map((n) => { + return ( + { + archive(n); + }} + /> + ); + })} +
+ ); +} diff --git a/apps/web/src/components/notifications/panel.tsx b/apps/web/src/components/notifications/panel.tsx new file mode 100644 index 00000000..71b4389f --- /dev/null +++ b/apps/web/src/components/notifications/panel.tsx @@ -0,0 +1,41 @@ +import { InboxIcon } from "@heroicons/react/20/solid"; +import { useEffect, useState } from "react"; +import { Link } from "react-router-dom"; +import api from "../../lib/api"; +import { Notification } from "../../types"; + +export default function NotificationsPanel() { + const [notificationList, setNotificationList] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + (async () => { + const { results } = await api.get("/notifications", { + query: { filter: "unread" }, + }); + setNotificationList(results); + setLoading(false); + })(); + }, []); + + const unreadNotificationCount = notificationList.filter( + (n) => !n.read, + ).length; + + return ( + + Open user menu + + + + {unreadNotificationCount > 0 && ( +
+ {unreadNotificationCount.toLocaleString()} +
+ )} + + ); +} diff --git a/apps/web/src/components/rating.tsx b/apps/web/src/components/rating.tsx index 6d8a6db2..0a29565d 100644 --- a/apps/web/src/components/rating.tsx +++ b/apps/web/src/components/rating.tsx @@ -25,9 +25,9 @@ export const StaticRating = ({ value, size, className }: Props) => {
= item ? "text-yellow-500" : "", + value >= item ? "text-highlight" : "", )} > @@ -35,9 +35,9 @@ export const StaticRating = ({ value, size, className }: Props) => {
= item - 0.5 ? "text-yellow-500" : "", + value >= item - 0.5 ? "text-highlight" : "", )} > @@ -74,20 +74,21 @@ export default ({ >
{[5, 4, 3, 2, 1].map((item) => { + console.log(value, item); return [ classNames( - "cursor-pointer text-gray-400", - "hover:text-peated-darkest", + "cursor-pointer", + "hover:text-slate-400", "rating-half-2", "peer", - "peer-hover:text-peated", - active ? "text-peated" : "", - checked ? "text-peated" : "", - value >= item ? "text-peated" : "", + "peer-hover:text-highlight", + active || checked || value >= item + ? "text-highlight" + : "text-slate-600", ) } > @@ -98,14 +99,14 @@ export default ({ value={item - 0.5} className={({ active, checked }) => classNames( - "cursor-pointer text-gray-400", - "hover:text-peated-darkest", + "cursor-pointer", + "hover:text-slate-400", "rating-half-1", "peer", - "peer-hover:text-peated", - active ? "text-peated" : "", - checked ? "text-peated" : "", - value >= item - 0.5 ? "text-peated" : "", + "peer-hover:text-highlight", + active || checked || value >= item - 0.5 + ? "text-highlight" + : "text-slate-600", ) } > diff --git a/apps/web/src/components/searchHeader.tsx b/apps/web/src/components/searchHeader.tsx index eb2155a9..cc0439ab 100644 --- a/apps/web/src/components/searchHeader.tsx +++ b/apps/web/src/components/searchHeader.tsx @@ -55,7 +55,7 @@ export default function SearchHeader({ setValue(e.target.value); if (onChange) onChange(e.target.value); }} - className="focus:outline-peated-light bg-peated-darker min-w-full rounded px-2 py-1.5 text-white focus:outline sm:px-3 sm:py-2" + className="w-full transform rounded bg-slate-900 px-2 py-1.5 text-white placeholder:text-slate-700 focus:outline focus:outline-slate-700 sm:px-3 sm:py-2" /> {onDone && ( @@ -64,7 +64,7 @@ export default function SearchHeader({ onClick={onDone} className={`group min-h-full ${blockStyles} pl-3 sm:pl-6`} > - + Done diff --git a/apps/web/src/components/selectField/selectDialog.tsx b/apps/web/src/components/selectField/selectDialog.tsx index ddbd0cf7..5a2fd7bd 100644 --- a/apps/web/src/components/selectField/selectDialog.tsx +++ b/apps/web/src/components/selectField/selectDialog.tsx @@ -97,15 +97,10 @@ export default ({ }, [query]); return ( - + - +
setOpen(false)} @@ -118,23 +113,23 @@ export default ({ />
-
diff --git a/apps/web/src/routes/activity.tsx b/apps/web/src/routes/activity.tsx index 0937f854..63036a37 100644 --- a/apps/web/src/routes/activity.tsx +++ b/apps/web/src/routes/activity.tsx @@ -1,7 +1,8 @@ import type { LoaderFunction } from "react-router-dom"; -import { Link, useLoaderData } from "react-router-dom"; +import { useLoaderData } from "react-router-dom"; import { ReactComponent as Glyph } from "../assets/glyph.svg"; +import EmptyActivity from "../components/emptyActivity"; import FloatingButton from "../components/floatingButton"; import Layout from "../components/layout"; import TastingList from "../components/tastingList"; @@ -18,25 +19,6 @@ export const loader: LoaderFunction = async (): Promise => { return { tastingList }; }; -const EmptyActivity = () => { - return ( - - - - - What are you drinking? - - - Get started by recording your first tasting notes. - - - ); -}; - export default function Activity() { const { tastingList } = useLoaderData() as LoaderData; @@ -46,7 +28,16 @@ export default function Activity() { {tastingList.length > 0 ? ( ) : ( - + + + + + What are you drinking? + + + Get started by recording your first tasting notes. + + )} ); diff --git a/apps/web/src/routes/addBottle.tsx b/apps/web/src/routes/addBottle.tsx index 5352be15..d5ba4a6e 100644 --- a/apps/web/src/routes/addBottle.tsx +++ b/apps/web/src/routes/addBottle.tsx @@ -1,7 +1,7 @@ import { FormEvent, useState } from "react"; import { useLocation, useNavigate } from "react-router-dom"; -import BottleName from "../components/bottleName"; +import { PreviewBottleCard } from "../components/bottleCard"; import BrandField from "../components/brandField"; import DistillerField from "../components/distillerField"; import Fieldset from "../components/fieldset"; @@ -22,54 +22,6 @@ type FormData = { category?: Option; }; -const PreviewCard = ({ - data: { distillers, brand, ...bottle }, -}: { - data: Partial; -}) => { - return ( -
-
-

- {bottle.name ? ( - - ) : ( - "Unknown Bottle" - )} -

-

- Produced by {brand?.name || "Unknown"} - {distillers && - distillers.length > 0 && - (distillers.length > 0 || brand?.name !== distillers[0].name) && ( - - {" "} - · Distilled at {distillers.map((d) => d.name).join(", ")} - - )} -

-
-
-

- {bottle.category && bottle.category.name} -

-

- {bottle.statedAge ? `Aged ${bottle.statedAge} years` : null} -

-
-
- ); -}; - export default function AddBottle() { const location = useLocation(); const navigate = useNavigate(); @@ -144,7 +96,9 @@ export default function AddBottle() {
{error && } - +
+ +
- {error && } +
+ +
+
-
+
-

Bottle Edition

-

+

Bottle Edition

+

If this is a specific series or barrel, feel free to note it below!

- +
{ - return ( - - - Are you enjoying a dram? - - - - Looks like no ones recorded this spirit. You could be the first! - - - ); -}; - export default function BottleDetails() { const { bottle, tastingList } = useLoaderData() as LoaderData; @@ -69,13 +52,18 @@ export default function BottleDetails() { return (
-
-

+
+

-

+

Produced by{" "} - {bottle.brand.name} + + {bottle.brand.name} + {distillers.length > 0 && (distillers.length > 0 || bottle.brand.name !== distillers[0].name) && ( @@ -87,7 +75,7 @@ export default function BottleDetails() { {d.name} @@ -97,11 +85,11 @@ export default function BottleDetails() { )}

-
-

+

+

{bottle.category && formatCategoryName(bottle.category)}

-

+

{bottle.statedAge ? `Aged ${bottle.statedAge} years` : null}

@@ -117,8 +105,8 @@ export default function BottleDetails() {
{stats.map((stat) => (
-

{stat.name}

-

+

{stat.name}

+

{stat.value}

@@ -126,14 +114,25 @@ export default function BottleDetails() {
{tastingList.results.length ? ( - + ) : ( - + + + Are you enjoying a dram? + + + + Looks like no ones recorded this spirit. You could be the first! + + )} {bottle.createdBy && ( -

+

This bottle was first added by{" "} - + {bottle.createdBy.displayName} {" "} diff --git a/apps/web/src/routes/bottles.tsx b/apps/web/src/routes/bottles.tsx index 6be55871..b2b3b0d4 100644 --- a/apps/web/src/routes/bottles.tsx +++ b/apps/web/src/routes/bottles.tsx @@ -2,6 +2,7 @@ import type { LoaderFunction } from "react-router-dom"; import { useLoaderData } from "react-router-dom"; import BottleTable from "../components/bottleTable"; +import EmptyActivity from "../components/emptyActivity"; import Layout from "../components/layout"; import api from "../lib/api"; import type { Bottle } from "../types"; @@ -35,16 +36,6 @@ export const loader: LoaderFunction = async ({ return { bottleListResponse }; }; -const EmptyActivity = () => { - return ( -

- - Looks like there's no bottles in the database yet. Weird. - -
- ); -}; - export default function BottleList() { const { bottleListResponse } = useLoaderData() as LoaderData; @@ -56,7 +47,9 @@ export default function BottleList() { rel={bottleListResponse.rel} /> ) : ( - + + Looks like there's no bottles in the database yet. Weird. + )} ); diff --git a/apps/web/src/routes/brandDetails.tsx b/apps/web/src/routes/brandDetails.tsx index 2e5c49ab..052d6362 100644 --- a/apps/web/src/routes/brandDetails.tsx +++ b/apps/web/src/routes/brandDetails.tsx @@ -35,10 +35,10 @@ export default function BrandDetails() {
-

+

{brand.name}

-

+

Located in {brand.country} {brand.region && · {brand.region}}

@@ -52,8 +52,8 @@ export default function BrandDetails() {
{stats.map((stat) => (
-

{stat.name}

-

+

{stat.name}

+

{stat.value}

diff --git a/apps/web/src/routes/brands.tsx b/apps/web/src/routes/brands.tsx index d6145f44..243d4c14 100644 --- a/apps/web/src/routes/brands.tsx +++ b/apps/web/src/routes/brands.tsx @@ -2,6 +2,7 @@ import type { LoaderFunction } from "react-router-dom"; import { useLoaderData } from "react-router-dom"; import BrandTable from "../components/brandTable"; +import EmptyActivity from "../components/emptyActivity"; import Layout from "../components/layout"; import api from "../lib/api"; import type { Entity } from "../types"; @@ -36,16 +37,6 @@ export const loader: LoaderFunction = async ({ return { brandListResponse }; }; -const EmptyActivity = () => { - return ( -
- - Looks like there's no brands in the database yet. Weird. - -
- ); -}; - export default function BrandList() { const { brandListResponse } = useLoaderData() as LoaderData; @@ -57,7 +48,9 @@ export default function BrandList() { rel={brandListResponse.rel} /> ) : ( - + + Looks like there's no brands in the database yet. Weird. + )} ); diff --git a/apps/web/src/routes/distillerDetails.tsx b/apps/web/src/routes/distillerDetails.tsx index 27c6211d..8165e7f5 100644 --- a/apps/web/src/routes/distillerDetails.tsx +++ b/apps/web/src/routes/distillerDetails.tsx @@ -36,10 +36,10 @@ export default function DistillerDetails() {
-

+

{distiller.name}

-

+

Located in {distiller.country} {distiller.region && · {distiller.region}}

@@ -54,8 +54,8 @@ export default function DistillerDetails() {
{stats.map((stat) => (
-

{stat.name}

-

+

{stat.name}

+

{stat.value}

diff --git a/apps/web/src/routes/distillers.tsx b/apps/web/src/routes/distillers.tsx index f919a098..cbc6f006 100644 --- a/apps/web/src/routes/distillers.tsx +++ b/apps/web/src/routes/distillers.tsx @@ -2,6 +2,7 @@ import type { LoaderFunction } from "react-router-dom"; import { useLoaderData } from "react-router-dom"; import DistillerTable from "../components/distillerTable"; +import EmptyActivity from "../components/emptyActivity"; import Layout from "../components/layout"; import api from "../lib/api"; import type { Entity } from "../types"; @@ -36,16 +37,6 @@ export const loader: LoaderFunction = async ({ return { distillerListResponse }; }; -const EmptyActivity = () => { - return ( -
- - Looks like there's no distillers in the database yet. Weird. - -
- ); -}; - export default function DistillerList() { const { distillerListResponse } = useLoaderData() as LoaderData; @@ -57,7 +48,9 @@ export default function DistillerList() { rel={distillerListResponse.rel} /> ) : ( - + + Looks like there's no distillers in the database yet. Weird. + )} ); diff --git a/apps/web/src/routes/friends.tsx b/apps/web/src/routes/friends.tsx index c041019d..42a675f6 100644 --- a/apps/web/src/routes/friends.tsx +++ b/apps/web/src/routes/friends.tsx @@ -4,6 +4,7 @@ import { useLoaderData } from "react-router-dom"; import { useState } from "react"; import { Link } from "react-router-dom"; import Button from "../components/button"; +import EmptyActivity from "../components/emptyActivity"; import Layout from "../components/layout"; import ListItem from "../components/listItem"; import TimeSince from "../components/timeSince"; @@ -11,17 +12,6 @@ import UserAvatar from "../components/userAvatar"; import api from "../lib/api"; import type { FollowRequest, FollowStatus } from "../types"; -const EmptyActivity = () => { - return ( -
- - You could definitely use a few more friends. We're not judging or - anything. - -
- ); -}; - type LoaderData = { requestList: FollowRequest[]; }; @@ -80,27 +70,23 @@ export default function Friends() { return ( -
    +
      {requestList.length ? ( requestList.map(({ user, ...follow }) => { return ( -
      - - - -
      +
      + +
      {user.displayName}
      - {theirFollowStatus[follow.id] === "pending" && - myFollowStatus[user.id] === "none"}
    diff --git a/apps/web/src/routes/notifications.tsx b/apps/web/src/routes/notifications.tsx index 6f8b803a..b8f190bd 100644 --- a/apps/web/src/routes/notifications.tsx +++ b/apps/web/src/routes/notifications.tsx @@ -1,21 +1,11 @@ import type { LoaderFunction } from "react-router-dom"; import { useLoaderData } from "react-router-dom"; -import { Link } from "react-router-dom"; import Layout from "../components/layout"; -import ListItem from "../components/listItem"; -import TimeSince from "../components/timeSince"; -import UserAvatar from "../components/userAvatar"; +import NotificationList from "../components/notifications/list"; import api from "../lib/api"; import type { Notification, Paginated } from "../types"; -const NotificationItem = ({ notification }: { notification: Notification }) => { - switch (notification.objectType) { - case "follow": - return
    requested to follow you
    ; - } -}; - const EmptyActivity = () => { return (
    @@ -34,7 +24,9 @@ type LoaderData = { // its token yet as react-dom (thus context) seemingly has // not rendered.. so this errors out export const loader: LoaderFunction = async (): Promise => { - const notificationList = await api.get(`/notifications`); + const notificationList = await api.get(`/notifications`, { + query: { filter: "unread" }, + }); return { notificationList }; }; @@ -44,42 +36,11 @@ export default function Notifications() { return ( -
      - {notificationList.results.length ? ( - notificationList.results.map(({ fromUser, ...notification }) => { - return ( - -
      - {fromUser && ( - <> - - - -
      - - {fromUser.displayName} - - -
      - - )} - -
      -
      - ); - }) - ) : ( - - )} -
    + {notificationList.results.length ? ( + + ) : ( + + )}
    ); } diff --git a/apps/web/src/routes/search.tsx b/apps/web/src/routes/search.tsx index 4abe5a16..b86dd41c 100644 --- a/apps/web/src/routes/search.tsx +++ b/apps/web/src/routes/search.tsx @@ -86,7 +86,7 @@ export default function Search() { /> } > -
      +
        {state === "loading" ? ( <> @@ -102,7 +102,7 @@ export default function Search() {
        -

        +

        -

        +

        {bottle.brand.name}

        -

        +

        {bottle.category && formatCategoryName(bottle.category)}

        -

        +

        {bottle.statedAge ? `${bottle.statedAge} years` : null}

        @@ -137,16 +137,16 @@ export default function Search() { })} {(results.length < maxResults || query !== "") && ( - +
        -

        +

        Can't find a bottle?

        -

        +

        {query !== "" ? ( Tap here to add{" "} diff --git a/apps/web/src/routes/userDetails.tsx b/apps/web/src/routes/userDetails.tsx index c2d7fb96..70ca7b18 100644 --- a/apps/web/src/routes/userDetails.tsx +++ b/apps/web/src/routes/userDetails.tsx @@ -3,6 +3,7 @@ import { useLoaderData } from "react-router-dom"; import { useState } from "react"; import Button from "../components/button"; +import EmptyActivity from "../components/emptyActivity"; import Layout from "../components/layout"; import TastingList from "../components/tastingList"; import UserAvatar from "../components/userAvatar"; @@ -39,16 +40,6 @@ export const loader: LoaderFunction = async ({ return { user, tastingList }; }; -const EmptyActivity = () => { - return ( -

        - - Looks like this ones a bit short on tastings. - -
        - ); -}; - export default function UserDetails() { const { user, tastingList } = useLoaderData() as LoaderData; const { user: currentUser } = useRequiredAuth(); @@ -69,27 +60,27 @@ export default function UserDetails() {
        -

        +

        {user.displayName}

        - + {user.stats.tastings.toLocaleString()} - Tastings + Tastings
        - + {user.stats.bottles.toLocaleString()} - Bottles + Bottles
        - + {user.stats.contributions.toLocaleString()} - Contributions + Contributions
        @@ -120,7 +111,9 @@ export default function UserDetails() { {tastingList.results.length ? ( ) : ( - + + Looks like this ones a bit short on tastings. + )} ); diff --git a/apps/web/src/styles/index.css b/apps/web/src/styles/index.css index 3838770a..b8e00496 100644 --- a/apps/web/src/styles/index.css +++ b/apps/web/src/styles/index.css @@ -9,3 +9,110 @@ @tailwind base; @tailwind components; @tailwind utilities; + +@layer base { + html { + @apply h-full; + } + + body { + @apply min-h-full text-white; + + background-color: theme("colors.slate.900"); + background-image: linear-gradient( + to right, + theme("colors.slate.900"), + theme("colors.slate.950"), + theme("colors.slate.900") + ); + } + + h1 { + @apply text-2xl; + } + h2 { + @apply text-xl; + } +} + +@layer components { + .layout { + @apply text-white; + background-color: theme("colors.slate.900"); + background-image: linear-gradient( + to right, + theme("colors.slate.900"), + theme("colors.slate.950"), + theme("colors.slate.900") + ); + } + + .header .logo { + /* @apply text-highlight; */ + } + + .header .logo { + @apply text-highlight; + /* -webkit-mask-image: linear-gradient(to bottom, #fff 25%, #000 75%); */ + mask: linear-gradient(to bottom, transparent 5%, #000 75%); + } + + .header > div { + @apply text-slate-950; + background-color: rgba(15, 23, 42, 0.25); + background-image: linear-gradient( + to right, + rgba(15, 23, 42, 0.5), + rgba(0, 0, 0, 0.5), + rgba(15, 23, 42, 0.5) + ); + + -webkit-backdrop-filter: blur(4px); + -o-backdrop-filter: blur(4px); + -moz-backdrop-filter: blur(4px); + + backdrop-filter: blur(4px); + } + + .card { + @apply overflow-hidden bg-slate-950 sm:rounded; + } + + .card-alt { + @apply bg-slate-950 text-white; + } + + .card-header { + @apply flex items-center space-x-4 p-3; + background: rgba(0, 0, 0, 0.25); + } + + .card-body { + @apply py-3; + } + + .menu { + @apply relative; + } + + .menu div[role="menu"] a, + .menu div[role="menu"] button { + @apply block w-full px-4 py-2 text-left hover:bg-slate-500; + } + + .dialog { + @apply fixed inset-0 z-10 min-h-screen overflow-y-auto text-center; + } + + .dialog-panel { + @apply relative h-screen min-h-full min-w-full transform overflow-hidden overflow-y-auto text-left transition-all; + + background-color: rgba(15, 23, 42, 1); + background-image: linear-gradient( + to right, + rgba(15, 23, 42, 1), + rgba(0, 0, 0, 1), + rgba(15, 23, 42, 1) + ); + } +} diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index 05e1edb8..14fc2c3d 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -94,13 +94,38 @@ export type ObjectType = | "toast" | "follow"; -export type Notification = { - objectType: ObjectType; +type BaseNotification = { + id: number; objectId: number; createdAt: string; fromUser?: User; + read: boolean; }; +export type FollowNotification = BaseNotification & { + objectType: "follow"; + ref: FollowRequest; +}; + +export type TastingRef = { + id: string; + bottle: { + id: string; + name: string; + brand: { + id: string; + name: string; + }; + }; +}; + +export type ToastNotification = BaseNotification & { + objectType: "toast"; + ref: TastingRef; +}; + +export type Notification = FollowNotification | ToastNotification; + // locations are where you're tasting from (e.g. a bar, a distillery) export type Location = { id: string; diff --git a/apps/web/tailwind.config.js b/apps/web/tailwind.config.js index 3637fa9b..e69c379d 100644 --- a/apps/web/tailwind.config.js +++ b/apps/web/tailwind.config.js @@ -13,14 +13,11 @@ module.exports = { ...colors, transparent: "transparent", current: "currentColor", - white: "#ffffff", - black: "#000000", - peated: { - light: "#77c1be", - DEFAULT: "#005C58", - dark: "#00423F", - darker: "#002927", - darkest: "#000F0F", + light: colors.slate[400], + highlight: "#68FE9C", + background: { + DEFAULT: "#111111", + alt: "#1F1F1F", }, }, extend: { @@ -50,9 +47,9 @@ module.exports = { css: { color: "#000", a: { - color: "#005C58", + color: "#20242E", "&:hover": { - color: "#005C58", + color: "#20242E", }, }, },