diff --git a/backend/drizzle/0000_huge_amazoness.sql b/backend/drizzle/0000_rare_stick.sql similarity index 91% rename from backend/drizzle/0000_huge_amazoness.sql rename to backend/drizzle/0000_rare_stick.sql index 7ccffe019..7ab318d44 100644 --- a/backend/drizzle/0000_huge_amazoness.sql +++ b/backend/drizzle/0000_rare_stick.sql @@ -3,15 +3,18 @@ CREATE TABLE IF NOT EXISTS "attachments" ( "filename" varchar NOT NULL, "content_type" varchar NOT NULL, "size" varchar NOT NULL, + "entity" varchar DEFAULT 'attachment' NOT NULL, "url" varchar NOT NULL, "created_at" timestamp DEFAULT now() NOT NULL, - "created_by" varchar + "created_by" varchar, + "modified_at" timestamp, + "modified_by" varchar, + "organization_id" varchar NOT NULL ); --> statement-breakpoint CREATE TABLE IF NOT EXISTS "memberships" ( "id" varchar PRIMARY KEY NOT NULL, "type" varchar NOT NULL, - "organization_id" varchar NOT NULL, "user_id" varchar NOT NULL, "role" varchar DEFAULT 'member' NOT NULL, "created_at" timestamp DEFAULT now() NOT NULL, @@ -20,7 +23,8 @@ CREATE TABLE IF NOT EXISTS "memberships" ( "modified_by" varchar, "archived" boolean DEFAULT false NOT NULL, "muted" boolean DEFAULT false NOT NULL, - "sort_order" double precision NOT NULL + "sort_order" double precision NOT NULL, + "organization_id" varchar NOT NULL ); --> statement-breakpoint CREATE TABLE IF NOT EXISTS "oauth_accounts" ( @@ -49,7 +53,6 @@ CREATE TABLE IF NOT EXISTS "organizations" ( "logo_url" varchar, "website_url" varchar, "welcome_text" varchar, - "is_production" boolean DEFAULT false NOT NULL, "auth_strategies" json DEFAULT '[]'::json NOT NULL, "chat_support" boolean DEFAULT false NOT NULL, "created_at" timestamp DEFAULT now() NOT NULL, @@ -117,7 +120,7 @@ CREATE TABLE IF NOT EXISTS "users" ( "thumbnail_url" varchar, "newsletter" boolean DEFAULT false NOT NULL, "last_seen_at" timestamp, - "last_visit_at" timestamp, + "last_started_at" timestamp, "last_sign_in_at" timestamp, "created_at" timestamp DEFAULT now() NOT NULL, "modified_at" timestamp, @@ -135,7 +138,13 @@ EXCEPTION END $$; --> statement-breakpoint DO $$ BEGIN - ALTER TABLE "memberships" ADD CONSTRAINT "memberships_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action; + ALTER TABLE "attachments" ADD CONSTRAINT "attachments_modified_by_users_id_fk" FOREIGN KEY ("modified_by") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "attachments" ADD CONSTRAINT "attachments_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$; @@ -158,6 +167,12 @@ EXCEPTION WHEN duplicate_object THEN null; END $$; --> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "memberships" ADD CONSTRAINT "memberships_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint DO $$ BEGIN ALTER TABLE "oauth_accounts" ADD CONSTRAINT "oauth_accounts_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action; EXCEPTION diff --git a/backend/drizzle/meta/0000_snapshot.json b/backend/drizzle/meta/0000_snapshot.json index 6acb31ca4..52ef0ec6b 100644 --- a/backend/drizzle/meta/0000_snapshot.json +++ b/backend/drizzle/meta/0000_snapshot.json @@ -1,5 +1,5 @@ { - "id": "135b8700-1927-4cd8-ac84-12cf4d5e09a1", + "id": "36f89a8d-02da-4e6b-b6b0-9355c9dada94", "prevId": "00000000-0000-0000-0000-000000000000", "version": "7", "dialect": "postgresql", @@ -32,6 +32,13 @@ "primaryKey": false, "notNull": true }, + "entity": { + "name": "entity", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'attachment'" + }, "url": { "name": "url", "type": "varchar", @@ -50,6 +57,24 @@ "type": "varchar", "primaryKey": false, "notNull": false + }, + "modified_at": { + "name": "modified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "modified_by": { + "name": "modified_by", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "varchar", + "primaryKey": false, + "notNull": true } }, "indexes": {}, @@ -66,6 +91,32 @@ ], "onDelete": "set null", "onUpdate": "no action" + }, + "attachments_modified_by_users_id_fk": { + "name": "attachments_modified_by_users_id_fk", + "tableFrom": "attachments", + "tableTo": "users", + "columnsFrom": [ + "modified_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "attachments_organization_id_organizations_id_fk": { + "name": "attachments_organization_id_organizations_id_fk", + "tableFrom": "attachments", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -87,12 +138,6 @@ "primaryKey": false, "notNull": true }, - "organization_id": { - "name": "organization_id", - "type": "varchar", - "primaryKey": false, - "notNull": true - }, "user_id": { "name": "user_id", "type": "varchar", @@ -150,23 +195,16 @@ "type": "double precision", "primaryKey": false, "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "varchar", + "primaryKey": false, + "notNull": true } }, "indexes": {}, "foreignKeys": { - "memberships_organization_id_organizations_id_fk": { - "name": "memberships_organization_id_organizations_id_fk", - "tableFrom": "memberships", - "tableTo": "organizations", - "columnsFrom": [ - "organization_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, "memberships_user_id_users_id_fk": { "name": "memberships_user_id_users_id_fk", "tableFrom": "memberships", @@ -205,6 +243,19 @@ ], "onDelete": "set null", "onUpdate": "no action" + }, + "memberships_organization_id_organizations_id_fk": { + "name": "memberships_organization_id_organizations_id_fk", + "tableFrom": "memberships", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -377,13 +428,6 @@ "primaryKey": false, "notNull": false }, - "is_production": { - "name": "is_production", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, "auth_strategies": { "name": "auth_strategies", "type": "json", @@ -934,8 +978,8 @@ "primaryKey": false, "notNull": false }, - "last_visit_at": { - "name": "last_visit_at", + "last_started_at": { + "name": "last_started_at", "type": "timestamp", "primaryKey": false, "notNull": false diff --git a/backend/drizzle/meta/_journal.json b/backend/drizzle/meta/_journal.json index 07dcd4d19..445e3b8c2 100644 --- a/backend/drizzle/meta/_journal.json +++ b/backend/drizzle/meta/_journal.json @@ -5,8 +5,8 @@ { "idx": 0, "version": "7", - "when": 1725866768212, - "tag": "0000_huge_amazoness", + "when": 1729106983063, + "tag": "0000_rare_stick", "breakpoints": true } ] diff --git a/backend/scripts/seeds/organizations/seed.ts b/backend/scripts/seeds/organizations/seed.ts index e8c97624c..6608e70bf 100644 --- a/backend/scripts/seeds/organizations/seed.ts +++ b/backend/scripts/seeds/organizations/seed.ts @@ -14,6 +14,10 @@ import { generateUnsubscribeToken } from '#/modules/users/helpers/unsubscribe-to import type { Status } from '../progress'; import { adminUser } from '../user/seed'; +const ORGANIZATIONS_COUNT = 100; +const MEMBERS_COUNT = 100; +const SYSTEM_ADMIN_MEMBERSHIP_COUNT = 10; + // Seed organizations with data export const organizationsSeed = async (progressCallback?: (stage: string, count: number, status: Status) => void) => { const organizationsInTable = await db.select().from(organizationsTable).limit(1); @@ -28,7 +32,7 @@ export const organizationsSeed = async (progressCallback?: (stage: string, count const organizations: (InsertOrganizationModel & { id: string; })[] = Array.from({ - length: 10, + length: ORGANIZATIONS_COUNT, }).map(() => { const name = organizationsUniqueEnforcer.enforce(() => faker.company.name()); @@ -57,13 +61,14 @@ export const organizationsSeed = async (progressCallback?: (stage: string, count let organizationsCount = 0; let membershipsCount = 0; let adminMembershipsOrder = 1; + let adminOrganizationsCount = 0; // Create 100 users for each organization for (const organization of organizations) { organizationsCount++; if (progressCallback) progressCallback('organizations', organizationsCount, 'inserting'); - const insertUsers: InsertUserModel[] = Array.from({ length: 100 }).map(() => { + const insertUsers: InsertUserModel[] = Array.from({ length: MEMBERS_COUNT }).map(() => { const firstName = faker.person.firstName(); const lastName = faker.person.lastName(); const firstAndLastName = { firstName, lastName }; @@ -109,19 +114,24 @@ export const organizationsSeed = async (progressCallback?: (stage: string, count }; }); - // add Admin user to every even organization - if (organizationsCount % 2 === 0) { + // Loop over organizations + + // Add Admin user to every even organization, but limit to a certain number + if (organizationsCount % 2 === 0 && adminOrganizationsCount < SYSTEM_ADMIN_MEMBERSHIP_COUNT) { memberships.push({ id: nanoid(), userId: adminUser.id, organizationId: organization.id, type: 'organization', + archived: faker.datatype.boolean(0.5), role: faker.helpers.arrayElement(['admin', 'member']), createdAt: faker.date.past(), order: adminMembershipsOrder, }); adminMembershipsOrder++; + adminOrganizationsCount++; // Increment the counter } + membershipsCount += memberships.length; if (progressCallback) progressCallback('memberships', membershipsCount, 'inserting'); diff --git a/backend/src/db/schema/attachments.ts b/backend/src/db/schema/attachments.ts index f461a2fef..bd93e0ee2 100644 --- a/backend/src/db/schema/attachments.ts +++ b/backend/src/db/schema/attachments.ts @@ -1,5 +1,6 @@ import { pgTable, timestamp, varchar } from 'drizzle-orm/pg-core'; import { nanoid } from '#/utils/nanoid'; +import { organizationsTable } from './organizations'; import { usersTable } from './users'; export const attachmentsTable = pgTable('attachments', { @@ -7,11 +8,23 @@ export const attachmentsTable = pgTable('attachments', { filename: varchar('filename').notNull(), contentType: varchar('content_type').notNull(), size: varchar('size').notNull(), + entity: varchar('entity', { enum: ['attachment'] }) + .notNull() + .default('attachment'), url: varchar('url').notNull(), createdAt: timestamp('created_at').defaultNow().notNull(), createdBy: varchar('created_by').references(() => usersTable.id, { onDelete: 'set null', }), + modifiedAt: timestamp('modified_at'), + modifiedBy: varchar('modified_by').references(() => usersTable.id, { + onDelete: 'set null', + }), + organizationId: varchar('organization_id') + .notNull() + .references(() => organizationsTable.id, { + onDelete: 'cascade', + }), }); export type AttachmentModel = typeof attachmentsTable.$inferSelect; diff --git a/backend/src/db/schema/memberships.ts b/backend/src/db/schema/memberships.ts index e837e06d8..3405f31bb 100644 --- a/backend/src/db/schema/memberships.ts +++ b/backend/src/db/schema/memberships.ts @@ -1,5 +1,4 @@ import { config } from 'config'; -import { relations } from 'drizzle-orm'; import { boolean, doublePrecision, pgTable, timestamp, varchar } from 'drizzle-orm/pg-core'; import { usersTable } from '#/db/schema/users'; import { nanoid } from '#/utils/nanoid'; @@ -10,37 +9,22 @@ const roleEnum = config.rolesByType.entityRoles; export const membershipsTable = pgTable('memberships', { id: varchar('id').primaryKey().$defaultFn(nanoid), type: varchar('type', { enum: config.contextEntityTypes }).notNull(), - organizationId: varchar('organization_id') - .notNull() - .references(() => organizationsTable.id, { onDelete: 'cascade' }), userId: varchar('user_id') .notNull() .references(() => usersTable.id, { onDelete: 'cascade' }), role: varchar('role', { enum: roleEnum }).notNull().default('member'), createdAt: timestamp('created_at').defaultNow().notNull(), - createdBy: varchar('created_by').references(() => usersTable.id, { - onDelete: 'set null', - }), + createdBy: varchar('created_by').references(() => usersTable.id, { onDelete: 'set null' }), modifiedAt: timestamp('modified_at'), - modifiedBy: varchar('modified_by').references(() => usersTable.id, { - onDelete: 'set null', - }), + modifiedBy: varchar('modified_by').references(() => usersTable.id, { onDelete: 'set null' }), archived: boolean('archived').default(false).notNull(), muted: boolean('muted').default(false).notNull(), order: doublePrecision('sort_order').notNull(), + organizationId: varchar('organization_id') + .notNull() + .references(() => organizationsTable.id, { onDelete: 'cascade' }), }); -export const membershipsTableRelations = relations(membershipsTable, ({ one }) => ({ - user: one(usersTable, { - fields: [membershipsTable.userId], - references: [usersTable.id], - }), - organization: one(organizationsTable, { - fields: [membershipsTable.organizationId], - references: [organizationsTable.id], - }), -})); - export const membershipSelect = { id: membershipsTable.id, role: membershipsTable.role, diff --git a/backend/src/db/schema/organizations.ts b/backend/src/db/schema/organizations.ts index 53d2b79ca..cdb094198 100644 --- a/backend/src/db/schema/organizations.ts +++ b/backend/src/db/schema/organizations.ts @@ -1,12 +1,12 @@ import { config } from 'config'; -import { relations } from 'drizzle-orm'; import { boolean, index, json, pgTable, timestamp, varchar } from 'drizzle-orm/pg-core'; import { usersTable } from '#/db/schema/users'; import { nanoid } from '#/utils/nanoid'; -import { membershipsTable } from './memberships'; type Language = (typeof config.languages)[number]['value']; +const languages = config.languages.map((lang) => lang.value) as [string, ...string[]]; + export const organizationsTable = pgTable( 'organizations', { @@ -19,11 +19,7 @@ export const organizationsTable = pgTable( slug: varchar('slug').unique().notNull(), country: varchar('country'), timezone: varchar('timezone'), - defaultLanguage: varchar('default_language', { - enum: ['en', 'nl'], - }) - .notNull() - .default(config.defaultLanguage), + defaultLanguage: varchar('default_language', { enum: languages }).notNull().default(config.defaultLanguage), languages: json('languages').$type().notNull().default([config.defaultLanguage]), notificationEmail: varchar('notification_email'), emailDomains: json('email_domains').$type().notNull().default([]), @@ -33,17 +29,12 @@ export const organizationsTable = pgTable( logoUrl: varchar('logo_url'), websiteUrl: varchar('website_url'), welcomeText: varchar('welcome_text'), - isProduction: boolean('is_production').notNull().default(false), authStrategies: json('auth_strategies').$type().notNull().default([]), chatSupport: boolean('chat_support').notNull().default(false), createdAt: timestamp('created_at').defaultNow().notNull(), - createdBy: varchar('created_by').references(() => usersTable.id, { - onDelete: 'set null', - }), + createdBy: varchar('created_by').references(() => usersTable.id, { onDelete: 'set null' }), modifiedAt: timestamp('modified_at'), - modifiedBy: varchar('modified_by').references(() => usersTable.id, { - onDelete: 'set null', - }), + modifiedBy: varchar('modified_by').references(() => usersTable.id, { onDelete: 'set null' }), }, (table) => { return { @@ -53,9 +44,5 @@ export const organizationsTable = pgTable( }, ); -export const organizationsTableRelations = relations(organizationsTable, ({ many }) => ({ - users: many(membershipsTable), -})); - export type OrganizationModel = typeof organizationsTable.$inferSelect; export type InsertOrganizationModel = typeof organizationsTable.$inferInsert; diff --git a/backend/src/db/schema/passkeys.ts b/backend/src/db/schema/passkeys.ts index bffc0c7dc..b2f17ecd0 100644 --- a/backend/src/db/schema/passkeys.ts +++ b/backend/src/db/schema/passkeys.ts @@ -1,4 +1,3 @@ -import { relations } from 'drizzle-orm'; import { pgTable, timestamp, varchar } from 'drizzle-orm/pg-core'; import { usersTable } from '#/db/schema/users'; import { nanoid } from '#/utils/nanoid'; @@ -13,12 +12,5 @@ export const passkeysTable = pgTable('passkeys', { createdAt: timestamp('created_at').defaultNow().notNull(), }); -export const passkeyTableRelations = relations(passkeysTable, ({ one }) => ({ - user: one(usersTable, { - fields: [passkeysTable.userEmail], - references: [usersTable.email], - }), -})); - export type PasskeyModel = typeof passkeysTable.$inferSelect; export type InsertPasskeyModel = typeof passkeysTable.$inferInsert; diff --git a/backend/src/db/schema/sessions.ts b/backend/src/db/schema/sessions.ts index d1094776b..6128297e7 100644 --- a/backend/src/db/schema/sessions.ts +++ b/backend/src/db/schema/sessions.ts @@ -28,7 +28,7 @@ export const sessionsTable = pgTable( }, (table) => { return { - adminUserIdIdx: index('idx_admin_id').on(table.adminUserId), + adminUserIdIndex: index('idx_admin_id').on(table.adminUserId), }; }, ); diff --git a/backend/src/db/schema/users.ts b/backend/src/db/schema/users.ts index 7774fff9c..0db6b40c5 100644 --- a/backend/src/db/schema/users.ts +++ b/backend/src/db/schema/users.ts @@ -1,8 +1,6 @@ import { config } from 'config'; -import { relations } from 'drizzle-orm'; import { boolean, foreignKey, index, pgTable, timestamp, varchar } from 'drizzle-orm/pg-core'; import { omitKeys } from '#/utils/omit'; -import { membershipsTable } from './memberships'; const roleEnum = config.rolesByType.systemRoles; @@ -31,7 +29,7 @@ export const usersTable = pgTable( thumbnailUrl: varchar('thumbnail_url'), newsletter: boolean('newsletter').notNull().default(false), lastSeenAt: timestamp('last_seen_at'), // last time any GET request has been made - lastVisitAt: timestamp('last_visit_at'), // last time GET me + lastStartedAt: timestamp('last_started_at'), // last time GET me lastSignInAt: timestamp('last_sign_in_at'), // last time user went through authentication flow createdAt: timestamp('created_at').defaultNow().notNull(), modifiedAt: timestamp('modified_at'), @@ -52,10 +50,6 @@ export const usersTable = pgTable( }, ); -export const usersTableRelations = relations(usersTable, ({ many }) => ({ - organizations: many(membershipsTable), -})); - export const safeUserSelect = omitKeys(usersTable, config.sensitiveFields); export type UnsafeUserModel = typeof usersTable.$inferSelect; diff --git a/backend/src/entity-config.ts b/backend/src/entity-config.ts index 6d04372d0..2db05b3cd 100644 --- a/backend/src/entity-config.ts +++ b/backend/src/entity-config.ts @@ -1,11 +1,20 @@ import { organizationsTable } from '#/db/schema/organizations'; import { usersTable } from '#/db/schema/users'; +import type { ContextEntity } from './types/common'; export type EntityTables = (typeof entityTables)[keyof typeof entityTables]; export type EntityTableNames = EntityTables['_']['name']; -export type StorageType = (typeof entityMenuSections)[number]['storageType']; +export type MenuSection = { + name: string; + entityType: ContextEntity; + submenu?: { + entityType: ContextEntity; + parentField: 'organizationId'; + }; +}; +export type MenuSectionName = MenuSection['name']; // Define what are the entities and their tables export const entityTables = { @@ -19,13 +28,10 @@ export const entityIdFields = { } as const; // Define how entities are rendered in user menu -export const entityMenuSections = [ +// Here you declare the menu sections +export const menuSections = [ { - storageType: 'organizations' as const, - type: 'organization' as const, - isSubmenu: false, - }, + name: 'organizations', + entityType: 'organization', + } as const, ]; - -// Expose unique storage types for menu schema -export const uniqueStorageTypes = Array.from(new Set(entityMenuSections.map((section) => section.storageType))); diff --git a/backend/src/lib/entity.ts b/backend/src/lib/entity.ts index 87c7af0f8..c242a9166 100644 --- a/backend/src/lib/entity.ts +++ b/backend/src/lib/entity.ts @@ -33,7 +33,7 @@ export async function resolveEntity(entityType: T, idOrSlug: s * @param entityType - The type of the entity. * @param ids - An array of unique identifiers (IDs) of the entities. */ -export const resolveEntities = async (entityType: Entity, ids: Array) => { +export async function resolveEntities(entityType: T, ids: Array): Promise>> { // Get the corresponding table for the entity type const table = entityTables[entityType]; @@ -46,5 +46,5 @@ export const resolveEntities = async (entityType: Entity, ids: Array) => // Query for multiple entities by IDs const entities = await db.select().from(table).where(inArray(table.id, ids)); - return entities; -}; + return entities as Array>; +} diff --git a/backend/src/lib/sse.ts b/backend/src/lib/sse.ts index 1630d103e..1a500c39d 100644 --- a/backend/src/lib/sse.ts +++ b/backend/src/lib/sse.ts @@ -11,7 +11,7 @@ const sendSSE = (userId: string, eventName: string, data: Record): void => { - if (!users || users.length === 0) return; - users.map((id) => sendSSE(id, eventName, data)); +export const sendSSEToUsers = (userIds: string[] | null, eventName: string, data: Record): void => { + if (!userIds || userIds.length === 0) return; + userIds.map((id) => sendSSE(id, eventName, data)); }; diff --git a/backend/src/modules/auth/helpers/user.ts b/backend/src/modules/auth/helpers/user.ts index 40ad322cf..2cba7d169 100644 --- a/backend/src/modules/auth/helpers/user.ts +++ b/backend/src/modules/auth/helpers/user.ts @@ -16,16 +16,16 @@ export const handleCreateUser = async ( ctx: Context, data: Omit, options?: { + isInvite?: boolean; provider?: { id: OauthProviderOptions; userId: string; }; - isEmailVerified?: boolean; redirectUrl?: string; }, ) => { // If sign up is disabled, return an error - if (!config.has.registrationEnabled) return errorResponse(ctx, 403, 'sign_up_disabled', 'warn', undefined); + if (!config.has.registrationEnabled && !options?.isInvite) return errorResponse(ctx, 403, 'sign_up_disabled', 'warn'); // Check if the slug is available const slugAvailable = await checkSlugAvailable(data.slug); @@ -38,6 +38,7 @@ export const handleCreateUser = async ( id: data.id, slug: slugAvailable ? data.slug : `${data.slug}-${data.id}`, firstName: data.firstName, + emailVerified: data.emailVerified, email: data.email.toLowerCase(), name: data.name, unsubscribeToken: generateUnsubscribeToken(data.email), @@ -53,7 +54,7 @@ export const handleCreateUser = async ( } // If the email is not verified, send a verification email - if (!options?.isEmailVerified) { + if (!data.emailVerified) { sendVerificationEmail(data.email); } else { await setSessionCookie(ctx, user.id, 'password'); @@ -63,7 +64,7 @@ export const handleCreateUser = async ( } catch (error) { // If the email already exists, return an error if (error instanceof Error && error.message.startsWith('duplicate key')) { - return errorResponse(ctx, 409, 'email_exists', 'warn', undefined); + return errorResponse(ctx, 409, 'email_exists', 'warn'); } if (error instanceof Error) { diff --git a/backend/src/modules/auth/index.ts b/backend/src/modules/auth/index.ts index 636709e35..68b5eb4fe 100644 --- a/backend/src/modules/auth/index.ts +++ b/backend/src/modules/auth/index.ts @@ -94,7 +94,13 @@ const authRoutes = app let tokenData: TokenData | undefined; if (token) { - const response = await fetch(`${config.backendUrl + generalRouteConfig.checkToken.path.replace('{token}', token)}`); + const response = await fetch(`${config.backendUrl + generalRouteConfig.checkToken.path}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ token }), + }); const data: CheckTokenResponse = await response.json(); tokenData = data?.data; @@ -113,13 +119,12 @@ const authRoutes = app slug, name: slug, email: email, + emailVerified: isEmailVerified, language: config.defaultLanguage, hashedPassword, }; - await handleCreateUser(ctx, newUser, { isEmailVerified }); - - return ctx.json({ success: true }, 200); + return await handleCreateUser(ctx, newUser, { isInvite: !!tokenData }); }) /* * Send verification email @@ -312,7 +317,13 @@ const authRoutes = app let tokenData: TokenData | undefined; if (token) { - const response = await fetch(`${config.backendUrl + generalRouteConfig.checkToken.path.replace('{token}', token)}`); + const response = await fetch(`${config.backendUrl + generalRouteConfig.checkToken.path}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ token }), + }); const data: CheckTokenResponse = await response.json(); tokenData = data?.data; @@ -605,7 +616,7 @@ const authRoutes = app id: strategy, userId: String(githubUser.id), }, - isEmailVerified: primaryEmail.verified, + isInvite: !!inviteToken, redirectUrl: redirectNewUserUrl, }, ); @@ -699,6 +710,7 @@ const authRoutes = app slug: slugFromEmail(user.email), email: user.email.toLowerCase(), name: user.given_name, + emailVerified: user.email_verified, language: config.defaultLanguage, thumbnailUrl: user.picture, firstName: user.given_name, @@ -709,7 +721,6 @@ const authRoutes = app id: strategy, userId: user.sub, }, - isEmailVerified: user.email_verified, redirectUrl: redirectNewUserUrl, }, ); @@ -803,6 +814,7 @@ const authRoutes = app slug: slugFromEmail(user.email), language: config.defaultLanguage, email: user.email.toLowerCase(), + emailVerified: false, name: user.given_name, thumbnailUrl: user.picture, firstName: user.given_name, @@ -813,7 +825,6 @@ const authRoutes = app id: strategy, userId: user.sub, }, - isEmailVerified: false, redirectUrl: redirectNewUserUrl, }, ); diff --git a/backend/src/modules/auth/routes.ts b/backend/src/modules/auth/routes.ts index b4cd7362a..708e82f2e 100644 --- a/backend/src/modules/auth/routes.ts +++ b/backend/src/modules/auth/routes.ts @@ -114,6 +114,10 @@ class AuthRoutesConfig { }, }, }, + 302: { + headers: z.object({ Location: z.string() }), + description: 'Redirect to frontend', + }, ...errorResponses, }, }); diff --git a/backend/src/modules/general/index.ts b/backend/src/modules/general/index.ts index 520f97d32..1bfc6cae1 100644 --- a/backend/src/modules/general/index.ts +++ b/backend/src/modules/general/index.ts @@ -14,7 +14,7 @@ import { db } from '#/db/db'; import { getContextUser, getMemberships } from '#/lib/context'; import { EventName, Paddle } from '@paddle/paddle-node-sdk'; -import { type MembershipModel, membershipsTable } from '#/db/schema/memberships'; +import { type MembershipModel, membershipSelect, membershipsTable } from '#/db/schema/memberships'; import { organizationsTable } from '#/db/schema/organizations'; import { type TokenModel, tokensTable } from '#/db/schema/tokens'; import { usersTable } from '#/db/schema/users'; @@ -25,7 +25,7 @@ import { i18n } from '#/lib/i18n'; import { isAuthenticated } from '#/middlewares/guard'; import { logEvent } from '#/middlewares/logger/log-event'; import { verifyUnsubscribeToken } from '#/modules/users/helpers/unsubscribe-token'; -import { type ContextEntity, CustomHono } from '#/types/common'; +import { CustomHono } from '#/types/common'; import { insertMembership } from '../memberships/helpers/insert-membership'; import { checkSlugAvailable } from './helpers/check-slug'; import generalRouteConfig from './routes'; @@ -81,7 +81,7 @@ const generalRoutes = app .select() .from(tokensTable) .where(and(eq(tokensTable.id, token))); - // if (!tokenRecord?.email) return errorResponse(ctx, 404, 'not_found', 'warn', 'token'); + if (!tokenRecord) return errorResponse(ctx, 404, 'not_found', 'warn'); // const user = await getUserBy('email', tokenRecord.email); // if (!user) return errorResponse(ctx, 404, 'not_found', 'warn', 'user'); @@ -258,75 +258,72 @@ const generalRoutes = app */ .openapi(generalRouteConfig.getSuggestionsConfig, async (ctx) => { const { q, type } = ctx.req.valid('query'); + const user = getContextUser(); const memberships = getMemberships(); - // Retrieve organizationIds for non-admin users and check if the user has at least one organization membership - let organizationIds: string[] = []; - - if (user.role !== 'admin') { - organizationIds = memberships.filter((el) => el.type === 'organization').map((el) => String(el.organizationId)); - if (!organizationIds.length) return errorResponse(ctx, 403, 'forbidden', 'warn', undefined, { user: user.id }); - } + // Retrieve organizationIds + const organizationIds = memberships.filter((el) => el.type === 'organization').map((el) => String(el.organizationId)); + if (!organizationIds.length) return errorResponse(ctx, 403, 'forbidden', 'warn', undefined); - // Provide suggestions for all entities or narrow them down to a specific type if specified + // Determine the entity types to query, default to all types if not specified const entityTypes = type ? [type] : config.pageEntityTypes; // Array to hold queries for concurrent execution - const queries = []; - - for (const entityType of entityTypes) { - const table = entityTables[entityType]; - if (!table) continue; - const entityIdField = entityIdFields[entityType]; - - // Basic selection setup - const baseSelect = { - id: table.id, - slug: table.slug, - name: table.name, - entity: table.entity, - ...('email' in table && { email: table.email }), - ...('thumbnailUrl' in table && { thumbnailUrl: table.thumbnailUrl }), - }; - - // Build search filters - const $or = [ilike(table.name, `%${q}%`)]; - if ('email' in table) $or.push(ilike(table.email, `%${q}%`)); - - // Build organization filters - const $and = []; - if (organizationIds.length) { - const $membershipAnd = [inArray(membershipsTable.organizationId, organizationIds)]; - if (config.contextEntityTypes.includes(entityType as ContextEntity)) { - $membershipAnd.push(eq(membershipsTable.type, entityType as ContextEntity)); + const queries = entityTypes + .map((entityType) => { + const table = entityTables[entityType]; + const entityIdField = entityIdFields[entityType]; + if (!table) return null; + + // Base selection setup including membership details + const baseSelect = { + id: table.id, + slug: table.slug, + name: table.name, + entity: table.entity, + ...('email' in table && { email: table.email }), + ...('thumbnailUrl' in table && { thumbnailUrl: table.thumbnailUrl }), + }; + + // Build search filters + const $or = [ilike(table.name, `%${q}%`)]; + if ('email' in table) { + $or.push(ilike(table.email, `%${q}%`)); } - const memberships = await db - .select() - .from(membershipsTable) - .where(and(...$membershipAnd)); - - const uniqueValuesSet = new Set(); - - for (const member of memberships) { - const id = member[entityIdField]; - if (id) uniqueValuesSet.add(id); + // For users, no need to join memberships, just perform the search + if (entityType === 'user') { + return db + .select(baseSelect) + .from(table) + .where(or(...$or)) + .limit(10); } - const uniqueValuesArray = Array.from(uniqueValuesSet); - $and.push(inArray(table.id, uniqueValuesArray)); - } - - $and.push($or.length > 1 ? or(...$or) : $or[0]); - const $where = $and.length > 1 ? and(...$and) : $and[0]; - - // Build query - queries.push(db.select(baseSelect).from(table).where($where).limit(10)); - } + // For other entities, perform the join with memberships + const $where = and( + or(...$or), + eq(membershipsTable.userId, user.id), + inArray(membershipsTable.organizationId, organizationIds), + eq(membershipsTable[entityIdField], table.id), + ); + + // Execute the query using inner join with memberships table + return db + .select({ + ...baseSelect, + membership: membershipSelect, + }) + .from(table) + .leftJoin(membershipsTable, and(eq(table.id, membershipsTable[entityIdField]), eq(membershipsTable.type, entityType))) + .where($where) + .limit(10); + }) + .filter(Boolean); // Filter out null values if any entity type is invalid const results = await Promise.all(queries); - const items = results.flat(); + const items = results.flat().filter((item) => item !== null); return ctx.json({ success: true, data: { items, total: items.length } }, 200); }) @@ -338,14 +335,15 @@ const generalRoutes = app if (!token) return errorResponse(ctx, 400, 'No token provided', 'warn', 'user'); + // Check if token exists const user = await getUserBy('unsubscribeToken', token); - if (!user) return errorResponse(ctx, 404, 'not_found', 'warn', 'user'); + // Verify token const isValid = verifyUnsubscribeToken(user.email, token); - if (!isValid) return errorResponse(ctx, 400, 'Token verification failed', 'warn', 'user'); + // Update user await db.update(usersTable).set({ newsletter: false }).where(eq(usersTable.id, user.id)); const redirectUrl = `${config.frontendUrl}/unsubscribe`; diff --git a/backend/src/modules/general/schema.ts b/backend/src/modules/general/schema.ts index b24b3d202..fc8e6c83f 100644 --- a/backend/src/modules/general/schema.ts +++ b/backend/src/modules/general/schema.ts @@ -42,7 +42,7 @@ export const entitySuggestionSchema = z.object({ email: z.string().optional(), thumbnailUrl: imageUrlSchema.nullable().optional(), entity: pageEntityTypeSchema, - parentId: z.string().nullable().optional(), + membership: membershipInfoSchema.nullable().optional(), }); export type Suggestion = z.infer; diff --git a/backend/src/modules/me/index.ts b/backend/src/modules/me/index.ts index ee12c6f55..802784fa4 100644 --- a/backend/src/modules/me/index.ts +++ b/backend/src/modules/me/index.ts @@ -2,22 +2,21 @@ import { and, asc, eq } from 'drizzle-orm'; import { db } from '#/db/db'; import { auth } from '#/db/lucia'; -import { membershipSelect, membershipsTable } from '#/db/schema/memberships'; import { usersTable } from '#/db/schema/users'; import { type ErrorType, createError, errorResponse } from '#/lib/errors'; import { logEvent } from '#/middlewares/logger/log-event'; -import { type ContextEntity, CustomHono, type EnabledOauthProviderOptions } from '#/types/common'; +import { CustomHono, type EnabledOauthProviderOptions } from '#/types/common'; import { removeSessionCookie } from '../auth/helpers/cookies'; import { checkSlugAvailable } from '../general/helpers/check-slug'; import { transformDatabaseUserWithCount } from '../users/helpers/transform-database-user'; import meRoutesConfig from './routes'; import { config } from 'config'; -import type { PgColumn } from 'drizzle-orm/pg-core'; import type { z } from 'zod'; +import { membershipSelect, membershipsTable } from '#/db/schema/memberships'; import { oauthAccountsTable } from '#/db/schema/oauth-accounts'; import { passkeysTable } from '#/db/schema/passkeys'; -import { entityIdFields, entityMenuSections, entityTables } from '#/entity-config'; +import { type MenuSection, entityIdFields, entityTables, menuSections } from '#/entity-config'; import { getContextUser, getMemberships } from '#/lib/context'; import { getPreparedSessions } from './helpers/get-sessions'; import type { menuItemsSchema, userMenuSchema } from './schema'; @@ -35,116 +34,119 @@ const meRoutes = app const passkey = await db.select().from(passkeysTable).where(eq(passkeysTable.userEmail, user.email)); + // List enabled identity providers const oauthAccounts = await db - .select({ - providerId: oauthAccountsTable.providerId, - }) + .select({ providerId: oauthAccountsTable.providerId }) .from(oauthAccountsTable) .where(eq(oauthAccountsTable.userId, user.id)); const validOAuthAccounts = oauthAccounts .map((el) => el.providerId) .filter((provider): provider is EnabledOauthProviderOptions => config.enabledOauthProviders.includes(provider as EnabledOauthProviderOptions)); + // Update last visit date - await db.update(usersTable).set({ lastVisitAt: new Date() }).where(eq(usersTable.id, user.id)); - - return ctx.json( - { - success: true, - data: { - ...transformDatabaseUserWithCount(user, memberships.length), - oauth: validOAuthAccounts, - passkey: !!passkey.length, - sessions: await getPreparedSessions(user.id, ctx), - }, - }, - 200, - ); + await db.update(usersTable).set({ lastStartedAt: new Date() }).where(eq(usersTable.id, user.id)); + + // Prepare data + const data = { + ...transformDatabaseUserWithCount(user, memberships.length), + oauth: validOAuthAccounts, + passkey: !!passkey.length, + sessions: await getPreparedSessions(user.id, ctx), + }; + + return ctx.json({ success: true, data }, 200); }) - /* - * Get current user menu - */ + // Your main function .openapi(meRoutesConfig.getUserMenu, async (ctx) => { const user = getContextUser(); + const memberships = getMemberships(); - const fetchAndFormatEntities = async (type: ContextEntity, subEntityType?: ContextEntity) => { - let formattedSubmenus: z.infer; - const mainTable = entityTables[type]; - const mainEntityIdField = entityIdFields[type]; + // Fetch function for each menu section, including handling submenus + const fetchMenuItemsForSection = async (section: MenuSection) => { + let formattedSubmenus: Omit[number], 'submenu'>[]; + const mainTable = entityTables[section.entityType]; + const mainEntityIdField = entityIdFields[section.entityType]; const entity = await db .select({ - entity: mainTable, + item: mainTable, membership: membershipSelect, }) .from(mainTable) - .where(and(eq(membershipsTable.userId, user.id), eq(membershipsTable.type, type))) + .where(and(eq(membershipsTable.userId, user.id), eq(membershipsTable.type, section.entityType))) .orderBy(asc(membershipsTable.order)) .innerJoin(membershipsTable, eq(membershipsTable[mainEntityIdField], mainTable.id)); - if (subEntityType && 'parentId' in entityTables[subEntityType]) { - const subTable = entityTables[subEntityType]; - const subEntityIdField = entityIdFields[subEntityType]; + // If the section has a submenu, fetch the submenu items + if (section.submenu) { + const subTable = entityTables[section.submenu.entityType]; + const subEntityIdField = entityIdFields[section.submenu.entityType]; const subEntity = await db .select({ - entity: subTable, + item: subTable, membership: membershipSelect, - parent: mainTable, }) .from(subTable) - .where(and(eq(membershipsTable.userId, user.id), eq(membershipsTable.type, subEntityType))) + .where(and(eq(membershipsTable.userId, user.id), eq(membershipsTable.type, section.submenu.entityType))) .orderBy(asc(membershipsTable.order)) - .innerJoin(membershipsTable, eq(membershipsTable[subEntityIdField], subTable.id)) - .innerJoin(mainTable, eq(mainTable.id, subTable.parentId as PgColumn)); - - formattedSubmenus = subEntity.map(({ entity, membership, parent }) => ({ - slug: entity.slug, - id: entity.id, - createdAt: entity.createdAt.toDateString(), - modifiedAt: entity.modifiedAt?.toDateString() ?? null, - name: entity.name, - entity: entity.entity, - thumbnailUrl: entity.thumbnailUrl, + .innerJoin(membershipsTable, eq(membershipsTable[subEntityIdField], subTable.id)); + + // TODO is this formatting necessary? Can we return data with proper select? toDateString? + formattedSubmenus = subEntity.map(({ item, membership }) => ({ + slug: item.slug, + id: item.id, + createdAt: item.createdAt.toDateString(), + modifiedAt: item.modifiedAt?.toDateString() ?? null, + organizationId: membership.organizationId, + name: item.name, + entity: item.entity, + thumbnailUrl: item.thumbnailUrl, membership, - parentId: parent.id, - parentSlug: parent.slug, })); } - return entity.map(({ entity, membership }) => ({ - slug: entity.slug, - id: entity.id, - createdAt: entity.createdAt.toDateString(), - modifiedAt: entity.modifiedAt?.toDateString() ?? null, - name: entity.name, - entity: entity.entity, - thumbnailUrl: entity.thumbnailUrl, + const submenuParentField = section.submenu?.parentField; + + // TODO is this formatting necessary? Can we return data with proper select? toDateString is necessary? + const entityItems = entity.map(({ item, membership }) => ({ + slug: item.slug, + id: item.id, + createdAt: item.createdAt.toDateString(), + modifiedAt: item.modifiedAt?.toDateString() ?? null, + organizationId: membership.organizationId, + name: item.name, + entity: item.entity, + thumbnailUrl: item.thumbnailUrl, membership, - submenu: formattedSubmenus ? formattedSubmenus.filter((p) => p.parentId === entity.id) : [], + submenu: submenuParentField ? formattedSubmenus.filter((p) => p.membership[submenuParentField] === item.id) : [], })); + + return entityItems; }; - const data = await entityMenuSections - .filter((el) => !el.isSubmenu) - .reduce( + // Build the menu data asynchronously + const data = async () => { + const result = await menuSections.reduce( async (accPromise, section) => { const acc = await accPromise; - const submenu = entityMenuSections.find((el) => el.storageType === section.storageType && el.isSubmenu); - return { - ...acc, - [section.storageType]: await fetchAndFormatEntities(section.type, submenu?.type), - }; + if (!memberships.length) { + acc[section.name] = []; + return acc; + } + + // Fetch menu items for the current section + acc[section.name] = await fetchMenuItemsForSection(section); + return acc; }, Promise.resolve({} as z.infer), ); - return ctx.json( - { - success: true, - data, - }, - 200, - ); + + return result; + }; + + return ctx.json({ success: true, data: await data() }, 200); }) /* * Terminate a session @@ -223,17 +225,13 @@ const meRoutes = app .filter((provider): provider is EnabledOauthProviderOptions => config.enabledOauthProviders.includes(provider as EnabledOauthProviderOptions)); logEvent('User updated', { user: updatedUser.id }); - return ctx.json( - { - success: true, - data: { - ...transformDatabaseUserWithCount(updatedUser, memberships.length), - oauth: validOAuthAccounts, - passkey: !!passkey.length, - }, - }, - 200, - ); + const data = { + ...transformDatabaseUserWithCount(updatedUser, memberships.length), + oauth: validOAuthAccounts, + passkey: !!passkey.length, + }; + + return ctx.json({ success: true, data }, 200); }) /* * Delete current user (self) diff --git a/backend/src/modules/me/schema.ts b/backend/src/modules/me/schema.ts index cc5d31fdb..f411e9d56 100644 --- a/backend/src/modules/me/schema.ts +++ b/backend/src/modules/me/schema.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; import { config } from 'config'; -import { type StorageType, uniqueStorageTypes } from '#/entity-config'; +import { type MenuSectionName, menuSections } from '#/entity-config'; import { idSchema, imageUrlSchema, nameSchema, slugSchema } from '#/utils/schema/common-schemas'; import { membershipInfoSchema } from '../memberships/schema'; import { userSchema } from '../users/schema'; @@ -39,9 +39,8 @@ export const menuItemSchema = z.object({ thumbnailUrl: imageUrlSchema.nullish(), entity: z.enum(config.contextEntityTypes), membership: membershipInfoSchema, - parentId: z.string().nullable().optional(), - parentSlug: z.string().optional(), - organizationId: z.string().optional(), + // TODO perhaps use membershipInfoSchema here, since membership should always be present in menu + organizationId: z.string().optional().nullable(), }); export const menuItemsSchema = z.array( @@ -51,13 +50,13 @@ export const menuItemsSchema = z.array( }), ); -// Create a menu schema based on entity storage types +// Create a menu schema based on menu sections in entity-config export const userMenuSchema = z.object( - uniqueStorageTypes.reduce( - (schema, storageType) => { - schema[storageType] = menuItemsSchema; - return schema; + menuSections.reduce( + (acc, section) => { + acc[section.name] = menuItemsSchema; + return acc; }, - {} as Record, + {} as Record, ), ); diff --git a/backend/src/modules/memberships/helpers/insert-membership.ts b/backend/src/modules/memberships/helpers/insert-membership.ts index 0aee1f47d..ccddfc3de 100644 --- a/backend/src/modules/memberships/helpers/insert-membership.ts +++ b/backend/src/modules/memberships/helpers/insert-membership.ts @@ -23,6 +23,7 @@ export const insertMembership = async > .from(membershipsTable) .where(eq(membershipsTable.userId, user.id)); + //TODO - make this generic const newMembership: InsertMembershipModel = { organizationId: '', type: entity.entity, diff --git a/backend/src/modules/memberships/index.ts b/backend/src/modules/memberships/index.ts index c23502653..aaa01d987 100644 --- a/backend/src/modules/memberships/index.ts +++ b/backend/src/modules/memberships/index.ts @@ -333,7 +333,12 @@ const membershipsRoutes = app orderToUpdate = ceilOrder + 1; } - const membershipContext = await resolveEntity(updatedType, membershipToUpdate[updatedEntityIdField]); + + const membershipContextId = membershipToUpdate[updatedEntityIdField]; + + if (!membershipContextId) return errorResponse(ctx, 404, 'not_found', 'warn', updatedType); + + const membershipContext = await resolveEntity(updatedType, membershipContextId); if (!membershipContext) return errorResponse(ctx, 404, 'not_found', 'warn', updatedType); @@ -345,7 +350,7 @@ const membershipsRoutes = app .where(and(eq(membershipsTable.type, updatedType), eq(membershipsTable.userId, user.id))); const isAllowed = permissionManager.isPermissionAllowed(permissionMemberships, 'update', membershipContext); if (!isAllowed && user.role !== 'admin') { - return errorResponse(ctx, 403, 'forbidden', 'warn', updatedType, { user: user.id, id: membershipContext.id }); + return errorResponse(ctx, 403, 'forbidden', 'warn', updatedType, { membership: membershipContext.id }); } } @@ -369,21 +374,15 @@ const membershipsRoutes = app logEvent('Membership updated', { user: updatedMembership.userId, membership: updatedMembership.id }); - return ctx.json( - { - success: true, - data: updatedMembership, - }, - 200, - ); + return ctx.json({ success: true, data: updatedMembership }, 200); }) /* - * Get members by entity id and type + * Get members by entity id/slug and type */ .openapi(membershipRouteConfig.getMembers, async (ctx) => { const { idOrSlug, entityType, q, sort, order, offset, limit, role } = ctx.req.valid('query'); - const entity = await resolveEntity(entityType, idOrSlug); + const entity = await resolveEntity(entityType, idOrSlug); if (!entity) return errorResponse(ctx, 404, 'not_found', 'warn', entityType); const entityIdField = entityIdFields[entity.entity]; diff --git a/backend/src/modules/memberships/schema.ts b/backend/src/modules/memberships/schema.ts index 187f8348e..5208bc987 100644 --- a/backend/src/modules/memberships/schema.ts +++ b/backend/src/modules/memberships/schema.ts @@ -35,6 +35,7 @@ export const createMembershipQuerySchema = baseMembersQuerySchema; export const deleteMembersQuerySchema = baseMembersQuerySchema.extend(idsQuerySchema.shape); +// TODO make generic the parent tree using mapping of entities export const membershipInfoSchema = z.object({ id: membershipTableSchema.shape.id, role: membershipTableSchema.shape.role, diff --git a/backend/src/modules/organizations/index.ts b/backend/src/modules/organizations/index.ts index 9a9c15293..4955ed7c7 100644 --- a/backend/src/modules/organizations/index.ts +++ b/backend/src/modules/organizations/index.ts @@ -7,7 +7,7 @@ import { config } from 'config'; import { render } from 'jsx-email'; import { usersTable } from '#/db/schema/users'; import { getUserBy } from '#/db/util'; -import { getContextUser, getMemberships, getOrganization } from '#/lib/context'; +import { getContextUser, getMemberships } from '#/lib/context'; import { resolveEntity } from '#/lib/entity'; import { type ErrorType, createError, errorResponse } from '#/lib/errors'; import { emailSender } from '#/lib/mailer'; @@ -35,8 +35,8 @@ const organizationsRoutes = app const { name, slug } = ctx.req.valid('json'); const user = getContextUser(); + // Check if slug is available const slugAvailable = await checkSlugAvailable(slug); - if (!slugAvailable) return errorResponse(ctx, 409, 'slug_exists', 'warn', 'organization', { slug }); const [createdOrganization] = await db @@ -56,23 +56,13 @@ const organizationsRoutes = app // Insert membership const createdMembership = await insertMembership({ user, role: 'admin', entity: createdOrganization }); - return ctx.json( - { - success: true, - data: { - ...createdOrganization, - membership: createdMembership, - counts: { - memberships: { - admins: 1, - members: 1, - total: 1, - }, - }, - }, - }, - 200, - ); + const data = { + ...createdOrganization, + membership: createdMembership, + counts: { memberships: { admins: 1, members: 1, total: 1 } }, + }; + + return ctx.json({ success: true, data }, 200); }) /* * Get list of organizations @@ -120,46 +110,41 @@ const organizationsRoutes = app }, }) .from(organizationsQuery.as('organizations')) - .leftJoin(memberships, eq(organizationsTable.id, memberships.organizationId)) + .leftJoin(memberships, and(eq(organizationsTable.id, memberships.organizationId), eq(memberships.userId, user.id))) .leftJoin(countsQuery, eq(organizationsTable.id, countsQuery.id)) .orderBy(orderColumn) .limit(Number(limit)) .offset(Number(offset)); - return ctx.json( - { - success: true, - data: { - items: organizations, - total, - }, - }, - 200, - ); + return ctx.json({ success: true, data: { items: organizations, total } }, 200); }) /* * Update an organization by id or slug */ .openapi(organizationRoutesConfig.updateOrganization, async (ctx) => { + const { idOrSlug } = ctx.req.valid('param'); + const user = getContextUser(); const memberships = getMemberships(); - const organization = getOrganization(); + + // Resolve organization + const organization = await resolveEntity('organization', idOrSlug); + if (!organization) return errorResponse(ctx, 404, 'not_found', 'warn', 'organization', { idOrSlug }); // If not allowed and not admin, return forbidden const canUpdate = permissionManager.isPermissionAllowed(memberships, 'update', organization); - if (!canUpdate && user.role !== 'admin') { - return errorResponse(ctx, 403, 'forbidden', 'warn', 'organization', { user: user.id, id: organization.id }); - } + if (!canUpdate && user.role !== 'admin') return errorResponse(ctx, 403, 'forbidden', 'warn', 'organization'); + + // TODO remove this and user permission manager once it returns membership + const userMembership = memberships.find((m) => m.organizationId === organization.id && m.type === 'organization'); + if (!userMembership) return errorResponse(ctx, 403, 'forbidden', 'warn', 'organization'); const updatedFields = ctx.req.valid('json'); const slug = updatedFields.slug; if (slug && slug !== organization.slug) { const slugAvailable = await checkSlugAvailable(slug); - - if (!slugAvailable) { - return errorResponse(ctx, 409, 'slug_exists', 'warn', 'organization', { slug }); - } + if (!slugAvailable) return errorResponse(ctx, 409, 'slug_exists', 'warn', 'organization', { slug }); } const [updatedOrganization] = await db @@ -172,37 +157,30 @@ const organizationsRoutes = app .where(eq(organizationsTable.id, organization.id)) .returning(); - const membershipsToUpdate = await db + const organizationMemberships = await db .select(membershipSelect) .from(membershipsTable) .where(and(eq(membershipsTable.type, 'organization'), eq(membershipsTable.organizationId, organization.id))); - if (membershipsToUpdate.length > 0) { - membershipsToUpdate.map((membership) => - sendSSEToUsers([membership.userId], 'update_entity', { - ...updatedOrganization, - membership: membershipsToUpdate.find((m) => m.id === membership.id) ?? null, - }), - ); + // Send SSE events to organization members + for (const membership of organizationMemberships) { + sendSSEToUsers([membership.userId], 'update_entity', { ...updatedOrganization, membership }); } logEvent('Organization updated', { organization: updatedOrganization.id }); const memberCounts = await memberCountsQuery('organization', 'organizationId', organization.id); - return ctx.json( - { - success: true, - data: { - ...updatedOrganization, - membership: membershipsToUpdate.find((m) => m.id === user.id) ?? null, - counts: { - memberships: memberCounts, - }, - }, + // Prepare data + const data = { + ...updatedOrganization, + membership: userMembership, + counts: { + memberships: memberCounts, }, - 200, - ); + }; + + return ctx.json({ success: true, data }, 200); }) /* * Get organization by id or slug @@ -214,33 +192,26 @@ const organizationsRoutes = app const memberships = getMemberships(); const organization = await resolveEntity('organization', idOrSlug); - - if (!organization) { - return errorResponse(ctx, 404, 'not_found', 'warn', 'organization', { id: idOrSlug }); - } + if (!organization) return errorResponse(ctx, 404, 'not_found', 'warn', 'organization', { id: idOrSlug }); // If not allowed and not admin, return forbidden const canRead = permissionManager.isPermissionAllowed(memberships, 'read', organization); - if (!canRead && user.role !== 'admin') { - return errorResponse(ctx, 403, 'forbidden', 'warn', 'organization', { user: user.id, id: idOrSlug }); - } + if (!canRead && user.role !== 'admin') return errorResponse(ctx, 403, 'forbidden', 'warn', 'organization'); + + const membership = memberships.find((m) => m.organizationId === organization.id && m.type === 'organization'); + if (!membership) return errorResponse(ctx, 403, 'forbidden', 'warn', 'organization'); - const membership = memberships.find((m) => m.organizationId === organization.id && m.type === 'organization') ?? null; const memberCounts = await memberCountsQuery('organization', 'organizationId', organization.id); - return ctx.json( - { - success: true, - data: { - ...organization, - membership, - counts: { - memberships: memberCounts, - }, - }, + const data = { + ...organization, + membership, + counts: { + memberships: memberCounts, }, - 200, - ); + }; + + return ctx.json({ success: true, data }, 200); }) /* @@ -254,21 +225,17 @@ const organizationsRoutes = app // Convert the ids to an array const toDeleteIds = Array.isArray(ids) ? ids : [ids]; - if (!toDeleteIds.length) { - return errorResponse(ctx, 400, 'invalid_request', 'warn', 'organization'); - } + if (!toDeleteIds.length) return errorResponse(ctx, 400, 'invalid_request', 'warn', 'organization'); const { allowedIds, disallowedIds } = await splitByAllowance('delete', 'organization', toDeleteIds, memberships); // Map errors of organization user is not allowed to delete const errors: ErrorType[] = disallowedIds.map((id) => createError(ctx, 404, 'not_found', 'warn', 'organization', { organization: id })); - if (!allowedIds.length) { - return errorResponse(ctx, 403, 'forbidden', 'warn', 'organization'); - } + if (!allowedIds.length) return errorResponse(ctx, 403, 'forbidden', 'warn', 'organization'); - // Get members - const organizationsMembers = await db + // Get ids of members for organizations + const memberIds = await db .select({ id: membershipsTable.userId }) .from(membershipsTable) .where(and(eq(membershipsTable.type, 'organization'), inArray(membershipsTable.organizationId, allowedIds))); @@ -276,13 +243,14 @@ const organizationsRoutes = app // Delete the organizations await db.delete(organizationsTable).where(inArray(organizationsTable.id, allowedIds)); - // Send SSE events for the organizations that were deleted + // Send SSE events to all members of organizations that were deleted for (const id of allowedIds) { - if (!organizationsMembers.length) continue; + if (!memberIds.length) continue; - const membersId = organizationsMembers.map((member) => member.id); - sendSSEToUsers(membersId, 'remove_entity', { id, entity: 'organization' }); + const userIds = memberIds.map((m) => m.id); + sendSSEToUsers(userIds, 'remove_entity', { id, entity: 'organization' }); } + logEvent('Organizations deleted', { ids: allowedIds.join() }); return ctx.json({ success: true, errors: errors }, 200); diff --git a/backend/src/modules/organizations/routes.ts b/backend/src/modules/organizations/routes.ts index b9a00b109..5bc05ad25 100644 --- a/backend/src/modules/organizations/routes.ts +++ b/backend/src/modules/organizations/routes.ts @@ -8,6 +8,7 @@ import { successWithoutDataSchema, } from '#/utils/schema/common-responses'; import { entityParamSchema, idsQuerySchema } from '#/utils/schema/common-schemas'; +import { membershipInfoSchema } from '../memberships/schema'; import { createOrganizationBodySchema, getOrganizationsQuerySchema, @@ -39,7 +40,7 @@ class OrganizationRoutesConfig { description: 'Organization was createRouteConfigd', content: { 'application/json': { - schema: successWithDataSchema(organizationSchema), + schema: successWithDataSchema(organizationSchema.extend({ membership: membershipInfoSchema })), }, }, }, @@ -92,7 +93,7 @@ class OrganizationRoutesConfig { description: 'Organization was updated', content: { 'application/json': { - schema: successWithDataSchema(organizationSchema), + schema: successWithDataSchema(organizationSchema.extend({ membership: membershipInfoSchema })), }, }, }, diff --git a/backend/src/modules/users/helpers/transform-database-user.ts b/backend/src/modules/users/helpers/transform-database-user.ts index 2368954b8..c6b58a4a3 100644 --- a/backend/src/modules/users/helpers/transform-database-user.ts +++ b/backend/src/modules/users/helpers/transform-database-user.ts @@ -10,7 +10,7 @@ export const transformDatabaseUserWithCount = ( return { ...user, lastSeenAt: user.lastSeenAt?.toISOString() ?? null, - lastVisitAt: user.lastVisitAt?.toISOString() ?? null, + lastStartedAt: user.lastStartedAt?.toISOString() ?? null, lastSignInAt: user.lastSignInAt?.toISOString() ?? null, createdAt: user.createdAt.toISOString(), modifiedAt: user.modifiedAt?.toISOString() ?? null, diff --git a/backend/src/modules/users/index.ts b/backend/src/modules/users/index.ts index 8a98115d0..9079a8a47 100644 --- a/backend/src/modules/users/index.ts +++ b/backend/src/modules/users/index.ts @@ -3,7 +3,6 @@ import { and, count, eq, ilike, inArray, or } from 'drizzle-orm'; import { coalesce, db } from '#/db/db'; import { auth } from '#/db/lucia'; import { membershipsTable } from '#/db/schema/memberships'; -import { organizationsTable } from '#/db/schema/organizations'; import { safeUserSelect, usersTable } from '#/db/schema/users'; import { getUsersByConditions } from '#/db/util'; import { getContextUser } from '#/lib/context'; @@ -162,22 +161,14 @@ const usersRoutes = app if (!targetUser) return errorResponse(ctx, 404, 'not_found', 'warn', 'user', { user: idOrSlug }); + // Now only admins or the user themselves can view a user + // TODO allow organization members to view each other using getMemberships if (user.role !== 'admin' && user.id !== targetUser.id) { return errorResponse(ctx, 403, 'forbidden', 'warn', 'user', { user: targetUser.id }); } - const userOrganizations = await db - .select({ - id: organizationsTable.id, - slug: organizationsTable.slug, - name: organizationsTable.name, - entity: organizationsTable.entity, - thumbnailUrl: organizationsTable.thumbnailUrl, - }) - .from(organizationsTable) - .innerJoin(membershipsTable, and(eq(membershipsTable.userId, targetUser.id), eq(membershipsTable.type, 'organization'))) - .where(eq(organizationsTable.id, membershipsTable.organizationId)); - + // Get the user's membership count + // TODO: put in a helper function const [{ memberships }] = await db .select({ memberships: count(), @@ -185,13 +176,7 @@ const usersRoutes = app .from(membershipsTable) .where(eq(membershipsTable.userId, targetUser.id)); - return ctx.json( - { - success: true, - data: { ...transformDatabaseUserWithCount(targetUser, memberships), ...{ organizations: userOrganizations } }, - }, - 200, - ); + return ctx.json({ success: true, data: transformDatabaseUserWithCount(targetUser, memberships) }, 200); }) /* * Update a user by id or slug @@ -200,22 +185,16 @@ const usersRoutes = app const { idOrSlug } = ctx.req.valid('param'); const user = getContextUser(); - const [targetUser] = await getUsersByConditions([or(eq(usersTable.id, idOrSlug), eq(usersTable.slug, idOrSlug))]); + const [targetUser] = await getUsersByConditions([or(eq(usersTable.id, idOrSlug), eq(usersTable.slug, idOrSlug))]); if (!targetUser) return errorResponse(ctx, 404, 'not_found', 'warn', 'user', { user: idOrSlug }); - if (user.role !== 'admin' && user.id !== targetUser.id) { - return errorResponse(ctx, 403, 'forbidden', 'warn', 'user', { user: idOrSlug }); - } - const { email, bannerUrl, bio, firstName, lastName, language, newsletter, thumbnailUrl, slug, role } = ctx.req.valid('json'); + // Check if slug is available if (slug && slug !== targetUser.slug) { const slugAvailable = await checkSlugAvailable(slug); - - if (!slugAvailable) { - return errorResponse(ctx, 409, 'slug_exists', 'warn', 'user', { slug }); - } + if (!slugAvailable) return errorResponse(ctx, 409, 'slug_exists', 'warn', 'user', { slug }); } const [updatedUser] = await db @@ -238,6 +217,8 @@ const usersRoutes = app .where(eq(usersTable.id, targetUser.id)) .returning(); + // Get the user's membership count + // TODO: put in a helper function const [{ memberships }] = await db .select({ memberships: count(), @@ -246,13 +227,9 @@ const usersRoutes = app .where(eq(membershipsTable.userId, updatedUser.id)); logEvent('User updated', { user: updatedUser.id }); - return ctx.json( - { - success: true, - data: transformDatabaseUserWithCount(updatedUser, memberships), - }, - 200, - ); + + const data = transformDatabaseUserWithCount(updatedUser, memberships); + return ctx.json({ success: true, data }, 200); }); export default usersRoutes; diff --git a/backend/src/modules/users/routes.ts b/backend/src/modules/users/routes.ts index e5d26d7e4..314d042d3 100644 --- a/backend/src/modules/users/routes.ts +++ b/backend/src/modules/users/routes.ts @@ -1,9 +1,7 @@ -import { z } from 'zod'; import { createRouteConfig } from '#/lib/route-config'; import { isAuthenticated, systemGuard } from '#/middlewares/guard'; import { errorResponses, successWithDataSchema, successWithErrorsSchema, successWithPaginationSchema } from '#/utils/schema/common-responses'; import { entityParamSchema, idsQuerySchema } from '#/utils/schema/common-schemas'; -import { entitySuggestionSchema } from '../general/schema'; import { updateUserBodySchema, userSchema, usersQuerySchema } from './schema'; class UsersRoutesConfig { @@ -68,7 +66,7 @@ class UsersRoutesConfig { description: 'User', content: { 'application/json': { - schema: successWithDataSchema(z.object({ ...userSchema.shape, organizations: z.array(entitySuggestionSchema) })), + schema: successWithDataSchema(userSchema), }, }, }, diff --git a/backend/src/modules/users/schema.ts b/backend/src/modules/users/schema.ts index cf86eff94..bbd220aed 100644 --- a/backend/src/modules/users/schema.ts +++ b/backend/src/modules/users/schema.ts @@ -8,7 +8,7 @@ import { imageUrlSchema, nameSchema, paginationQuerySchema, validSlugSchema } fr export const userSchema = createSelectSchema(usersTable, { email: z.string().email(), lastSeenAt: z.string().nullable(), - lastVisitAt: z.string().nullable(), + lastStartedAt: z.string().nullable(), lastSignInAt: z.string().nullable(), createdAt: z.string(), modifiedAt: z.string().nullable(), diff --git a/backend/src/utils/schema/schema.ts b/backend/src/utils/schema/schema.ts index 153190191..0c5b014d2 100644 --- a/backend/src/utils/schema/schema.ts +++ b/backend/src/utils/schema/schema.ts @@ -15,3 +15,17 @@ export const mapEntitiesSchema = (getSchemaForTable: (tabl ), ); }; + +// Map over all the entity tables and create a schema for each with their respective table name +export const mapMenuSectionsSchema = (getSchemaForTable: (tableName: string) => T) => { + return z.object( + Object.values(entityTables).reduce( + (acc, table) => { + const name = getTableConfig(table).name as EntityTableNames; + acc[name] = getSchemaForTable(name); // Use the passed function to define the schema + return acc; + }, + {} as Record, + ), + ); +}; diff --git a/cella.config.js b/cella.config.js index 6e1470ebe..efc985bc1 100644 --- a/cella.config.js +++ b/cella.config.js @@ -26,6 +26,7 @@ export const config = { 'config/default.ts', 'config/development.ts', 'config/tunnel.ts', + 'cli/create-cella/*', 'frontend/vite.config.ts', 'frontend/public/favicon.ico', 'frontend/public/static/icons/*', diff --git a/cli/create-cella/package.json b/cli/create-cella/package.json index 53c6409ed..e39f24516 100644 --- a/cli/create-cella/package.json +++ b/cli/create-cella/package.json @@ -1,23 +1,9 @@ { "name": "@cellajs/create-cella", - "version": "0.0.3", + "version": "0.0.4", "private": false, "license": "MIT", - "description": "Intuivive TypeScript template to build local-first web apps. Implementation-ready. MIT license.", - "keywords": [ - "template", - "monorepo", - "fullstack", - "typescript", - "hono", - "drizzle", - "shadcn", - "postgres", - "react", - "vite", - "pwa", - "cli" - ], + "description": "Create your own app in seconds with Cella: a TypeScript template for local-first web apps.", "publishConfig": { "access": "public" }, diff --git a/cli/sync-cella/package.json b/cli/sync-cella/package.json index f9b29090a..a6f3c46a4 100644 --- a/cli/sync-cella/package.json +++ b/cli/sync-cella/package.json @@ -3,24 +3,7 @@ "version": "0.0.1", "private": false, "license": "MIT", - "description": "Intuivive TypeScript template to build local-first web apps. Implementation-ready. MIT license.", - "keywords": [ - "template", - "monorepo", - "fullstack", - "typescript", - "hono", - "drizzle", - "shadcn", - "postgres", - "react", - "vite", - "pwa", - "cli", - "sync", - "fetch", - "upstream" - ], + "description": "Receive updates from cella while reducing conflicts.", "repository": { "type": "git", "url": "https://github.com/cellajs/cella", diff --git a/cli/sync-cella/src/diverged.js b/cli/sync-cella/src/diverged.js index d482db374..dc4d3f9d5 100644 --- a/cli/sync-cella/src/diverged.js +++ b/cli/sync-cella/src/diverged.js @@ -39,7 +39,7 @@ export async function diverged({ commonFiles = upstreamFileList.filter((file) => localFileList.includes(file)); - commonSpinner.success('Successfully found common files between upstream and local branch.'); + commonSpinner.success('Found common files between upstream and local branch.'); } catch (error) { console.error(error); commonSpinner.error('Failed to find common files between upstream and local branch.'); @@ -57,7 +57,7 @@ export async function diverged({ // Get the list of diverged files by comparing local branch and upstream branch divergedFiles = await runGitCommand({ targetFolder, command: `diff --name-only ${localBranch} upstream/${upstreamBranch}` }); - divergedSpinner.success('Successfully found diverged files between upstream and local branch.'); + divergedSpinner.success('Found diverged files between upstream and local branch.'); } catch (error) { console.error(error); divergedSpinner.error('Failed to find diverged files between upstream and local branch.'); @@ -71,7 +71,7 @@ export async function diverged({ const ignorePatterns = await extractIgnorePatterns({ ignoreList, ignoreFile }); if (ignorePatterns.length > 0) { - ignoreSpinner.success('Successfully created ignore patterns.'); + ignoreSpinner.success('Created ignore patterns.'); } else { ignoreSpinner.warning("No ignore list or ignore file found. Proceeding without ignoring files."); } @@ -88,7 +88,7 @@ export async function diverged({ if (ignorePatterns.length > 0) { filteredFiles = applyIgnorePatterns(filteredFiles, ignorePatterns); } - filterSpinnen.success('Successfully filtered diverged files.'); + filterSpinnen.success('Filtered diverged files.'); const writeSpinner = yoctoSpinner({ text: 'Writing diverged files to file', @@ -97,13 +97,21 @@ export async function diverged({ // Write the final list of diverged files to the specified file if (filteredFiles.length > 0) { await writeFile(divergedFile, filteredFiles.join("\n"), "utf-8"); - writeSpinner.success(`Diverged files successfully written to ${divergedFile}.`); + writeSpinner.success(`Diverged files written to ${divergedFile}.`); } else { writeSpinner.success("No files have diverged between the upstream and local branch that are not ignored."); // Optionally remove the Diverged file if empty await rm(divergedFile, { force: true }); } - console.log(`${colors.green('Success')} Successfully completed the diverged command.`); + console.log() + + // Log each diverged file line by line for clickable paths in VSCode + filteredFiles.forEach((file) => console.log(`./${file}`)); + + console.log() + console.log(`Found ${colors.blue(filteredFiles.length)} diverged files between the upstream and local branch.`); + console.log() + console.log(`${colors.green('✔')} Completed the diverged command.`); console.log() } diff --git a/compose.yaml b/compose.yaml index fa74d5946..fdc24c308 100644 --- a/compose.yaml +++ b/compose.yaml @@ -16,4 +16,13 @@ services: - 5432:5432 restart: always volumes: - - pg_data:/var/lib/postgresql/data \ No newline at end of file + - pg_data:/var/lib/postgresql/data + + electric: + image: electricsql/electric:latest + environment: + DATABASE_URL: postgresql://postgres:postgres@db:5432/postgres?sslmode=disable + ports: + - 3000:3000 + depends_on: + - db \ No newline at end of file diff --git a/config/README.md b/config/README.md index 2ea3e9756..0a5ec9039 100644 --- a/config/README.md +++ b/config/README.md @@ -1,7 +1,6 @@ # Config In this folder you find the config files to set up all kinds of app-specific configurations that can be made public. Each mode (`development`, `production` etc) gets a different config file. - ### TODO * Make `tunnel` mode operational * A workflow to automate building cellajs.com with the main branch, while having the template code ready for a clean installation. diff --git a/config/default.ts b/config/default.ts index 3c67299ef..0f15fc46f 100644 --- a/config/default.ts +++ b/config/default.ts @@ -8,6 +8,7 @@ export const config = { backendUrl: 'https://api.cellajs.com', backendAuthUrl: 'https://api.cellajs.com/auth', tusUrl: 'https://tus.cellajs.com', + electricUrl: 'https://electric.cellajs.com', defaultRedirectPath: '/home', firstSignInRedirectPath: '/welcome', @@ -115,6 +116,7 @@ export const config = { // Optional settings has: { pwa: true, // Progressive Web App support for preloading static assets and offline support + sync: false, // Realtime updates and sync using Electric Sync registrationEnabled: true, // Allow users to sign up. If disabled, the app is by invitation only waitList: false, // Suggest a waitlist for unknown emails when sign up is disabled }, @@ -164,6 +166,9 @@ export const config = { }, }, + // UI settings + navLogoAnimation: 'animate-spin-slow', + // Common countries common: { countries: ['fr', 'de', 'nl', 'ua', 'us', 'gb'], diff --git a/frontend/package.json b/frontend/package.json index 48a6b667d..5f3ebbb91 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,9 +17,10 @@ "dependencies": { "@atlaskit/pragmatic-drag-and-drop": "^1.3.1", "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.0.3", - "@blocknote/core": "^0.15.11", - "@blocknote/react": "^0.15.11", - "@blocknote/shadcn": "^0.15.11", + "@blocknote/core": "^0.16.0", + "@blocknote/react": "^0.16.0", + "@blocknote/shadcn": "^0.16.0", + "@floating-ui/react": "^0.26.25", "@github/mini-throttle": "^2.1.1", "@hookform/resolvers": "^3.9.0", "@oslojs/encoding": "^1.1.0", @@ -53,8 +54,8 @@ "@tanstack/react-query": "^5.59.0", "@tanstack/react-query-devtools": "^5.59.0", "@tanstack/react-query-persist-client": "^5.59.0", - "@tanstack/react-router": "^1.58.17", - "@tanstack/router-devtools": "^1.58.17", + "@tanstack/react-router": "^1.63.2", + "@tanstack/router-devtools": "^1.63.2", "@uppy/audio": "^2.0.1", "@uppy/core": "^4.2.1", "@uppy/dashboard": "^4.1.0", @@ -90,7 +91,6 @@ "jspdf-autotable": "^3.8.3", "locales": "workspace:*", "lucide-react": "^0.447.0", - "middleware@latest": "link:zustand/middleware@latest", "nanoid": "^5.0.7", "react": "^18.3.1", "react-confetti-explosion": "^2.1.2", @@ -112,7 +112,7 @@ "vaul": "^1.0.0", "workbox-window": "^7.1.0", "zod": "^3.23.8", - "zustand": "5.0.0-rc.2", + "zustand": "5.0.0", "zxcvbn": "^4.4.2" }, "devDependencies": { diff --git a/frontend/src/hooks/use-body-class.ts b/frontend/src/hooks/use-body-class.ts index 6df59f688..aceebad99 100644 --- a/frontend/src/hooks/use-body-class.ts +++ b/frontend/src/hooks/use-body-class.ts @@ -12,15 +12,9 @@ function useBodyClass(classMappings: { [key: string]: boolean }) { for (let i = 0; i < classNames.length; i++) { const className = classNames[i]; if (stableClassMappings[className]) { - if (!bodyClassList.contains(className)) { - console.log(`Adding class: ${className}`); - bodyClassList.add(className); - } + if (!bodyClassList.contains(className)) bodyClassList.add(className); } else { - if (bodyClassList.contains(className)) { - console.log(`Removing class: ${className}`); - bodyClassList.remove(className); - } + if (bodyClassList.contains(className)) bodyClassList.remove(className); } } @@ -28,10 +22,7 @@ function useBodyClass(classMappings: { [key: string]: boolean }) { return () => { for (let i = 0; i < classNames.length; i++) { const className = classNames[i]; - if (bodyClassList.contains(className)) { - console.log(`Cleaning up class: ${className}`); - bodyClassList.remove(className); - } + if (bodyClassList.contains(className)) bodyClassList.remove(className); } }; }, [stableClassMappings]); diff --git a/frontend/src/hooks/use-breakpoints.tsx b/frontend/src/hooks/use-breakpoints.tsx index 04b21ccb3..cc1a9502f 100644 --- a/frontend/src/hooks/use-breakpoints.tsx +++ b/frontend/src/hooks/use-breakpoints.tsx @@ -3,7 +3,11 @@ import { useEffect, useState } from 'react'; type ValidBreakpoints = keyof typeof config.theme.screenSizes; -export const useBreakpoints = (mustBe: 'min' | 'max', breakpoint: ValidBreakpoints): boolean => { +export const useBreakpoints = ( + mustBe: 'min' | 'max', + breakpoint: ValidBreakpoints, + enableReactivity = true, // Optional parameter to enable/disable reactivity +) => { const breakpoints: { [key: string]: string } = config.theme.screenSizes; // Sort breakpoints by their pixel value in ascending order @@ -39,6 +43,8 @@ export const useBreakpoints = (mustBe: 'min' | 'max', breakpoint: ValidBreakpoin // Update breakpoints on window resize useEffect(() => { + if (!enableReactivity) return; + const updateBreakpoints = () => { setCurrentBreakpoints(getMatchedBreakpoints()); }; @@ -51,7 +57,7 @@ export const useBreakpoints = (mustBe: 'min' | 'max', breakpoint: ValidBreakpoin // Cleanup on unmount return () => window.removeEventListener('resize', updateBreakpoints); - }, [breakpoints]); + }, [breakpoints, enableReactivity]); // Get the index of the current largest matched breakpoint and target breakpoint const currentBreakpointIndex = sortedBreakpoints.indexOf(currentBreakpoints[currentBreakpoints.length - 1]); diff --git a/frontend/src/hooks/use-draft-form.tsx b/frontend/src/hooks/use-draft-form.tsx index f02b73acf..fe3a54951 100644 --- a/frontend/src/hooks/use-draft-form.tsx +++ b/frontend/src/hooks/use-draft-form.tsx @@ -13,6 +13,7 @@ export function useFormWithDraft< props?: UseFormProps, ): UseFormReturn & { unsavedChanges: boolean; + loading: boolean; } { const form = useForm(props); const getForm = useDraftStore((state) => state.getForm); @@ -20,6 +21,7 @@ export function useFormWithDraft< const resetForm = useDraftStore((state) => state.resetForm); const [unsavedChanges, setUnsavedChanges] = useState(false); + const [loading, setLoading] = useState(true); // loading state useEffect(() => { const values = getForm(formId); @@ -30,6 +32,7 @@ export function useFormWithDraft< form.setValue(key as FieldPath, value); } } + setLoading(false); // Set loading to false once draft values have been applied }, [formId]); const allFields = form.watch(); @@ -37,9 +40,7 @@ export function useFormWithDraft< useEffect(() => { if (form.formState.isDirty) { const values = Object.fromEntries(Object.entries(allFields).filter(([_, value]) => value !== undefined)); - if (Object.keys(values).length > 0) { - return setForm(formId, values); - } + if (Object.keys(values).length > 0) return setForm(formId, values); } if (unsavedChanges) { @@ -51,6 +52,7 @@ export function useFormWithDraft< return { ...form, unsavedChanges, + loading, reset: (values, keepStateOptions) => { resetForm(formId); form.reset(values, keepStateOptions); diff --git a/frontend/src/index.css b/frontend/src/index.css index 2322b3a9a..714c4acf1 100755 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -7,7 +7,6 @@ @tailwind utilities; @layer base { - .bn-container.bn-shadcn, :root { --chart-1: 12 76% 61%; diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index e99e62419..b19e6bb03 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -7,8 +7,8 @@ import { ThemeManager } from '~/modules/common/theme-manager'; import { RouterProvider } from '@tanstack/react-router'; // Boot with i18n & dayjs -import '~/lib/i18n'; import '~/lib/dayjs'; +import '~/lib/i18n'; const root = document.getElementById('root'); if (!root) throw new Error('Root element not found'); @@ -16,7 +16,7 @@ if (!root) throw new Error('Root element not found'); import router from '~/lib/router'; import { initSentry } from '~/lib/sentry'; import { renderAscii } from '~/utils/ascii'; -import { QueryClientProvider } from './query-client-provider'; +import { QueryClientProvider } from './modules/common/query-client-provider'; // Render ASCII logo in console renderAscii(); diff --git a/frontend/src/modules/auth/check-email-form.tsx b/frontend/src/modules/auth/check-email-form.tsx index 3aa578d30..c16c3489f 100644 --- a/frontend/src/modules/auth/check-email-form.tsx +++ b/frontend/src/modules/auth/check-email-form.tsx @@ -38,7 +38,7 @@ export const CheckEmailForm = ({ tokenData, setStep }: CheckEmailProps) => { setStep('signIn', form.getValues('email'), hasPasskey); }, onError: (error) => { - const nextStep = config.has.registrationEnabled ? 'signUp' : config.has.waitList ? 'waitList' : 'inviteOnly'; + const nextStep = config.has.registrationEnabled || tokenData ? 'signUp' : config.has.waitList ? 'waitList' : 'inviteOnly'; if (error.status === 404) return setStep(nextStep, form.getValues('email'), false); }, }); diff --git a/frontend/src/modules/auth/index.tsx b/frontend/src/modules/auth/index.tsx index ad9eddcbf..d8467b405 100644 --- a/frontend/src/modules/auth/index.tsx +++ b/frontend/src/modules/auth/index.tsx @@ -46,8 +46,12 @@ const SignIn = () => { token, }); setEmail(data.email); + setError(null); }) .catch(setError); + } else { + setTokenData(null); + setError(null); } }, [token]); diff --git a/frontend/src/modules/auth/sign-in-form.tsx b/frontend/src/modules/auth/sign-in-form.tsx index 904426071..6ad691b9f 100644 --- a/frontend/src/modules/auth/sign-in-form.tsx +++ b/frontend/src/modules/auth/sign-in-form.tsx @@ -170,7 +170,7 @@ export const ResetPasswordRequest = ({ email }: { email: string }) => { id: 'send-reset-password', className: 'md:max-w-xl', title: t('common:reset_password'), - text: t('common:reset_password.text'), + description: t('common:reset_password.text'), }, ); }; diff --git a/frontend/src/modules/common/blocknote/custom-elements/notify/index.tsx b/frontend/src/modules/common/blocknote/custom-elements/notify/index.tsx index 765da4927..cf75ab56d 100644 --- a/frontend/src/modules/common/blocknote/custom-elements/notify/index.tsx +++ b/frontend/src/modules/common/blocknote/custom-elements/notify/index.tsx @@ -12,7 +12,7 @@ import { DropdownMenuTrigger, } from '~/modules/ui/dropdown-menu'; -import { notifyTypes } from '~/modules/common/blocknote/custom-elements/notify/notifyOptions'; +import { notifyTypes } from '~/modules/common/blocknote/custom-elements/notify/notify-options'; import type { CustomBlockNoteSchema } from '~/modules/common/blocknote/types'; // The Notify block. diff --git a/frontend/src/modules/common/blocknote/custom-elements/notify/notifyOptions.tsx b/frontend/src/modules/common/blocknote/custom-elements/notify/notify-options.tsx similarity index 100% rename from frontend/src/modules/common/blocknote/custom-elements/notify/notifyOptions.tsx rename to frontend/src/modules/common/blocknote/custom-elements/notify/notify-options.tsx diff --git a/frontend/src/modules/common/blocknote/custom-formatting-toolbar/custom-align-cahnge.tsx b/frontend/src/modules/common/blocknote/custom-formatting-toolbar/custom-align-change.tsx similarity index 100% rename from frontend/src/modules/common/blocknote/custom-formatting-toolbar/custom-align-cahnge.tsx rename to frontend/src/modules/common/blocknote/custom-formatting-toolbar/custom-align-change.tsx diff --git a/frontend/src/modules/common/blocknote/custom-formatting-toolbar/custom-text-stype-change.tsx b/frontend/src/modules/common/blocknote/custom-formatting-toolbar/custom-text-type-change.tsx similarity index 100% rename from frontend/src/modules/common/blocknote/custom-formatting-toolbar/custom-text-stype-change.tsx rename to frontend/src/modules/common/blocknote/custom-formatting-toolbar/custom-text-type-change.tsx diff --git a/frontend/src/modules/common/blocknote/custom-formatting-toolbar/index.tsx b/frontend/src/modules/common/blocknote/custom-formatting-toolbar/index.tsx index 600ac2760..84ccf89f4 100644 --- a/frontend/src/modules/common/blocknote/custom-formatting-toolbar/index.tsx +++ b/frontend/src/modules/common/blocknote/custom-formatting-toolbar/index.tsx @@ -8,9 +8,9 @@ import { NestBlockButton, UnnestBlockButton, } from '@blocknote/react'; -import { CustomTextAlignSelect } from '~/modules/common/blocknote/custom-formatting-toolbar/custom-align-cahnge'; +import { CustomTextAlignSelect } from '~/modules/common/blocknote/custom-formatting-toolbar/custom-align-change'; import { CustomBlockTypeSelect } from '~/modules/common/blocknote/custom-formatting-toolbar/custom-block-type-change'; -import { CustomTextStyleSelect } from '~/modules/common/blocknote/custom-formatting-toolbar/custom-text-stype-change'; +import { CustomTextStyleSelect } from '~/modules/common/blocknote/custom-formatting-toolbar/custom-text-type-change'; import type { CustomFormatToolBarConfig } from '~/modules/common/blocknote/types'; export const CustomFormattingToolbar = ({ config }: { config: CustomFormatToolBarConfig }) => ( diff --git a/frontend/src/modules/common/blocknote/helpers.ts b/frontend/src/modules/common/blocknote/helpers.ts index 4ab22eb0a..7f5070c1d 100644 --- a/frontend/src/modules/common/blocknote/helpers.ts +++ b/frontend/src/modules/common/blocknote/helpers.ts @@ -1,4 +1,5 @@ import type { Block } from '@blocknote/core'; +import type { CustomBlockNoteSchema } from './types'; export const getContentAsString = (blocks: Block[]) => { const blocksStringifyContent = blocks @@ -9,3 +10,36 @@ export const getContentAsString = (blocks: Block[]) => { .join(''); return blocksStringifyContent; }; + +export const focusEditor = (editor: CustomBlockNoteSchema) => { + const lastBlock = editor.document[editor.document.length - 1]; + editor.focus(); + editor.setTextCursorPosition(lastBlock.id, 'end'); +}; + +export const handleSubmitOnEnter = (editor: CustomBlockNoteSchema): CustomBlockNoteSchema['document'] | null => { + const blocks = editor.document; + // Get the last block and modify its content so we remove last \n + const lastBlock = blocks[blocks.length - 1]; + if (Array.isArray(lastBlock.content)) { + const lastBlockContent = lastBlock.content as { text: string }[]; + if (lastBlockContent.length > 0) lastBlockContent[0].text = lastBlockContent[0].text.replace(/\n$/, ''); // Remove the last newline character + const updatedLastBlock = { ...lastBlock, content: lastBlockContent }; + return [...blocks.slice(0, -1), updatedLastBlock] as CustomBlockNoteSchema['document']; + } + return null; +}; + +export const trimInlineContentText = (descriptionHtml: string) => { + const parser = new DOMParser(); + const doc = parser.parseFromString(descriptionHtml, 'text/html'); + + // Select all elements with the class 'bn-inline-content' + const inlineContents = doc.querySelectorAll('.bn-inline-content'); + + for (const element of inlineContents) { + // Trim the text and update the element's content + if (element.textContent) element.textContent = element.textContent.trim(); + } + return doc.body.innerHTML; +}; diff --git a/frontend/src/modules/common/blocknote/index.tsx b/frontend/src/modules/common/blocknote/index.tsx index ba98213b5..3f3c7015d 100644 --- a/frontend/src/modules/common/blocknote/index.tsx +++ b/frontend/src/modules/common/blocknote/index.tsx @@ -1,10 +1,20 @@ -import { GridSuggestionMenuController, useCreateBlockNote } from '@blocknote/react'; +import { FilePanelController, type FilePanelProps, GridSuggestionMenuController, useCreateBlockNote } from '@blocknote/react'; import { BlockNoteView } from '@blocknote/shadcn'; import '@blocknote/shadcn/style.css'; import DOMPurify from 'dompurify'; -import { useLayoutEffect, useRef } from 'react'; +import { type KeyboardEventHandler, useCallback, useEffect, useRef, useState } from 'react'; +import * as Badge from '~/modules/ui/badge'; +import * as Button from '~/modules/ui/button'; +import * as Card from '~/modules/ui/card'; +import * as DropdownMenu from '~/modules/ui/dropdown-menu'; +import * as Input from '~/modules/ui/input'; +import * as Label from '~/modules/ui/label'; +import * as Popover from '~/modules/ui/popover'; +import * as Select from '~/modules/ui/select'; +import * as Tabs from '~/modules/ui/tabs'; +import * as Toggle from '~/modules/ui/toggle'; +import * as Tooltip from '~/modules/ui/tooltip'; import { useThemeStore } from '~/store/theme'; -import { cn } from '~/utils/cn'; import { customFormattingToolBarConfig, @@ -19,117 +29,204 @@ import { CustomSlashMenu } from '~/modules/common/blocknote/custom-slash-menu'; import type { Member } from '~/types/common'; import type { Block } from '@blocknote/core'; -import { getContentAsString } from './helpers'; +import { FloatingPortal } from '@floating-ui/react'; +import router from '~/lib/router'; + +import { focusEditor, getContentAsString, handleSubmitOnEnter, trimInlineContentText } from '~/modules/common/blocknote/helpers'; import './styles.css'; type BlockNoteProps = { id: string; - triggerUpdateOnChange: boolean; defaultValue?: string; className?: string; sideMenu?: boolean; slashMenu?: boolean; formattingToolbar?: boolean; - customSideMenu?: boolean; - customSlashMenu?: boolean; - customFormattingToolbar?: boolean; + updateDataOnBeforeLoad?: boolean; + trailingBlock?: boolean; emojis?: boolean; members?: Member[]; - updateData: (value: string) => void; + updateData: (html: string) => void; + filePanel?: (props: FilePanelProps) => JSX.Element; + onChange?: (value: string) => void; onFocus?: () => void; - onBlur?: () => void; - onKeyDown?: () => void; + onEscapeClick?: () => void; + onEnterClick?: () => void; + onTextDifference?: () => void; }; export const BlockNote = ({ id, - triggerUpdateOnChange, defaultValue = '', className = '', sideMenu = true, slashMenu = true, formattingToolbar = true, - customSideMenu = false, - customSlashMenu = false, - customFormattingToolbar = false, emojis = true, + trailingBlock = true, + updateDataOnBeforeLoad = false, members, updateData, - onKeyDown, - onBlur, + filePanel, + onChange, + onEscapeClick, + onEnterClick, onFocus, + onTextDifference, }: BlockNoteProps) => { const { mode } = useThemeStore(); const wasInitial = useRef(false); + const editor = useCreateBlockNote({ schema: customSchema, trailingBlock }); - const editor = useCreateBlockNote({ schema: customSchema }); + const isCreationMode = !!onChange; + const [text, setText] = useState(defaultValue); - const emojiPicker = customSlashMenu ? [...customSlashIndexedItems, ...customSlashNotIndexedItems].includes('Emoji') : emojis; + const emojiPicker = slashMenu ? [...customSlashIndexedItems, ...customSlashNotIndexedItems].includes('Emoji') : emojis; - const onBlockNoteChange = async () => { - //if user in Formatting Toolbar does not update + const triggerDataUpdate = () => { + // if user in Formatting Toolbar does not update if (editor.getSelection()) return; + // if user in file panel does not update + if (editor.filePanel?.shown) return; + + updateData(text); + }; + + const onBlockNoteChange = useCallback(async () => { + if (!editor || !editor.document) return; + // Converts the editor's contents from Block objects to HTML and sanitizes it const descriptionHtml = await editor.blocksToFullHTML(editor.document); const cleanDescription = DOMPurify.sanitize(descriptionHtml); - updateData(cleanDescription); + + // Get the current and old block content as strings for comparison + const newHtml = getContentAsString(editor.document as Block[]); + const oldBlocks = await editor.tryParseHTMLToBlocks(text); + const oldHtml = getContentAsString(oldBlocks as Block[]); + + // Check if there is any difference in the content + if (oldHtml !== newHtml) onTextDifference?.(); + + // Prepare the content for further updates (trims and sanitizes) + const contentToUpdate = trimInlineContentText(cleanDescription); + // Update the state or trigger the onChange callback in creation mode + if (isCreationMode) onChange?.(contentToUpdate); + setText(contentToUpdate); + }, [editor, text, isCreationMode, onChange, onTextDifference]); + + const handleKeyDown: KeyboardEventHandler = async (event) => { + if (event.key === 'Escape') { + event.preventDefault(); + onEscapeClick?.(); + } + if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') { + event.preventDefault(); + + // to ensure that blocknote have description + if ( + editor.document?.some((block) => { + const content = block.content; + return Array.isArray(content) && (content as { text: string }[])[0]?.text.trim() !== ''; + }) + ) { + const blocksToUpdate = handleSubmitOnEnter(editor); + if (blocksToUpdate) editor.replaceBlocks(editor.document, blocksToUpdate); + triggerDataUpdate(); + onEnterClick?.(); + } + } }; - useLayoutEffect(() => { + useEffect(() => { const blockUpdate = async (html: string) => { + if (wasInitial.current && !isCreationMode) return; + const blocks = await editor.tryParseHTMLToBlocks(html); + + // Get the current blocks and the new blocks' content as strings to compare them const currentBlocks = getContentAsString(editor.document as Block[]); const newBlocksContent = getContentAsString(blocks as Block[]); // Only replace blocks if the content actually changes - if (currentBlocks !== newBlocksContent || html === '') { - editor.replaceBlocks(editor.document, blocks); - const lastBlock = editor.document[editor.document.length - 1]; - editor.focus(); - editor.setTextCursorPosition(lastBlock.id, 'end'); - if (!wasInitial.current) wasInitial.current = true; - } + if (!isCreationMode && currentBlocks === newBlocksContent) return; + + editor.replaceBlocks(editor.document, blocks); + // Handle focus: + // 1. In creation mode, focus the editor only if it hasn't been initialized before. + // 2. Outside creation mode, focus the editor every time. + if (isCreationMode) { + if (!wasInitial.current) focusEditor(editor); // Focus only on the first initialization in creation mode + } else focusEditor(editor); // Always focus when not in creation mode + + // Mark the editor as having been initialized + wasInitial.current = true; }; + blockUpdate(defaultValue); }, [defaultValue]); + useEffect(() => { + if (!updateDataOnBeforeLoad) return; + const unsubscribe = router.subscribe('onBeforeLoad', triggerDataUpdate); + return () => unsubscribe(); + }, []); + return ( { - // to avoid update if content empty, so from draft shown - if (!triggerUpdateOnChange || editor.document[0].content?.toString() === '') return; - queueMicrotask(() => onBlockNoteChange()); - }} - onFocus={() => queueMicrotask(() => onFocus?.())} - onBlur={() => queueMicrotask(() => onBlur?.())} - onKeyDown={onKeyDown} - sideMenu={customSideMenu ? false : sideMenu} - slashMenu={customSlashMenu ? false : slashMenu} - formattingToolbar={customFormattingToolbar ? false : formattingToolbar} + shadCNComponents={{ Button, DropdownMenu, Popover, Tooltip, Select, Label, Input, Card, Badge, Toggle, Tabs }} + onChange={onBlockNoteChange} + onFocus={onFocus} + onBlur={triggerDataUpdate} + onKeyDown={handleKeyDown} + sideMenu={!sideMenu} + slashMenu={!slashMenu} + formattingToolbar={!formattingToolbar} emojiPicker={!emojiPicker} - className={cn('p-3 border rounded-md', className)} + filePanel={!filePanel} + className={className} > - {customSlashMenu && } - {customFormattingToolbar && ( -
- -
+ {slashMenu && ( + +
+ +
+
+ )} + + {formattingToolbar && ( + +
+ +
+
)} - {customSideMenu && } - + + {sideMenu && } + + +
+ +
+
+ {emojiPicker && ( - + +
+ +
+
)} + + {filePanel && }
); }; diff --git a/frontend/src/modules/common/blocknote/styles.css b/frontend/src/modules/common/blocknote/styles.css index 85c5ba94a..2db3fead3 100644 --- a/frontend/src/modules/common/blocknote/styles.css +++ b/frontend/src/modules/common/blocknote/styles.css @@ -1,6 +1,6 @@ p.bn-inline-content { font-size: 0.888rem; - font-weight: 300; + font-weight: 350; line-height: 1.3; min-height: 1.3rem; padding: 0; @@ -14,7 +14,7 @@ p.bn-inline-content code { } .bn-grid-suggestion-menu { - background-color: hsl(var(--card)); + background-color: hsl(var(--popover)); border: 1px solid hsl(var(--border)); z-index: 9999 !important; max-height: 40vh !important; @@ -41,6 +41,12 @@ p.bn-inline-content code { top: -0.15rem; } +@media (max-width: 639px) { + .bn-shadcn .bn-side-menu { + display: none !important; + } +} + .prosemirror-dropcursor-block { border-radius: 2rem; } @@ -54,7 +60,7 @@ p.bn-inline-content code { } .bn-menu-dropdown { - background-color: hsl(var(--card)); + background-color: hsl(var(--popover)); border: 1px solid hsl(var(--border)); position: fixed; z-index: 9999 !important; @@ -64,7 +70,7 @@ p.bn-inline-content code { } .bn-toolbar.bn-formatting-toolbar { - background-color: hsl(var(--card)); + background-color: hsl(var(--popover)); border: 1px solid hsl(var(--border)); } @@ -83,7 +89,7 @@ p.bn-inline-content code { max-height: 40vh; overflow-y: auto; z-index: 9999 !important; - background-color: hsl(var(--card)); + background-color: hsl(var(--popover)); border: 1px solid hsl(var(--border)); border-radius: 8px; box-shadow: 0 1px 4px 0 rgb(0 0 0 / 0.1); diff --git a/frontend/src/modules/common/data-table/util.tsx b/frontend/src/modules/common/data-table/util.tsx index b93dd6f79..149c64c7d 100644 --- a/frontend/src/modules/common/data-table/util.tsx +++ b/frontend/src/modules/common/data-table/util.tsx @@ -1,10 +1,22 @@ +import type { NavigateFn } from '@tanstack/react-router'; import { Suspense, lazy } from 'react'; import { sheet } from '~/modules/common/sheeter/state'; import type { User } from '~/types/common'; const UserProfilePage = lazy(() => import('~/modules/users/profile-page')); -export const openUserPreviewSheet = (user: Omit) => { +export const openUserPreviewSheet = (user: Omit, navigate: NavigateFn, addSearch = false) => { + if (addSearch) { + navigate({ + to: '.', + replace: true, + resetScroll: false, + search: (prev) => ({ + ...prev, + ...{ userIdPreview: user.id }, + }), + }); + } sheet.create( @@ -12,6 +24,19 @@ export const openUserPreviewSheet = (user: Omit) => { { className: 'max-w-full lg:max-w-4xl p-0', id: `user-preview-${user.id}`, + side: 'right', + removeCallback: () => { + navigate({ + to: '.', + replace: true, + resetScroll: false, + search: (prev) => { + const { userIdPreview: _, ...nextSearch } = prev; + return nextSearch; + }, + }); + sheet.remove(`user-preview-${user.id}`); + }, }, ); }; diff --git a/frontend/src/modules/common/dialoger/dialog.tsx b/frontend/src/modules/common/dialoger/dialog.tsx new file mode 100644 index 000000000..a40a11506 --- /dev/null +++ b/frontend/src/modules/common/dialoger/dialog.tsx @@ -0,0 +1,75 @@ +import { type DialogT, dialog as dialogState } from '~/modules/common/dialoger/state'; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '~/modules/ui/dialog'; +import { cn } from '~/utils/cn'; + +type CustomInteractOutsideEvent = CustomEvent<{ originalEvent: PointerEvent | FocusEvent }>; + +export interface DialogProp { + dialog: DialogT; + removeDialog: (dialog: DialogT) => void; +} +export default function StandardDialog({ dialog, removeDialog }: DialogProp) { + const { + id, + content, + preventEscPress, + container, + open, + description, + title, + className, + containerBackdrop, + containerBackdropClassName, + autoFocus, + hideClose, + } = dialog; + + const closeDialog = () => { + removeDialog(dialog); + dialog.removeCallback?.(); + }; + const onOpenChange = (open: boolean) => { + dialogState.update(dialog.id, { open }); + if (!open) closeDialog(); + }; + + const handleInteractOutside = (event: CustomInteractOutsideEvent) => { + if (container && !containerBackdrop) event.preventDefault(); + }; + + const handleEscapeKeyDown = (event: KeyboardEvent) => { + if (preventEscPress) event.preventDefault(); + }; + + return ( + + {container && containerBackdrop && ( +
+ )} + { + if (!autoFocus) event.preventDefault(); + }} + className={className} + container={container} + > + + {title} + {description} + + + {/* For accessibility */} + {!description && !title && } + {content} + +
+ ); +} diff --git a/frontend/src/modules/common/dialoger/drawer.tsx b/frontend/src/modules/common/dialoger/drawer.tsx new file mode 100644 index 000000000..e2cef1bbb --- /dev/null +++ b/frontend/src/modules/common/dialoger/drawer.tsx @@ -0,0 +1,27 @@ +import type { DialogProp } from '~/modules/common/dialoger/dialog'; +import { dialog as dialogState } from '~/modules/common/dialoger/state'; +import { Drawer, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle } from '~/modules/ui/drawer'; + +export default function DrawerDialog({ dialog, removeDialog }: DialogProp) { + const { id, content, open, description, title, className } = dialog; + + const onOpenChange = (open: boolean) => { + dialogState.update(dialog.id, { open }); + if (!open) { + removeDialog(dialog); + dialog.removeCallback?.(); + } + }; + + return ( + + + + {title} + {description} + +
{content}
+
+
+ ); +} diff --git a/frontend/src/modules/common/dialoger/index.tsx b/frontend/src/modules/common/dialoger/index.tsx index fb5e9a0eb..9eb471a4f 100644 --- a/frontend/src/modules/common/dialoger/index.tsx +++ b/frontend/src/modules/common/dialoger/index.tsx @@ -1,8 +1,8 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useBreakpoints } from '~/hooks/use-breakpoints'; +import StandardDialog from '~/modules/common/dialoger/dialog'; +import DrawerDialog from '~/modules/common/dialoger/drawer'; import { DialogState, type DialogT, type DialogToRemove } from '~/modules/common/dialoger/state'; -import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '~/modules/ui/dialog'; -import { Drawer, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle } from '~/modules/ui/drawer'; export function Dialoger() { const [dialogs, setDialogs] = useState([]); @@ -10,16 +10,7 @@ export function Dialoger() { const isMobile = useBreakpoints('max', 'sm'); const prevFocusedElement = useRef(null); - const updateDialog = (dialog: DialogT, open: boolean) => { - DialogState.update(dialog.id, { open }); - }; - const onOpenChange = (dialog: DialogT) => (open: boolean) => { - updateDialog(dialog, open); - if (!open) removeDialog(dialog); - }; - const removeDialog = useCallback((dialog: DialogT | DialogToRemove) => { - DialogState.update(dialog.id, { open: false }); setDialogs((dialogs) => dialogs.filter(({ id }) => id !== dialog.id)); if (dialog.refocus && prevFocusedElement.current) { // Timeout is needed to prevent focus from being stolen by the dialog that was just removed @@ -32,26 +23,13 @@ export function Dialoger() { useEffect(() => { return DialogState.subscribe((dialog) => { - if ('remove' in dialog) { - removeDialog(dialog); - return; - } - if ('reset' in dialog) { - setUpdatedDialogs((updatedDialogs) => updatedDialogs.filter(({ id }) => id !== dialog.id)); - return; - } - prevFocusedElement.current = (document.activeElement || document.body) as HTMLElement; - setUpdatedDialogs((updatedDialogs) => { - const existingDialog = updatedDialogs.find(({ id }) => id === dialog.id); - if (existingDialog) return updatedDialogs.map((d) => (d.id === dialog.id ? dialog : d)); + if ('remove' in dialog) return removeDialog(dialog); + + if ('reset' in dialog) return setUpdatedDialogs((updatedDialogs) => updatedDialogs.filter(({ id }) => id !== dialog.id)); - return [...updatedDialogs, dialog]; - }); - setDialogs((dialogs) => { - const existingDialog = dialogs.find(({ id }) => id === dialog.id); - if (existingDialog) return dialogs; - return [...dialogs, dialog]; - }); + prevFocusedElement.current = (document.activeElement || document.body) as HTMLElement; + setUpdatedDialogs((updatedDialogs) => [...updatedDialogs.filter((d) => d.id !== dialog.id), dialog]); + setDialogs((dialogs) => [...dialogs.filter((d) => d.id !== dialog.id), dialog]); }); }, []); @@ -59,56 +37,7 @@ export function Dialoger() { return dialogs.map((dialog) => { const existingDialog = updatedDialogs.find(({ id }) => id === dialog.id); - - if (!isMobile || !dialog.drawerOnMobile) { - return ( - - {dialog.container && dialog.containerBackdrop && ( -
- )} - { - if (dialog.container && !dialog.containerBackdrop) e.preventDefault(); - }} - hideClose={dialog.hideClose} - onOpenAutoFocus={(event: Event) => { - if (!dialog.autoFocus) event.preventDefault(); - }} - className={existingDialog?.className ? existingDialog.className : dialog.className} - container={existingDialog?.container ? existingDialog.container : dialog.container} - > - - - {existingDialog?.title - ? existingDialog.title - : dialog.title && (typeof dialog.title === 'string' ? {dialog.title} : dialog.title)} - - {dialog.text} - - - {/* For accessibility */} - {!dialog.text && !dialog.title && } - {existingDialog?.content ? existingDialog.content : dialog.content} - -
- ); - } - - return ( - - - - - {existingDialog?.title - ? existingDialog.title - : dialog.title && (typeof dialog.title === 'string' ? {dialog.title} : dialog.title)} - - {dialog.text} - - -
{dialog.content}
-
-
- ); + const DialogComponent = !isMobile || !dialog.drawerOnMobile ? StandardDialog : DrawerDialog; + return ; }); } diff --git a/frontend/src/modules/common/dialoger/state.ts b/frontend/src/modules/common/dialoger/state.ts index 7da385585..5237823e8 100644 --- a/frontend/src/modules/common/dialoger/state.ts +++ b/frontend/src/modules/common/dialoger/state.ts @@ -5,19 +5,22 @@ let dialogsCounter = 1; export type DialogT = { id: number | string; title?: string | React.ReactNode; - text?: React.ReactNode; + description?: React.ReactNode; drawerOnMobile?: boolean; container?: HTMLElement | null; className?: string; refocus?: boolean; containerBackdrop?: boolean; + containerBackdropClassName?: string; autoFocus?: boolean; hideClose?: boolean; + preventEscPress?: boolean; content?: React.ReactNode; titleContent?: string | React.ReactNode; addToTitle?: boolean; useDefaultTitle?: boolean; open?: boolean; + removeCallback?: () => void; }; export type DialogToRemove = { @@ -60,9 +63,7 @@ class Observer { }; publish = (data: DialogT | DialogToRemove | DialogToReset) => { - for (const subscriber of this.subscribers) { - subscriber(data); - } + for (const subscriber of this.subscribers) subscriber(data); }; set = (data: DialogT) => { @@ -70,22 +71,15 @@ class Observer { const existingDialogIndex = this.dialogs.findIndex((dialog) => dialog.id === data.id); // If it exists, replace it, otherwise add it - if (existingDialogIndex > -1) { - this.dialogs[existingDialogIndex] = data; - } else { - this.dialogs = [...this.dialogs, data]; - } + if (existingDialogIndex > -1) this.dialogs[existingDialogIndex] = data; + else this.dialogs = [...this.dialogs, data]; this.publish(data); }; - get = (id: number | string) => { - return this.dialogs.find((dialog) => dialog.id === id); - }; + get = (id: number | string) => this.dialogs.find((dialog) => dialog.id === id); - haveOpenDialogs = () => { - return this.dialogs.some((d) => isDialog(d) && d.open); - }; + haveOpenDialogs = () => this.dialogs.some((d) => isDialog(d) && d.open); remove = (refocus = true, id?: number | string) => { if (id) { @@ -93,7 +87,6 @@ class Observer { this.dialogs = this.dialogs.filter((dialog) => dialog.id !== id); return; } - // Remove all dialogs for (const dialog of this.dialogs) this.publish({ id: dialog.id, remove: true, refocus }); this.dialogs = []; @@ -126,7 +119,6 @@ const dialogFunction = (content: React.ReactNode, data?: ExternalDialog) => { DialogState.set({ content, drawerOnMobile: true, - containerBackdrop: true, refocus: true, autoFocus: true, hideClose: false, diff --git a/frontend/src/modules/common/error-notice.tsx b/frontend/src/modules/common/error-notice.tsx index 0dac9b30a..74f570a99 100644 --- a/frontend/src/modules/common/error-notice.tsx +++ b/frontend/src/modules/common/error-notice.tsx @@ -40,7 +40,7 @@ const ErrorNotice: React.FC = ({ error, resetErrorBoundary, is drawerOnMobile: false, className: 'sm:max-w-5xl', title: t('common:contact_us'), - text: t('common:contact_us.text'), + description: t('common:contact_us.text'), }); } window.Gleap.openConversations(); diff --git a/frontend/src/modules/common/focus-view/index.tsx b/frontend/src/modules/common/focus-view/index.tsx index ba9b23d11..100a86754 100644 --- a/frontend/src/modules/common/focus-view/index.tsx +++ b/frontend/src/modules/common/focus-view/index.tsx @@ -1,6 +1,5 @@ import { Expand, Shrink } from 'lucide-react'; import type React from 'react'; -import { useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { showToast } from '~/lib/toasts'; import { TooltipButton } from '~/modules/common/tooltip-button'; @@ -8,8 +7,9 @@ import { Button } from '~/modules/ui/button'; import { useNavigationStore } from '~/store/navigation'; import { cn } from '~/utils/cn'; -import './style.css'; import useBodyClass from '~/hooks/use-body-class'; +import { sheet } from '~/modules/common/sheeter/state'; +import './style.css'; interface FocusViewProps { className?: string; @@ -23,6 +23,7 @@ export const FocusView = ({ className = '', iconOnly }: FocusViewProps) => { const toggleFocus = () => { showToast(focusView ? t('common:left_focus.text') : t('common:entered_focus.text'), 'success'); setFocusView(!focusView); + sheet.remove('nav-sheet'); setNavSheetOpen(null); window.scrollTo(0, 0); }; @@ -38,16 +39,9 @@ export const FocusView = ({ className = '', iconOnly }: FocusViewProps) => { }; export const FocusViewContainer = ({ children, className = '' }: { children: React.ReactNode; className?: string }) => { - const { focusView, setFocusView } = useNavigationStore(); + const { focusView } = useNavigationStore(); useBodyClass({ 'focus-view': focusView }); - // Reset focus view on unmount - useEffect(() => { - return () => { - setFocusView(false); - }; - }, []); - return
{children}
; }; diff --git a/frontend/src/modules/common/gleap/index.tsx b/frontend/src/modules/common/gleap/index.tsx index e6a24f203..553dc262f 100644 --- a/frontend/src/modules/common/gleap/index.tsx +++ b/frontend/src/modules/common/gleap/index.tsx @@ -1,12 +1,13 @@ import { config } from 'config'; import Gleap from 'gleap'; +import { useEffect } from 'react'; import '~/modules/common/gleap/style.css'; import { useUserStore } from '~/store/user'; import type { User } from '~/types/common'; declare global { interface Window { - Gleap: typeof Gleap; + Gleap: typeof Gleap | undefined; } } @@ -20,28 +21,40 @@ const setGleapUser = (user: User) => { if (window.Gleap.isUserIdentified()) { window.Gleap.updateContact({ email: user.email, name: user.name || user.email }); } else { - window.Gleap.identify(user.id, { email: user.email, name: user.name || user.email, createdAt: new Date(user.createdAt) }); + window.Gleap.identify(user.id, { + email: user.email, + name: user.name || user.email, + createdAt: new Date(user.createdAt), + }); } }; const GleapSupport = () => { - window.Gleap = Gleap; const { user } = useUserStore(); - // Set Gleap user on mount - if (user && window.Gleap && !window.Gleap.isUserIdentified()) setGleapUser(user); + useEffect(() => { + window.Gleap = Gleap; - // Update Gleap user on user change - useUserStore.subscribe((state) => { - const user: User = state.user; + // Set Gleap user on mount + if (user && window.Gleap && !window.Gleap.isUserIdentified()) setGleapUser(user); - if (user) return setGleapUser(user); + // Update Gleap user on user change + useUserStore.subscribe((state) => { + const user: User = state.user; - // Clear Gleap user on sign out - window.Gleap.clearIdentity(); - }); + if (user) return setGleapUser(user); - return <>; + // Clear Gleap user on sign out + window.Gleap?.clearIdentity(); + }); + + return () => { + window.Gleap?.destroy(); + window.Gleap = undefined; + }; + }, []); + + return null; }; export default GleapSupport; diff --git a/frontend/src/modules/common/panwiever/panwiever-setup.tsx b/frontend/src/modules/common/image-viewer/image-viewer-setup.tsx similarity index 100% rename from frontend/src/modules/common/panwiever/panwiever-setup.tsx rename to frontend/src/modules/common/image-viewer/image-viewer-setup.tsx diff --git a/frontend/src/modules/common/panwiever/index.tsx b/frontend/src/modules/common/image-viewer/index.tsx similarity index 98% rename from frontend/src/modules/common/panwiever/index.tsx rename to frontend/src/modules/common/image-viewer/index.tsx index 1f19d7e74..e959b64ab 100644 --- a/frontend/src/modules/common/panwiever/index.tsx +++ b/frontend/src/modules/common/image-viewer/index.tsx @@ -3,7 +3,7 @@ import { CornerDownLeft, FlipHorizontal2, Minus, Plus, RefreshCw } from 'lucide-react'; import * as React from 'react'; import { Button } from '~/modules/ui/button'; -import PanViewer from './panwiever-setup'; +import PanViewer from './image-viewer-setup'; type ReactPanZoomProps = { image: string; diff --git a/frontend/src/modules/common/main-app.tsx b/frontend/src/modules/common/main-app.tsx index e09984819..b4ceaaf67 100644 --- a/frontend/src/modules/common/main-app.tsx +++ b/frontend/src/modules/common/main-app.tsx @@ -5,6 +5,7 @@ import { Dialoger } from '~/modules/common/dialoger'; import { DropDowner } from '~/modules/common/dropdowner'; import ErrorNotice from '~/modules/common/error-notice'; import MainNav from '~/modules/common/main-nav'; +import { SetBody } from '~/modules/common/set-body'; import { Sheeter } from '~/modules/common/sheeter'; import SSE from '~/modules/common/sse'; import { SSEProvider } from '~/modules/common/sse/provider'; @@ -17,6 +18,7 @@ const App = () => { > + diff --git a/frontend/src/modules/common/main-content.tsx b/frontend/src/modules/common/main-content.tsx index c0d0f8e17..85419abb0 100644 --- a/frontend/src/modules/common/main-content.tsx +++ b/frontend/src/modules/common/main-content.tsx @@ -5,7 +5,7 @@ import AlertRenderer from '~/modules/common/main-alert/alert-render'; export const MainContent = () => { return (
-
+
diff --git a/frontend/src/modules/common/main-footer.tsx b/frontend/src/modules/common/main-footer.tsx index 036a191f5..1982183c2 100644 --- a/frontend/src/modules/common/main-footer.tsx +++ b/frontend/src/modules/common/main-footer.tsx @@ -43,7 +43,7 @@ export const FooterLinks = ({ links = defaultFooterLinks, className = '' }: Foot drawerOnMobile: false, className: 'sm:max-w-5xl', title: t('common:contact_us'), - text: t('common:contact_us.text'), + description: t('common:contact_us.text'), }); }; diff --git a/frontend/src/modules/common/main-nav/main-nav-button.tsx b/frontend/src/modules/common/main-nav/bar-nav/bar-nav-button.tsx similarity index 84% rename from frontend/src/modules/common/main-nav/main-nav-button.tsx rename to frontend/src/modules/common/main-nav/bar-nav/bar-nav-button.tsx index 2755738de..f3a33abe4 100644 --- a/frontend/src/modules/common/main-nav/main-nav-button.tsx +++ b/frontend/src/modules/common/main-nav/bar-nav/bar-nav-button.tsx @@ -7,7 +7,7 @@ import { useUserStore } from '~/store/user'; import { useTranslation } from 'react-i18next'; import type { NavItem } from '~/modules/common/main-nav'; -import MainNavLoader from '~/modules/common/main-nav/main-nav-loader'; +import MainNavLoader from '~/modules/common/main-nav/bar-nav/bar-nav-loader'; import { TooltipButton } from '~/modules/common/tooltip-button'; import { cn } from '~/utils/cn'; @@ -23,14 +23,14 @@ export const NavButton = ({ navItem, isActive, onClick }: NavButtonProps) => { const { theme } = useThemeStore(); const navIconColor = theme !== 'none' ? 'text-primary-foreground' : ''; - const activeClass = isActive ? 'bg-accent/20 hover:bg-accent/20' : ''; + const activeClass = isActive ? 'bg-background/50 hover:bg-background/75' : ''; return ( + ); +}; + +export default MobileNavButton; diff --git a/frontend/src/modules/common/main-nav/float-nav/index.tsx b/frontend/src/modules/common/main-nav/float-nav/index.tsx new file mode 100644 index 000000000..80c11abaf --- /dev/null +++ b/frontend/src/modules/common/main-nav/float-nav/index.tsx @@ -0,0 +1,44 @@ +import { useEffect, useState } from 'react'; +import type { NavItem } from '~/modules/common/main-nav'; +import MobileNavButton from './button-container'; + +const FloatNav = ({ items, onClick }: { items: NavItem[]; onClick: (index: number) => void }) => { + const [showButtons, setShowButtons] = useState(true); + const [lastScrollY, setLastScrollY] = useState(0); + + useEffect(() => { + const handleScroll = () => { + const currentScrollY = window.scrollY; + // User is scrolling down, hide buttons. Up, show buttons + if (currentScrollY > lastScrollY) setShowButtons(false); + else setShowButtons(true); + + // Update last scroll position + setLastScrollY(currentScrollY); + }; + + window.addEventListener('scroll', handleScroll); + return () => window.removeEventListener('scroll', handleScroll); + }, [lastScrollY]); + + useEffect(() => { + const mainAppRoot = document.getElementById('main-app-root'); + + if (mainAppRoot) mainAppRoot.style.height = 'auto'; + return () => { + if (mainAppRoot) mainAppRoot.style.height = ''; + }; + }, []); + + return ( + + ); +}; + +export default FloatNav; diff --git a/frontend/src/modules/common/main-nav/index.tsx b/frontend/src/modules/common/main-nav/index.tsx index b4d367183..6e3ad237e 100644 --- a/frontend/src/modules/common/main-nav/index.tsx +++ b/frontend/src/modules/common/main-nav/index.tsx @@ -1,26 +1,15 @@ import { useNavigate } from '@tanstack/react-router'; -import { config } from 'config'; -import { type LucideProps, UserX } from 'lucide-react'; -import { Fragment, Suspense, lazy, useEffect, useMemo } from 'react'; -import { useThemeStore } from '~/store/theme'; - +import type { LucideProps } from 'lucide-react'; +import { useEffect, useMemo } from 'react'; import { useBreakpoints } from '~/hooks/use-breakpoints'; -import { dialog } from '~/modules/common/dialoger/state'; -import { useNavigationStore } from '~/store/navigation'; -import { cn } from '~/utils/cn'; - -import { useTranslation } from 'react-i18next'; -import { toast } from 'sonner'; -import { impersonationStop } from '~/api/auth'; -import useBodyClass from '~/hooks/use-body-class'; import { useHotkeys } from '~/hooks/use-hot-keys'; -import useMounted from '~/hooks/use-mounted'; import router from '~/lib/router'; -import { NavButton } from '~/modules/common/main-nav/main-nav-button'; +import { dialog } from '~/modules/common/dialoger/state'; +import BarNav from '~/modules/common/main-nav/bar-nav'; +import FloatNav from '~/modules/common/main-nav/float-nav'; import { sheet } from '~/modules/common/sheeter/state'; -import { getAndSetMe, getAndSetMenu } from '~/modules/users/helpers'; import { type NavItemId, baseNavItems, navItems } from '~/nav-config'; -import { useUserStore } from '~/store/user'; +import { useNavigationStore } from '~/store/navigation'; export type NavItem = { id: NavItemId; @@ -31,19 +20,11 @@ export type NavItem = { mirrorOnMobile?: boolean; }; -const DebugToolbars = config.mode === 'development' ? lazy(() => import('~/modules/common/debug-toolbars')) : () => null; - -const AppNav = () => { +const MainNav = () => { const navigate = useNavigate(); - const { t } = useTranslation(); - const { hasStarted } = useMounted(); const isMobile = useBreakpoints('max', 'sm'); - const { setLoading, setFocusView, focusView, keepMenuOpen, navSheetOpen, setNavSheetOpen } = useNavigationStore(); - const { theme } = useThemeStore(); - const { user } = useUserStore(); - - const currentSession = useMemo(() => user?.sessions.find((s) => s.isCurrent), [user]); + const { setLoading, setFocusView, navSheetOpen, setNavSheetOpen } = useNavigationStore(); const showedNavButtons = useMemo(() => { const desktop = router.state.matches.flatMap((el) => el.staticData.showedDesktopNavButtons || []); @@ -56,22 +37,13 @@ const AppNav = () => { return navItems.filter(({ id }) => itemsIds.includes(id)); }, [showedNavButtons]); - // Keep menu open - useBodyClass({ 'keep-nav-open': keepMenuOpen, 'nav-open': !!navSheetOpen }); - - const stopImpersonation = async () => { - await impersonationStop(); - await Promise.all([getAndSetMe(), getAndSetMenu()]); - navigate({ to: config.defaultRedirectPath, replace: true }); - toast.success(t('common:success.stopped_impersonation')); - }; - - const navBackground = theme !== 'none' ? 'bg-primary' : 'bg-primary-foreground'; + const showFloatNav = renderedItems.length > 0 && renderedItems.length <= 2; const navButtonClick = (navItem: NavItem) => { // If its a have dialog, open it if (navItem.dialog) { return dialog(navItem.dialog, { + id: 'workspace-add-task', className: navItem.id === 'search' ? 'sm:max-w-2xl p-0 border-0 mb-4' : '', drawerOnMobile: false, refocus: false, @@ -81,8 +53,13 @@ const AppNav = () => { } // If its a route, navigate to it - if (navItem.href) return navigate({ to: navItem.href }); - + if (navItem.href) { + if (!useNavigationStore.getState().keepMenuOpen) { + setNavSheetOpen(null); + sheet.update('nav-sheet', { open: false }); + } + return navigate({ to: navItem.href }); + } // Set nav sheet const sheetSide = isMobile ? (navItem.mirrorOnMobile ? 'right' : 'left') : 'left'; @@ -90,43 +67,47 @@ const AppNav = () => { // Create a sheet sheet.create(navItem.sheet, { - id: `${navItem.id}-nav`, + id: 'nav-sheet', side: sheetSide, + hideClose: true, modal: isMobile, - className: 'fixed sm:z-[80] p-0 sm:inset-0 xs:max-w-80 sm:left-16', + className: `fixed sm:z-[105] p-0 sm:inset-0 xs:max-w-80 sm:left-16 ${navItem.id === 'menu' && 'group-[.keep-menu-open]/body:xl:shadow-none'}`, removeCallback: () => { setNavSheetOpen(null); }, }); }; - const clickNavItem = (id: NavItemId, index: number) => { + const clickNavItem = (index: number) => { // If the nav item is already open, close it - if (id === navSheetOpen) { - sheet.remove(); - return setNavSheetOpen(null); + const id = renderedItems[index].id; + if (id === navSheetOpen && sheet.get('nav-sheet')?.open) { + setNavSheetOpen(null); + sheet.update('nav-sheet', { open: false }); + return; } - if (dialog.haveOpenDialogs()) return; - navButtonClick(renderedItems[index]); }; useHotkeys([ - ['Shift + A', () => clickNavItem('account', 3)], - ['Shift + F', () => clickNavItem('search', 2)], - ['Shift + H', () => clickNavItem('home', 1)], - ['Shift + M', () => clickNavItem('menu', 0)], + ['Shift + A', () => clickNavItem(3)], + ['Shift + F', () => clickNavItem(2)], + ['Shift + H', () => clickNavItem(1)], + ['Shift + M', () => clickNavItem(0)], ]); useEffect(() => { router.subscribe('onBeforeLoad', ({ pathChanged, toLocation, fromLocation }) => { + const sheetOpen = useNavigationStore.getState().navSheetOpen; if (toLocation.pathname !== fromLocation.pathname) { - // Disable focus view - setFocusView(false); - // Remove sheets in content - sheet.remove(`${navSheetOpen}-nav`); - setNavSheetOpen(null); + if (useNavigationStore.getState().focusView) setFocusView(false); + + // Remove all sheets in content or + if (sheetOpen && (sheetOpen !== 'menu' || !useNavigationStore.getState().keepMenuOpen)) { + setNavSheetOpen(null); + sheet.remove(); + } else sheet.remove(undefined, 'nav-sheet'); } pathChanged && setLoading(true); }); @@ -135,48 +116,8 @@ const AppNav = () => { }); }, []); - return ( - - ); + const NavComponent = showFloatNav ? FloatNav : BarNav; + return ; }; -export default AppNav; +export default MainNav; diff --git a/frontend/src/modules/common/main-search.tsx b/frontend/src/modules/common/main-search.tsx index 8248d445d..776d325f9 100644 --- a/frontend/src/modules/common/main-search.tsx +++ b/frontend/src/modules/common/main-search.tsx @@ -3,7 +3,7 @@ import { useNavigate } from '@tanstack/react-router'; import type { entitySuggestionSchema } from 'backend/modules/general/schema'; import type { Entity } from 'backend/types/common'; import { config } from 'config'; -import { History, Loader2, Search, X } from 'lucide-react'; +import { History, Search, X } from 'lucide-react'; import { Fragment, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import type { z } from 'zod'; @@ -14,11 +14,12 @@ import { dialog } from '~/modules/common/dialoger/state'; import StickyBox from '~/modules/common/sticky-box'; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandLoading, CommandSeparator } from '~/modules/ui/command'; import { ScrollArea } from '~/modules/ui/scroll-area'; -import { baseEntityRoutes, suggestionSections } from '~/nav-config'; +import { getEntityPath, suggestionSections } from '~/nav-config'; import { useNavigationStore } from '~/store/navigation'; import { Button } from '../ui/button'; +import Spinner from './spinner'; -type SuggestionType = z.infer; +export type SuggestionType = z.infer; export interface SuggestionSection { id: string; @@ -73,19 +74,15 @@ export const MainSearch = () => { }); const onSelectSuggestion = (suggestion: SuggestionType) => { - const { entity, parentId, slug } = suggestion; // Update recent searches with the search value updateRecentSearches(searchValue); - // Construct the destination URL - const basePath = baseEntityRoutes[entity]; - const queryParams = parentId ? `?${entity}=${slug}` : ''; - const to = `${basePath}${queryParams}`; - - const idOrSlug = parentId ?? slug; + // TODO because cella doesnt have a product entity, we remove orgIdOrSlug here. + // TODO an attachments crud with views will be added to cella, so we can add the orgIdOrSlug back + const { idOrSlug, path } = getEntityPath(suggestion); navigate({ - to, + to: path, resetScroll: false, params: { idOrSlug }, }); @@ -98,12 +95,13 @@ export const MainSearch = () => { }, [suggestions]); return ( - + { const historyIndexes = recentSearches.map((_, index) => index); @@ -116,8 +114,8 @@ export const MainSearch = () => { /> {isFetching && ( - - + + )} { diff --git a/frontend/src/modules/common/nav-sheet/helpers/add-menu-item.ts b/frontend/src/modules/common/nav-sheet/helpers/add-menu-item.ts index 453fc1d61..19165d960 100644 --- a/frontend/src/modules/common/nav-sheet/helpers/add-menu-item.ts +++ b/frontend/src/modules/common/nav-sheet/helpers/add-menu-item.ts @@ -2,26 +2,25 @@ import { useNavigationStore } from '~/store/navigation'; import type { UserMenu, UserMenuItem } from '~/types/common'; // Adding new item on local store user's menu -export const addMenuItem = (newEntity: UserMenuItem, storage: keyof UserMenu) => { +export const addMenuItem = (newEntity: UserMenuItem, sectionName: keyof UserMenu, parentSlug?: string) => { const menu = useNavigationStore.getState().menu; - // TODO: Do we still need parentId? const add = (items: UserMenuItem[]): UserMenuItem[] => { return items.map((item) => { - if (item.id === newEntity.parentId) { - return { - ...item, - submenu: item.submenu ? [...item.submenu, newEntity] : [newEntity], - }; - } - return item; + if (!parentSlug || item.slug !== parentSlug) return item; + + // If parent is found, add new entity to its submenu + return { + ...item, + submenu: item.submenu ? [...item.submenu, newEntity] : [newEntity], + }; }); }; - const updatedStorage = newEntity.parentId ? add(menu[storage]) : [...menu[storage], newEntity]; + const updatedMenuSection = parentSlug ? add(menu[sectionName]) : [...menu[sectionName], newEntity]; return { ...menu, - [storage]: updatedStorage, + [sectionName]: updatedMenuSection, }; }; diff --git a/frontend/src/modules/common/nav-sheet/helpers/index.ts b/frontend/src/modules/common/nav-sheet/helpers/index.ts index 65f3ec28e..70887e4ff 100644 --- a/frontend/src/modules/common/nav-sheet/helpers/index.ts +++ b/frontend/src/modules/common/nav-sheet/helpers/index.ts @@ -1,20 +1,59 @@ +import type { Edge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/dist/types/types'; import type { ContextEntity, UserMenu, UserMenuItem } from '~/types/common'; -const sortAndFilterMenu = (data: UserMenuItem[], entityType: ContextEntity, archived: boolean) => { - const menuList = data - //filter by type and archive state - .filter((el) => el.entity === entityType && el.membership.archived === archived) - // sort items by order - .sort((a, b) => a.membership.order - b.membership.order); - return menuList; +const sortAndFilterMenu = (data: UserMenuItem[], entityType: ContextEntity, archived: boolean, reverse = false): UserMenuItem[] => { + return ( + data + //filter by type and archive state + .filter((el) => el.entity === entityType && el.membership.archived === archived) + .sort((a, b) => { + // sort items by order + const orderA = a.membership?.order ?? 0; // Fallback to 0 if order is missing + const orderB = b.membership?.order ?? 0; + return reverse ? orderB - orderA : orderA - orderB; + }) + ); }; -export const findRelatedItemsByType = (data: UserMenu, entityType: ContextEntity, archived: boolean) => { +export const getRelativeItemOrder = ( + data: UserMenu, + entityType: ContextEntity, + archived: boolean, + itemId: string, + itemOrder: number, + edge: Edge | null, +) => { + const isEdgeTop = edge === 'top'; const flatData = Object.values(data).flat(); - const items = sortAndFilterMenu(flatData, entityType, archived); - if (items.length) return items; - const subItemsMenu = flatData.flatMap((el) => el.submenu || []); - const subItems = sortAndFilterMenu(subItemsMenu, entityType, archived); - return subItems.length ? subItems : []; + // Sort and filter main menu items + const items = sortAndFilterMenu(flatData, entityType, archived, isEdgeTop); + + // If no main menu items found, check submenu for the given itemId + let neededItems = items; + if (!items.length) { + const parentMenu = flatData.find((el) => el.submenu?.some((subEl) => subEl.id === itemId)); + if (parentMenu) neededItems = sortAndFilterMenu(parentMenu.submenu ?? [], entityType, archived, isEdgeTop); + } + // Find the relative item based on the item's position and the edge (top or bottom) + const relativeItem = neededItems.find(({ membership }) => (isEdgeTop ? membership.order < itemOrder : membership.order > itemOrder)); + + let newOrder: number; + + // Compute the new order based on the conditions + if (!relativeItem || relativeItem.membership.order === itemOrder) newOrder = orderChange(itemOrder, isEdgeTop ? 'dec' : 'inc'); + else if (relativeItem.id === itemId) newOrder = relativeItem.membership.order; + else newOrder = (relativeItem.membership.order + itemOrder) / 2; + + return newOrder; +}; + +export const orderChange = (order: number, action: 'inc' | 'dec') => { + if (action === 'inc') { + if (Number.isInteger(order)) return order + 1; + return Math.ceil(order); + } + + if (order > 1 && Number.isInteger(order)) return order - 1; + return order / 2; }; diff --git a/frontend/src/modules/common/nav-sheet/menu-archive-toggle.tsx b/frontend/src/modules/common/nav-sheet/menu-archive-toggle.tsx index e4b4a5132..364642b72 100644 --- a/frontend/src/modules/common/nav-sheet/menu-archive-toggle.tsx +++ b/frontend/src/modules/common/nav-sheet/menu-archive-toggle.tsx @@ -10,6 +10,7 @@ interface MenuArchiveToggleProps { isSubmenu?: boolean; } +// TODO isSubmenu and isArchivedVisible can go away and instead use conditional tailwind classes export const MenuArchiveToggle = ({ archiveToggleClick, inactiveCount, isArchivedVisible, isSubmenu }: MenuArchiveToggleProps) => { const { t } = useTranslation(); @@ -21,7 +22,7 @@ export const MenuArchiveToggle = ({ archiveToggleClick, inactiveCount, isArchive variant="secondary" className={`w-full ${ isSubmenu ? 'h-8 relative menu-item-sub' : '' - } group mb-1 cursor-pointer bg-background p-0 transition duration-300 focus:outline-none ring-1 ring-inset ring-transparent focus:ring-foreground hover:bg-accent/50 hover:text-accent-foreground`} + } group mb-1 cursor-pointer bg-background p-0 transition duration-300 focus-visible:outline-none ring-inset focus-visible:ring-offset-0 focus:ring-foreground hover:bg-accent/50 hover:text-accent-foreground`} >
diff --git a/frontend/src/modules/common/nav-sheet/sheet-menu-items.tsx b/frontend/src/modules/common/nav-sheet/sheet-menu-items.tsx index f08f3db1a..e2a5936b0 100644 --- a/frontend/src/modules/common/nav-sheet/sheet-menu-items.tsx +++ b/frontend/src/modules/common/nav-sheet/sheet-menu-items.tsx @@ -1,63 +1,61 @@ import { Link, useParams } from '@tanstack/react-router'; import { Plus } from 'lucide-react'; +import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { AvatarWrap } from '~/modules/common/avatar-wrap'; import { Button } from '~/modules/ui/button'; -import { baseEntityRoutes } from '~/nav-config'; +import { getEntityPath } from '~/nav-config'; import { useNavigationStore } from '~/store/navigation'; import type { ContextEntity, UserMenuItem } from '~/types/common'; import { cn } from '~/utils/cn'; interface SheetMenuItemProps { item: UserMenuItem; - type: ContextEntity; - mainItemIdOrSlug?: string | null; className?: string; searchResults?: boolean; } -export const SheetMenuItem = ({ item, type, className, mainItemIdOrSlug, searchResults }: SheetMenuItemProps) => { +export const SheetMenuItem = ({ item, className, searchResults }: SheetMenuItemProps) => { const { t } = useTranslation(); //Strict false is needed because sheet menu can be open at any route const currentIdOrSlug = useParams({ strict: false, select: (p) => p.idOrSlug }); const isActive = currentIdOrSlug === item.slug || currentIdOrSlug === item.id; - // Construct the destination URL - const basePath = baseEntityRoutes[type]; - const queryParams = mainItemIdOrSlug ? `?${type}=${item.slug}` : ''; - const path = `${basePath}${queryParams}`; - const idOrSlug = mainItemIdOrSlug ?? item.slug; - const orgIdOrSlug = item.membership.organizationId; + // Build route path for the entity + // TODO because cella doesnt have a product entity, we remove orgIdOrSlug here. + // TODO an attachments crud with views will be added to cella, so we can add the orgIdOrSlug back + const { idOrSlug, path } = useMemo(() => getEntityPath(item), [item]); + // TODO use tailwind conditional classes return (
{item.name}
-
- {searchResults && {t(type)}} +
+ {searchResults && {t(item.entity)}} {item.submenu?.length ? `${item.submenu?.length} ${t(`app:${item.submenu?.length > 1 ? `${item.submenu[0].entity}s` : item.submenu[0].entity}`).toLowerCase()}` @@ -101,20 +99,12 @@ export const SheetMenuItems = ({ data, type, shownOption, createDialog, classNam ); const renderItems = () => { - const filteredItems = data - .filter((item) => (shownOption === 'archived' ? item.membership.archived : !item.membership.archived)) - .sort((a, b) => a.membership.order - b.membership.order); + const filteredItems = data.filter((item) => (shownOption === 'archived' ? item.membership.archived : !item.membership.archived)); return ( <> {filteredItems.map((item) => (
- + {!item.membership.archived && item.submenu && !!item.submenu.length && !hideSubmenu && ( )} diff --git a/frontend/src/modules/common/nav-sheet/sheet-menu-options/complex-item.tsx b/frontend/src/modules/common/nav-sheet/sheet-menu-options/complex-item.tsx index 97d23150e..166890f96 100644 --- a/frontend/src/modules/common/nav-sheet/sheet-menu-options/complex-item.tsx +++ b/frontend/src/modules/common/nav-sheet/sheet-menu-options/complex-item.tsx @@ -5,7 +5,7 @@ import { useEffect, useRef, useState } from 'react'; import { DropIndicator } from '~/modules/common/drop-indicator'; import { MenuArchiveToggle } from '~/modules/common/nav-sheet/menu-archive-toggle'; import { SheetMenuItemsOptions } from '~/modules/common/nav-sheet/sheet-menu-options'; -import { ItemOption } from '~/modules/common/nav-sheet/sheet-menu-options/item-option'; +import { MenuItemOptions } from '~/modules/common/nav-sheet/sheet-menu-options/menu-item-options'; import type { UserMenuItem } from '~/types/common'; import { getDraggableItemData } from '~/utils/drag-drop'; import { isPageData } from '../sheet-menu'; @@ -28,9 +28,7 @@ export const ComplexOptionElement = ({ const [closestEdge, setClosestEdge] = useState(null); const handleCanDrop = (sourceData: Record) => { - return ( - isPageData(sourceData) && sourceData.item.id !== item.id && sourceData.itemType === item.entity && sourceData.item.parentId === item.parentId - ); + return isPageData(sourceData) && sourceData.item.id !== item.id && sourceData.itemType === item.entity; }; // create draggable & dropTarget elements and auto scroll @@ -65,7 +63,7 @@ export const ComplexOptionElement = ({ return (
- + {!item.membership.archived && item.submenu && !!item.submenu.length && !hideSubmenu && ( <> diff --git a/frontend/src/modules/common/nav-sheet/sheet-menu-options/item-option.tsx b/frontend/src/modules/common/nav-sheet/sheet-menu-options/menu-item-options.tsx similarity index 69% rename from frontend/src/modules/common/nav-sheet/sheet-menu-options/item-option.tsx rename to frontend/src/modules/common/nav-sheet/sheet-menu-options/menu-item-options.tsx index e68de67d9..08af86e55 100644 --- a/frontend/src/modules/common/nav-sheet/sheet-menu-options/item-option.tsx +++ b/frontend/src/modules/common/nav-sheet/sheet-menu-options/menu-item-options.tsx @@ -1,5 +1,4 @@ import { onlineManager } from '@tanstack/react-query'; -import type { ContextEntity } from 'backend/types/common'; import { config } from 'config'; import { motion } from 'framer-motion'; import { Archive, ArchiveRestore, Bell, BellOff } from 'lucide-react'; @@ -10,19 +9,16 @@ import { useMutation } from '~/hooks/use-mutations'; import { dispatchCustomEvent } from '~/lib/custom-events'; import { AvatarWrap } from '~/modules/common/avatar-wrap'; import { Button } from '~/modules/ui/button'; -import { useNavigationStore } from '~/store/navigation'; import type { UserMenuItem } from '~/types/common'; import Spinner from '../../spinner'; -interface ItemOptionProps { +interface MenuItemOptionsProps { item: UserMenuItem; - itemType: ContextEntity; - parentItemSlug?: string; } -export const ItemOption = ({ item, itemType, parentItemSlug }: ItemOptionProps) => { +export const MenuItemOptions = ({ item }: MenuItemOptionsProps) => { const { t } = useTranslation(); - const { archiveStateToggle } = useNavigationStore(); + const { mutate: updateMembership, status } = useMutation({ mutationFn: (values: UpdateMenuOptionsProp) => baseUpdateMembership(values), onSuccess: (updatedMembership) => { @@ -30,18 +26,18 @@ export const ItemOption = ({ item, itemType, parentItemSlug }: ItemOptionProps) if (updatedMembership.archived !== item.membership.archived) { const archived = updatedMembership.archived || !item.membership.archived; - archiveStateToggle(item, archived, parentItemSlug ? parentItemSlug : null); + item.membership.archived = archived; - toastMessage = t(`common:success.${updatedMembership.archived ? 'archived' : 'restore'}_resource`, { resource: t(`common:${itemType}`) }); + toastMessage = t(`common:success.${updatedMembership.archived ? 'archived' : 'restore'}_resource`, { resource: t(`common:${item.entity}`) }); } if (updatedMembership.muted !== item.membership.muted) { const muted = updatedMembership.muted || !item.membership.muted; item.membership.muted = muted; - toastMessage = t(`common:success.${updatedMembership.muted ? 'mute' : 'unmute'}_resource`, { resource: t(`common:${itemType}`) }); + toastMessage = t(`common:success.${updatedMembership.muted ? 'mute' : 'unmute'}_resource`, { resource: t(`common:${item.entity}`) }); } - dispatchCustomEvent('menuEntityChange', { entity: itemType, membership: updatedMembership, toast: toastMessage }); + dispatchCustomEvent('menuEntityChange', { entity: item.entity, membership: updatedMembership, toast: toastMessage }); }, }); @@ -62,17 +58,17 @@ export const ItemOption = ({ item, itemType, parentItemSlug }: ItemOptionProps) return ( {status === 'pending' ? ( -
+
) : ( -
- {item.name} {config.mode === 'development' && #{item.membership.order}} +
+ {item.name} {config.debug && #{item.membership.order}}
handleUpdateMembershipKey('archive')} - subTask={!!parentItemSlug} + subtask={!item.submenu} /> handleUpdateMembershipKey('mute')} - subTask={!!parentItemSlug} + subtask={!item.submenu} />
@@ -106,9 +102,9 @@ interface OptionButtonsProps { Icon: React.ElementType; title: string; onClick: () => void; - subTask?: boolean; + subtask?: boolean; } -const OptionButtons = ({ Icon, title, onClick, subTask = false }: OptionButtonsProps) => ( +const OptionButtons = ({ Icon, title, onClick, subtask = false }: OptionButtonsProps) => ( ); diff --git a/frontend/src/modules/common/nav-sheet/sheet-menu-search.tsx b/frontend/src/modules/common/nav-sheet/sheet-menu-search.tsx index b6fd9a0e6..7b9df7a25 100644 --- a/frontend/src/modules/common/nav-sheet/sheet-menu-search.tsx +++ b/frontend/src/modules/common/nav-sheet/sheet-menu-search.tsx @@ -24,13 +24,16 @@ export const SheetMenuSearch = ({ menu, searchTerm, setSearchTerm, searchResults if (!searchTerm.trim()) return []; const lowerCaseTerm = searchTerm.toLowerCase(); + + // Flatten menu items and submenus const filterItems = (items: UserMenuItem[]): UserMenuItem[] => items.flatMap((item) => { const isMatch = item.name.toLowerCase().includes(lowerCaseTerm); const filteredSubmenu = item.submenu ? filterItems(item.submenu) : []; return isMatch ? [item, ...filteredSubmenu] : filteredSubmenu; }); - return menuSections.filter((el) => !el.isSubmenu).flatMap((section) => filterItems(menu[section.storageType])); + + return menuSections.flatMap((section) => filterItems(menu[section.name])); }; searchResultsChange(filterResults()); }, [searchTerm, menu]); diff --git a/frontend/src/modules/common/nav-sheet/sheet-menu-section.tsx b/frontend/src/modules/common/nav-sheet/sheet-menu-section.tsx index c19a202b7..17f23bec8 100755 --- a/frontend/src/modules/common/nav-sheet/sheet-menu-section.tsx +++ b/frontend/src/modules/common/nav-sheet/sheet-menu-section.tsx @@ -20,12 +20,12 @@ interface MenuSectionProps { export const MenuSection = ({ data, sectionType, sectionLabel, entityType, createForm }: MenuSectionProps) => { const { t } = useTranslation(); + const { activeSections } = useNavigationStore(); const [optionsView, setOptionsView] = useState(false); const [isArchivedVisible, setArchivedVisible] = useState(false); - const { activeSections } = useNavigationStore(); + const isSectionVisible = activeSections?.[sectionType] !== undefined ? activeSections[sectionType] : true; - const parentItemId = data.length > 0 ? data[0].parentId : ''; const sectionRef = useRef(null); const archivedRef = useRef(null); @@ -47,6 +47,7 @@ export const MenuSection = ({ data, sectionType, sectionLabel, entityType, creat setArchivedVisible(!isArchivedVisible); }; + // TODO - refactor this into a generic hook? // Helper function to set or remove 'tabindex' attribute const updateTabIndex = (ref: React.RefObject, isVisible: boolean) => { if (!ref.current) return; @@ -69,17 +70,15 @@ export const MenuSection = ({ data, sectionType, sectionLabel, entityType, creat return ( <> - {!parentItemId && ( - - )} +
i.membership.archived).length} isArchivedVisible={isArchivedVisible} diff --git a/frontend/src/modules/common/nav-sheet/sheet-menu.tsx b/frontend/src/modules/common/nav-sheet/sheet-menu.tsx index e5776ddae..2dc87e121 100644 --- a/frontend/src/modules/common/nav-sheet/sheet-menu.tsx +++ b/frontend/src/modules/common/nav-sheet/sheet-menu.tsx @@ -9,11 +9,11 @@ import { monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/ad import { config } from 'config'; import { type LucideProps, Search } from 'lucide-react'; import { useTranslation } from 'react-i18next'; + import { updateMembership } from '~/api/memberships'; import { dispatchCustomEvent } from '~/lib/custom-events'; - import ContentPlaceholder from '~/modules/common/content-placeholder'; -import { findRelatedItemsByType } from '~/modules/common/nav-sheet/helpers'; +import { getRelativeItemOrder } from '~/modules/common/nav-sheet/helpers'; import { SheetMenuItem } from '~/modules/common/nav-sheet/sheet-menu-items'; import { SheetMenuSearch } from '~/modules/common/nav-sheet/sheet-menu-search'; import { MenuSection } from '~/modules/common/nav-sheet/sheet-menu-section'; @@ -29,48 +29,41 @@ export const isPageData = (data: Record): data is Page }; export type SectionItem = { - storageType: keyof UserMenu; - type: ContextEntity; + name: keyof UserMenu; + entityType: ContextEntity; label: string; createForm?: React.ReactNode; - isSubmenu?: boolean; - toPrefix?: boolean; + submenu?: SectionItem; icon?: React.ElementType; }; export const SheetMenu = memo(() => { const { t } = useTranslation(); - const { menu, keepMenuOpen, hideSubmenu, toggleHideSubmenu, toggleKeepMenu } = useNavigationStore(); + const { menu, keepOpenPreference, hideSubmenu, toggleHideSubmenu, toggleKeepOpenPreference } = useNavigationStore(); const [searchTerm, setSearchTerm] = useState(''); const [searchResults, setSearchResults] = useState([]); const pwaEnabled = config.has.pwa; const searchResultsListItems = useCallback(() => { - return searchResults.length > 0 - ? searchResults.map((item: UserMenuItem) => ( - - )) - : []; + return searchResults.length > 0 ? searchResults.map((item: UserMenuItem) => ) : []; }, [searchResults]); const renderedSections = useMemo(() => { - return menuSections - .filter((el) => !el.isSubmenu) - .map((section) => { - const menuSection = menu[section.storageType]; - - return ( - - ); - }); + return menuSections.map((section) => { + const menuSection = menu[section.name]; + + return ( + + ); + }); }, [menu]); // monitoring drop event @@ -88,25 +81,16 @@ export const SheetMenu = memo(() => { const targetData = target.data; if (!isPageData(targetData) || !isPageData(sourceData)) return; - const closestEdgeOfTarget: Edge | null = extractClosestEdge(targetData); - const neededItems = findRelatedItemsByType(menu, sourceData.item.entity, sourceData.item.membership.archived); - const targetItemIndex = neededItems.findIndex((i) => i.id === targetData.item.id); - const relativeItemIndex = closestEdgeOfTarget === 'top' ? targetItemIndex - 1 : targetItemIndex + 1; - - const relativeItem = neededItems[relativeItemIndex]; - let newOrder: number; - - if (relativeItem === undefined || relativeItem.membership.order === targetData.order) { - newOrder = closestEdgeOfTarget === 'top' ? targetData.order / 2 : targetData.order + 1; - } else if (relativeItem.id === sourceData.item.id) newOrder = sourceData.order; - else newOrder = (relativeItem.membership.order + targetData.order) / 2; + const { item: sourceItem } = sourceData; + const edge: Edge | null = extractClosestEdge(targetData); + const newOrder = getRelativeItemOrder(menu, sourceItem.entity, sourceItem.membership.archived, sourceItem.id, targetData.order, edge); const updatedMembership = await updateMembership({ - membershipId: sourceData.item.membership.id, + membershipId: sourceItem.membership.id, order: newOrder, - organizationId: sourceData.item.organizationId || sourceData.item.id, + organizationId: sourceItem.organizationId || sourceItem.id, }); - dispatchCustomEvent('menuEntityChange', { entity: sourceData.item.entity, membership: updatedMembership }); + dispatchCustomEvent('menuEntityChange', { entity: sourceItem.entity, membership: updatedMembership }); }, }), ); @@ -117,7 +101,7 @@ export const SheetMenu = memo(() => {
{searchTerm && ( -
+
{searchResultsListItems().length > 0 ? ( searchResultsListItems() ) : ( @@ -132,13 +116,19 @@ export const SheetMenu = memo(() => {
- +
{pwaEnabled && } - {menuSections.some((el) => el.isSubmenu) && ( + {menuSections.some((el) => el.submenu) && (
); diff --git a/frontend/src/modules/ui/sheet.tsx b/frontend/src/modules/ui/sheet.tsx index df68151ae..b52831a8f 100644 --- a/frontend/src/modules/ui/sheet.tsx +++ b/frontend/src/modules/ui/sheet.tsx @@ -1,6 +1,7 @@ import * as SheetPrimitive from '@radix-ui/react-dialog'; import * as VisuallyHidden from '@radix-ui/react-visually-hidden'; import { type VariantProps, cva } from 'class-variance-authority'; +import { X } from 'lucide-react'; import * as React from 'react'; import { cn } from '~/utils/cn'; @@ -17,7 +18,7 @@ const SheetOverlay = React.forwardRef ( , VariantProps { onClick?: () => void; // Adding onClick prop + hideClose?: boolean; } const SheetContent = React.forwardRef, SheetContentProps>( - ({ side = 'right', className, children, onClick, ...props }, ref) => ( + ({ side = 'right', className, children, hideClose = false, onClick, ...props }, ref) => ( <> + {!hideClose && ( + + + Close + + )} {children} diff --git a/frontend/src/modules/ui/tooltip.tsx b/frontend/src/modules/ui/tooltip.tsx index f6d50b925..48e6202cb 100644 --- a/frontend/src/modules/ui/tooltip.tsx +++ b/frontend/src/modules/ui/tooltip.tsx @@ -19,7 +19,7 @@ const TooltipContent = React.forwardRef< ref={ref} sideOffset={sideOffset} className={cn( - 'max-md:hidden bg-popover text-popover-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-[100] overflow-hidden rounded-md border px-3 py-1.5 font-light text-sm', + 'max-md:hidden bg-muted-foreground text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-[200] overflow-hidden rounded-md px-3 py-1.5 font-light text-xs', className, )} {...props} diff --git a/frontend/src/modules/users/profile-page-content.tsx b/frontend/src/modules/users/profile-page-content.tsx index 74ea3f5dc..8c5c7338d 100644 --- a/frontend/src/modules/users/profile-page-content.tsx +++ b/frontend/src/modules/users/profile-page-content.tsx @@ -1,26 +1,21 @@ -import { Squirrel } from 'lucide-react'; -import ContentPlaceholder from '~/modules/common/content-placeholder'; +// import { useQuery } from '@tanstack/react-query'; +// import { getUser } from '~/api/users'; -import { useEffect, useState } from 'react'; -import { getUser } from '~/api/users'; +import { Squirrel } from 'lucide-react'; +import ContentPlaceholder from '../common/content-placeholder'; -// Here you can add app-specific profile page content const ProfilePageContent = ({ sheet, userId, organizationId }: { userId: string; organizationId?: string; sheet?: boolean }) => { - const [orgId, setOrgId] = useState(organizationId); + // const { data: user } = useQuery({ + // queryKey: ['users', userId], + // queryFn: () => getUser(userId), + // // Disable the query when `organizationId` is available + // enabled: !organizationId, + // }); - useEffect(() => { - // If `orgId` is already provided, no need to fetch - if (orgId) return; - (async () => { - const { organizations } = await getUser(userId); - if (organizations.length > 0) setOrgId(organizations[0].id); - })(); - }, [orgId]); + console.info('ProfilePageContent', { userId, sheet }); // Don't render anything until `orgId` is available - if (!orgId) return null; - - console.debug('data available in profile page content:', sheet, userId, organizationId); + if (!organizationId) return
Do a get organizations request here
; return ; }; diff --git a/frontend/src/modules/users/profile-page.tsx b/frontend/src/modules/users/profile-page.tsx index dd3ee4e86..896675fca 100644 --- a/frontend/src/modules/users/profile-page.tsx +++ b/frontend/src/modules/users/profile-page.tsx @@ -37,10 +37,11 @@ const UserProfilePage = ({ const { user: currentUser, setUser } = useUserStore(); const isSelf = currentUser.id === user.id; - const organizationId = isUserMember(user) ? user.membership.organizationId : user.organizations?.[0]?.id; + const organizationId = isUserMember(user) ? user.membership.organizationId : undefined; const { mutate } = useUpdateUserMutation(currentUser.id); + // TODO this should be a Link with button variant style? const handleSettingCLick = () => { navigate({ to: '/user/settings', replace: true }); }; diff --git a/frontend/src/modules/users/settings-page.tsx b/frontend/src/modules/users/settings-page.tsx index c30239166..bcba79ef5 100644 --- a/frontend/src/modules/users/settings-page.tsx +++ b/frontend/src/modules/users/settings-page.tsx @@ -74,7 +74,7 @@ const UserSettingsPage = () => { { className: 'md:max-w-xl', title: t('common:delete_account'), - text: t('common:confirm.delete_account', { email: user.email }), + description: t('common:confirm.delete_account', { email: user.email }), }, ); }; diff --git a/frontend/src/modules/users/update-user-form.tsx b/frontend/src/modules/users/update-user-form.tsx index 9bd5a9649..2fcfbee4c 100644 --- a/frontend/src/modules/users/update-user-form.tsx +++ b/frontend/src/modules/users/update-user-form.tsx @@ -15,6 +15,7 @@ import { Button } from '~/modules/ui/button'; import { Checkbox } from '~/modules/ui/checkbox'; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '~/modules/ui/form'; +import { isValidElement } from 'react'; import type { UseFormProps } from 'react-hook-form'; import { toast } from 'sonner'; import { useFormWithDraft } from '~/hooks/use-draft-form'; @@ -114,12 +115,15 @@ const UpdateUserForm = ({ user, callback, sheet: isSheet, hiddenFields, children useEffect(() => { if (form.unsavedChanges) { const targetSheet = sheet.get('update-user'); - if (targetSheet && targetSheet.title?.type?.name !== 'UnsavedBadge') { - sheet.update('update-user', { - title: , - }); - } - return; + + if (!targetSheet || !isValidElement(targetSheet.title)) return; + // Check if the title's type is a function (React component) and not a string + const { type: tittleType } = targetSheet.title; + + if (typeof tittleType !== 'function' || tittleType.name === 'UnsavedBadge') return; + sheet.update('update-user', { + title: , + }); } }, [form.unsavedChanges]); diff --git a/frontend/src/modules/users/users-table/columns.tsx b/frontend/src/modules/users/users-table/columns.tsx index 062b7c011..773c97b09 100644 --- a/frontend/src/modules/users/users-table/columns.tsx +++ b/frontend/src/modules/users/users-table/columns.tsx @@ -38,16 +38,7 @@ export const useColumns = (callback: (users: User[], action: 'create' | 'update' onClick={(e) => { if (e.metaKey || e.ctrlKey) return; e.preventDefault(); - navigate({ - to: '.', - replace: true, - resetScroll: false, - search: (prev) => ({ - ...prev, - ...{ userIdPreview: row.id }, - }), - }); - openUserPreviewSheet(row); + openUserPreviewSheet(row, navigate, true); }} > diff --git a/frontend/src/modules/users/users-table/index.tsx b/frontend/src/modules/users/users-table/index.tsx index fcce208fe..e7340b466 100644 --- a/frontend/src/modules/users/users-table/index.tsx +++ b/frontend/src/modules/users/users-table/index.tsx @@ -1,5 +1,5 @@ import { onlineManager, useSuspenseInfiniteQuery } from '@tanstack/react-query'; -import { useSearch } from '@tanstack/react-router'; +import { useNavigate, useSearch } from '@tanstack/react-router'; import { useEffect, useMemo, useRef, useState } from 'react'; import { updateUser } from '~/api/users'; @@ -44,6 +44,8 @@ type SystemRoles = (typeof config.rolesByType.systemRoles)[number] | undefined; const UsersTable = () => { const { t } = useTranslation(); + const navigate = useNavigate(); + const search = useSearch({ from: UsersTableRoute.id }); const containerRef = useRef(null); @@ -138,9 +140,10 @@ const UsersTable = () => { drawerOnMobile: false, className: 'w-auto shadow-none relative z-[60] max-w-4xl', container: containerRef.current, - containerBackdrop: false, + containerBackdrop: true, + containerBackdropClassName: 'z-50', title: t('common:invite'), - text: `${t('common:invite_users.text')}`, + description: `${t('common:invite_users.text')}`, }); }; @@ -158,7 +161,7 @@ const UsersTable = () => { drawerOnMobile: false, className: 'max-w-xl', title: t('common:delete'), - text: t('common:confirm.delete_resource', { + description: t('common:confirm.delete_resource', { name: selectedUsers.map((u) => u.email).join(', '), resource: selectedUsers.length > 1 ? t('common:users').toLowerCase() : t('common:user').toLowerCase(), }), @@ -169,7 +172,7 @@ const UsersTable = () => { useEffect(() => { if (!rows.length || !search.userIdPreview) return; const user = rows.find((t) => t.id === search.userIdPreview); - if (user) openUserPreviewSheet(user); + if (user) openUserPreviewSheet(user, navigate); }, [rows]); return ( diff --git a/frontend/src/modules/users/users-table/update-row.tsx b/frontend/src/modules/users/users-table/update-row.tsx index 121c77b3f..6692006b7 100644 --- a/frontend/src/modules/users/users-table/update-row.tsx +++ b/frontend/src/modules/users/users-table/update-row.tsx @@ -29,6 +29,7 @@ const UpdateRow = ({ user, callback, tabIndex }: Props) => { , { id: 'update-user', + side: 'right', className: 'max-w-full lg:max-w-4xl', title: t('common:edit_resource', { resource: t('common:user').toLowerCase() }), }, diff --git a/frontend/src/nav-config.tsx b/frontend/src/nav-config.tsx index 177d1140c..2f0350053 100644 --- a/frontend/src/nav-config.tsx +++ b/frontend/src/nav-config.tsx @@ -8,13 +8,13 @@ import CreateOrganizationForm from '~/modules/organizations/create-organization- import { config } from 'config'; import type { FooterLinkProps } from '~/modules/common/main-footer'; import type { NavItem } from '~/modules/common/main-nav'; -import { MainSearch, type SuggestionSection } from '~/modules/common/main-search'; +import { MainSearch, type SuggestionSection, type SuggestionType } from '~/modules/common/main-search'; import type { SectionItem } from '~/modules/common/nav-sheet/sheet-menu'; +import type { UserMenuItem } from './types/common'; // Set entities paths export const baseEntityRoutes = { user: '/user/$idOrSlug', - userInOrg: '/$orgIdOrSlug/user/$idOrSlug', organization: '/$idOrSlug', } as const; @@ -31,12 +31,11 @@ export const navItems: NavItem[] = [ { id: 'account', icon: User, sheet: , mirrorOnMobile: true }, ]; -// Here you declare the menu sections(same need in BE with storageType, type & isSubmenu ) +// Here you declare the menu sections export const menuSections: SectionItem[] = [ { - storageType: 'organizations', - type: 'organization', - isSubmenu: false, + name: 'organizations', + entityType: 'organization', createForm: , label: 'common:organizations', }, @@ -54,3 +53,14 @@ export const suggestionSections: SuggestionSection[] = [ { id: 'users', label: 'common:users', type: 'user' }, { id: 'organizations', label: 'common:organizations', type: 'organization' }, ]; + +// App specific entity path resolver +// TODO review this again, I dont like the fallback to empty string +export const getEntityPath = (item: UserMenuItem | SuggestionType) => { + const path = baseEntityRoutes[item.entity]; + + const idOrSlug = item.slug || item.id || ''; + const orgIdOrSlug = item.organizationId || ''; + + return { path, idOrSlug, orgIdOrSlug }; +}; diff --git a/frontend/src/routes/general.tsx b/frontend/src/routes/general.tsx index 7684c2f6b..ad8cc6185 100644 --- a/frontend/src/routes/general.tsx +++ b/frontend/src/routes/general.tsx @@ -65,7 +65,9 @@ export const AppRoute = createRoute({ ), - loader: async ({ location }) => { + loader: async ({ location, cause }) => { + if (cause !== 'enter') return; + try { console.debug('Fetch me & menu while entering app ', location.pathname); const getSelf = async () => { diff --git a/frontend/src/routes/index.tsx b/frontend/src/routes/index.tsx index 5e620b49f..ede676556 100644 --- a/frontend/src/routes/index.tsx +++ b/frontend/src/routes/index.tsx @@ -4,7 +4,7 @@ import { HomeAliasRoute, HomeRoute, WelcomeRoute } from '~/routes/home'; import { AboutRoute, AccessibilityRoute, ContactRoute, LegalRoute } from '~/routes/marketing'; import { OrganizationMembersRoute, OrganizationRoute, OrganizationSettingsRoute } from '~/routes/organizations'; import { MetricsRoute, OrganizationsTableRoute, RequestsTableRoute, SystemRoute, UsersTableRoute } from '~/routes/system'; -import { UserInOrgProfileRoute, UserProfileRoute, UserSettingsRoute } from '~/routes/users'; +import { UserProfileRoute, UserSettingsRoute } from '~/routes/users'; import { AppRoute, ErrorNoticeRoute, PublicRoute, UnsubscribeRoute, acceptInviteRoute, rootRoute } from './general'; export const routeTree = rootRoute.addChildren([ @@ -24,7 +24,6 @@ export const routeTree = rootRoute.addChildren([ WelcomeRoute, SystemRoute.addChildren([UsersTableRoute, OrganizationsTableRoute, RequestsTableRoute, MetricsRoute]), UserProfileRoute, - UserInOrgProfileRoute, UserSettingsRoute, // App specific routes here diff --git a/frontend/src/routes/users.tsx b/frontend/src/routes/users.tsx index 7cf775644..b595c2abf 100644 --- a/frontend/src/routes/users.tsx +++ b/frontend/src/routes/users.tsx @@ -36,25 +36,6 @@ export const UserProfileRoute = createRoute({ }, }); -export const UserInOrgProfileRoute = createRoute({ - path: baseEntityRoutes.userInOrg, - staticData: { pageTitle: 'Profile', isAuth: true }, - getParentRoute: () => AppRoute, - loader: async ({ params: { idOrSlug } }) => { - queryClient.ensureQueryData(userQueryOptions(idOrSlug)); - }, - errorComponent: ({ error }) => , - component: () => { - const { idOrSlug } = useParams({ from: UserProfileRoute.id }); - const userQuery = useSuspenseQuery(userQueryOptions(idOrSlug)); - return ( - - - - ); - }, -}); - export const UserSettingsRoute = createRoute({ path: '/user/settings', staticData: { pageTitle: 'Settings', isAuth: true }, diff --git a/frontend/src/store/navigation.ts b/frontend/src/store/navigation.ts index e02069ada..10ae80b30 100644 --- a/frontend/src/store/navigation.ts +++ b/frontend/src/store/navigation.ts @@ -3,11 +3,9 @@ import { config } from 'config'; import { create } from 'zustand'; import { createJSONStorage, devtools, persist } from 'zustand/middleware'; import { immer } from 'zustand/middleware/immer'; -import { sheet } from '~/modules/common/sheeter/state'; import { menuSections } from '~/nav-config'; -import type { UserMenu, UserMenuItem } from '~/types/common'; -import { objectKeys } from '~/utils/object'; +import type { UserMenu } from '~/types/common'; type EntitySubList = Record; export type EntityConfig = Record; @@ -19,7 +17,9 @@ interface NavigationState { navSheetOpen: string | null; setNavSheetOpen: (sheet: string | null) => void; keepMenuOpen: boolean; - toggleKeepMenu: (status: boolean) => void; + setKeepMenuOpen: (status: boolean) => void; + keepOpenPreference: boolean; + toggleKeepOpenPreference: (status: boolean) => void; hideSubmenu: boolean; toggleHideSubmenu: (status: boolean) => void; activeSections: Record | null; @@ -29,7 +29,6 @@ interface NavigationState { setLoading: (status: boolean) => void; focusView: boolean; setFocusView: (status: boolean) => void; - archiveStateToggle: (item: UserMenuItem, active: boolean, parentId?: string | null) => void; finishedOnboarding: boolean; setFinishedOnboarding: () => void; clearNavigationStore: () => void; @@ -38,20 +37,30 @@ interface NavigationState { interface InitStore extends Pick< NavigationState, - 'recentSearches' | 'keepMenuOpen' | 'hideSubmenu' | 'navLoading' | 'focusView' | 'menu' | 'activeSections' | 'finishedOnboarding' | 'navSheetOpen' + | 'recentSearches' + | 'keepMenuOpen' + | 'hideSubmenu' + | 'navLoading' + | 'focusView' + | 'menu' + | 'activeSections' + | 'finishedOnboarding' + | 'navSheetOpen' + | 'keepOpenPreference' > {} const initialMenuState: UserMenu = menuSections - .filter((el) => !el.isSubmenu) + .filter((el) => !el.submenu) .reduce((acc, section) => { - acc[section.storageType] = []; + acc[section.name] = []; return acc; }, {} as UserMenu); const initStore: InitStore = { recentSearches: [], navSheetOpen: null, - keepMenuOpen: false, + keepMenuOpen: window.innerWidth > 1280, + keepOpenPreference: false, hideSubmenu: false, navLoading: false, focusView: false, @@ -76,11 +85,16 @@ export const useNavigationStore = create()( state.recentSearches = searchValues; }); }, - toggleKeepMenu: (status) => { + setKeepMenuOpen: (status) => { set((state) => { state.keepMenuOpen = status; }); }, + toggleKeepOpenPreference: (status) => { + set((state) => { + state.keepOpenPreference = status; + }); + }, toggleHideSubmenu: (status) => { set((state) => { state.hideSubmenu = status; @@ -94,7 +108,6 @@ export const useNavigationStore = create()( setFocusView: (status) => { set((state) => { state.focusView = status; - sheet.remove(); }); }, toggleSection: (section) => { @@ -109,26 +122,6 @@ export const useNavigationStore = create()( state.activeSections = null; }); }, - archiveStateToggle: (item: UserMenuItem, active: boolean, parentId?: string | null) => { - set((state) => { - if (!parentId) { - // Update the 'archived' status for the item in all sections - for (const sectionKey of objectKeys(state.menu)) { - const section = state.menu[sectionKey]; - const itemIndex = section.findIndex((el) => el.id === item.id); - if (itemIndex !== -1) state.menu[sectionKey][itemIndex].membership.archived = active; - } - } else { - // Update the 'archived' status for the item in a specific submenu - const section = menuSections.find((el) => el.type === item.entity)?.storageType; - if (!section) return; - const parent = state.menu[section].find((item) => item.id === parentId); - if (!parent || !parent.submenu) return; - const itemIndex = parent.submenu.findIndex((el) => el.id === item.id); - if (itemIndex && itemIndex !== -1) parent.submenu[itemIndex].membership.archived = active; - } - }); - }, setFinishedOnboarding: () => { set((state) => { state.finishedOnboarding = true; @@ -141,11 +134,11 @@ export const useNavigationStore = create()( })), }), { - version: 5, + version: 6, name: `${config.slug}-navigation`, partialize: (state) => ({ menu: state.menu, - keepMenuOpen: state.keepMenuOpen, + keepOpenPreference: state.keepOpenPreference, hideSubmenu: state.hideSubmenu, activeSections: state.activeSections, recentSearches: state.recentSearches, diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts index 188b74ba7..359f34a7f 100644 --- a/frontend/tailwind.config.ts +++ b/frontend/tailwind.config.ts @@ -100,6 +100,19 @@ export default { '60%': { transform: 'rotate(0.0deg)' }, '100%': { transform: 'rotate(0.0deg)' }, }, + heartbeat: { + '0%': { transform: 'scale(1);' }, + '14%': { transform: 'scale(1.3);' }, + '28%': { transform: 'scale(1);' }, + '42%': { transform: 'scale(1.3);' }, + '70%': { transform: 'scale(1);' }, + }, + 'flip-horizontal': { + '50%': { transform: 'rotateY(180deg)' }, + }, + 'flip-vertical': { + '50%': { transform: 'rotateX(180deg)' }, + }, }, animation: { 'waving-hand': 'wave 2s linear infinite', @@ -108,6 +121,9 @@ export default { 'accordion-up': 'accordion-up 0.2s ease-out', 'collapsible-down': 'collapsible-down 0.2s ease-out', 'collapsible-up': 'collapsible-up 0.2s ease-out', + heartbeat: 'heartbeat 1s infinite', + hflip: 'flip-horizontal 2s infinite', + vflip: 'flip-certical 2s infinite', }, }, }, diff --git a/locales/en/common.json b/locales/en/common.json index 79fafe3fd..6289c8f16 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -121,7 +121,7 @@ "error.passkey_add_failed": "Registering passkey failed. Try again later.", "error.passkey_remove_failed": "Removing passkey failed. Try again later.", "error.passkey_sign_in": "Passkey verification failed.", - "error.reorder_resources": "{{resource}} reorder failed.", + "error.reorder_resource": "{{resource}} reorder failed.", "error.reported_try_later": "An error has been reported. Please try again later.", "error.reported_try_or_contact": "An error has been reported. Please try again later and contact support if the problem persists.", "error.request_email_is_user": "This email is already linked to an account. Please use a different one!", @@ -238,7 +238,7 @@ "oauth": "OAuth providers", "oauth.text": "Connect your account with a popular identity provider.", "offline": "Offline", - "offline.text": "Connection lost. Severely limited functionality remains.", + "offline.text": "Connection lost. Limited functionality remains.", "offline_mode": "Offline access", "offline_mode.text": "Connection lost. Continue in offline mode.", "offline_mode_off.text": "{{appName}} cleared offline cache and stopped syncing.", @@ -315,6 +315,7 @@ "roadmap": "Roadmap", "role": "Role", "save": "Save", + "saved": "Saved!", "save_changes": "Save changes", "schedule_call.text": "Schedule a call", "search": "Search", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5ad367bf5..c6ee9f7c7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -301,14 +301,17 @@ importers: specifier: ^1.0.3 version: 1.0.3 '@blocknote/core': - specifier: ^0.15.11 - version: 0.15.11 + specifier: ^0.16.0 + version: 0.16.0 '@blocknote/react': - specifier: ^0.15.11 - version: 0.15.11(@tiptap/pm@2.8.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: ^0.16.0 + version: 0.16.0(@tiptap/pm@2.8.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@blocknote/shadcn': - specifier: ^0.15.11 - version: 0.15.11(@tiptap/pm@2.8.0)(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(ts-node@10.9.2(@swc/core@1.7.26)(@types/node@22.7.4)(typescript@5.6.2)) + specifier: ^0.16.0 + version: 0.16.0(@tiptap/pm@2.8.0)(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(ts-node@10.9.2(@swc/core@1.7.26)(@types/node@22.7.4)(typescript@5.6.2)) + '@floating-ui/react': + specifier: ^0.26.25 + version: 0.26.25(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@github/mini-throttle': specifier: ^2.1.1 version: 2.1.1 @@ -409,11 +412,11 @@ importers: specifier: ^5.59.0 version: 5.59.0(@tanstack/react-query@5.59.0(react@18.3.1))(react@18.3.1) '@tanstack/react-router': - specifier: ^1.58.17 - version: 1.58.17(@tanstack/router-generator@1.58.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: ^1.63.2 + version: 1.63.2(@tanstack/router-generator@1.58.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@tanstack/router-devtools': - specifier: ^1.58.17 - version: 1.58.17(@tanstack/react-router@1.58.17(@tanstack/router-generator@1.58.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(csstype@3.1.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: ^1.63.2 + version: 1.63.2(@tanstack/react-router@1.63.2(@tanstack/router-generator@1.58.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(csstype@3.1.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@uppy/audio': specifier: ^2.0.1 version: 2.0.1(@uppy/core@4.2.1) @@ -519,9 +522,6 @@ importers: lucide-react: specifier: ^0.447.0 version: 0.447.0(react@18.3.1) - middleware@latest: - specifier: link:zustand/middleware@latest - version: link:zustand/middleware@latest nanoid: specifier: ^5.0.7 version: 5.0.7 @@ -586,8 +586,8 @@ importers: specifier: ^3.23.8 version: 3.23.8 zustand: - specifier: 5.0.0-rc.2 - version: 5.0.0-rc.2(@types/react@18.3.11)(immer@10.1.1)(react@18.3.1)(use-sync-external-store@1.2.2(react@18.3.1)) + specifier: 5.0.0 + version: 5.0.0(@types/react@18.3.11)(immer@10.1.1)(react@18.3.1)(use-sync-external-store@1.2.2(react@18.3.1)) zxcvbn: specifier: ^4.4.2 version: 4.4.2 @@ -1556,17 +1556,17 @@ packages: cpu: [x64] os: [win32] - '@blocknote/core@0.15.11': - resolution: {integrity: sha512-IVOypyKkqZxONLVCc2Q2+NBvFCzmh57IrdZDOhWtIroq2qfAgvPp+aVoFlU2EZK9scJwFMqzarHHHpBxl+5f1A==} + '@blocknote/core@0.16.0': + resolution: {integrity: sha512-egX+GjlAB8r/zaox278zNTTUMNVRHVQ2qVlPHQZgGOXSDq2Z+Lm7i4xKYMz/UT/IdrL7iGxnHrAsbc0H/kqc9A==} - '@blocknote/react@0.15.11': - resolution: {integrity: sha512-HGNvVW80pZd+qhHhgYM9O/qGRrHRLxr4pAju78tK/treUzX8qV+uOV9IPMBfhASzizLfsLCWJC95XGCEKBJrFQ==} + '@blocknote/react@0.16.0': + resolution: {integrity: sha512-vEwAp4z1FBqcH75OEbEW/yd4nj8XcSKAzCElV7aL6nVhPiKgYzrzG/WVckTq1h9lMaGeAuYqLErww4IIsbiawg==} peerDependencies: react: ^18 react-dom: ^18 - '@blocknote/shadcn@0.15.11': - resolution: {integrity: sha512-VuaRMIWWU2cYjo+14exwKb0Qz01scsSxL2tS4Eo2pxHoFzDdxlCteCrWtAaldpZAnJJDkM3X279U3BICmxDLPA==} + '@blocknote/shadcn@0.16.0': + resolution: {integrity: sha512-4mqvtvZgFBKyFtYrgWWokP+SbkRjaZJsEMsxKlnVb+XlYkT+KKzgvYjXQioySwcNOMhgAjOWK2bleZ7CfPT1Ww==} peerDependencies: react: ^18 react-dom: ^18 @@ -2584,8 +2584,8 @@ packages: react: '>=16.8.0' react-dom: '>=16.8.0' - '@floating-ui/react@0.26.24': - resolution: {integrity: sha512-2ly0pCkZIGEQUq5H8bBK0XJmc1xIK/RM3tvVzY3GBER7IOD1UgmC2Y2tjj4AuS+TC+vTE1KJv2053290jua0Sw==} + '@floating-ui/react@0.26.25': + resolution: {integrity: sha512-hZOmgN0NTOzOuZxI1oIrDu3Gcl8WViIkvPMpB4xdd4QD6xAMtwgwr3VPoiyH/bLtRcS1cDnhxLSD1NsMJmwh/A==} peerDependencies: react: '>=16.8.0' react-dom: '>=16.8.0' @@ -4836,8 +4836,8 @@ packages: peerDependencies: tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20' - '@tanstack/history@1.58.15': - resolution: {integrity: sha512-M36Ke2Q2v8Iv4Cx0xw04iVkixuOligiFLOifH35DqGnzXe9PAtTHIooieQowqYkAjC09KuLo5j6sgvwKTZ+U5Q==} + '@tanstack/history@1.61.1': + resolution: {integrity: sha512-2CqERleeqO3hkhJmyJm37tiL3LYgeOpmo8szqdjgtnnG0z7ZpvzkZz6HkfOr9Ca/ha7mhAiouSvLYuLkM37AMg==} engines: {node: '>=12'} '@tanstack/query-core@5.59.0': @@ -4869,8 +4869,8 @@ packages: peerDependencies: react: ^18 || ^19 - '@tanstack/react-router@1.58.17': - resolution: {integrity: sha512-t9QDunhRywq043ArnwWo6OgJh99jHkTJRQS+Yl25CLalfbi7qyNvTXSQuAzwIKz0GK0JjkD4eGf7ZtTayaOZ1w==} + '@tanstack/react-router@1.63.2': + resolution: {integrity: sha512-6Bla8LK4cu4L0atBZNNIUhQpiCgXNIa2XnrybHVbqTipWlfRJ0I71pXWuSVqGP487wNGZgVG5ITZFLR+y+NhKg==} engines: {node: '>=12'} peerDependencies: '@tanstack/router-generator': 1.58.12 @@ -4886,11 +4886,11 @@ packages: react: ^17.0.0 || ^18.0.0 react-dom: ^17.0.0 || ^18.0.0 - '@tanstack/router-devtools@1.58.17': - resolution: {integrity: sha512-VdvVldHigm9E49Q0FOSz2mLqf67SY7F8TF3TAbLlr06iI99QBMcnEU7u+pGsO/wlX8hSirliX1Lbj+9zDY57Ow==} + '@tanstack/router-devtools@1.63.2': + resolution: {integrity: sha512-qNtjl6fbgjybVyvxPNHgx91F2JqiWLIuIoCTLVN6yX/WRGH6SfWT4SxEfWGYieHTT9YX8fsFo44F15WeStFHVg==} engines: {node: '>=12'} peerDependencies: - '@tanstack/react-router': ^1.58.17 + '@tanstack/react-router': ^1.63.2 react: '>=18' react-dom: '>=18' @@ -9352,6 +9352,9 @@ packages: tailwind-merge@2.5.3: resolution: {integrity: sha512-d9ZolCAIzom1nf/5p4LdD5zvjmgSxY0BGgdSvmXIoMYAiPdAW/dSpP7joCDYFY7r/HkEa2qmPtkgsu0xjQeQtw==} + tailwind-merge@2.5.4: + resolution: {integrity: sha512-0q8cfZHMu9nuYP/b5Shb7Y7Sh1B7Nnl5GqNr1U+n2p6+mybvRtayrQ+0042Z5byvTA8ihjlP8Odo8/VnHbZu4Q==} + tailwindcss-animate@1.0.7: resolution: {integrity: sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==} peerDependencies: @@ -10119,8 +10122,8 @@ packages: zod@3.23.8: resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==} - zustand@5.0.0-rc.2: - resolution: {integrity: sha512-o2Nwuvnk8vQBX7CcHL8WfFkZNJdxB/VKeWw0tNglw8p4cypsZ3tRT7rTRTDNeUPFS0qaMBRSKe+fVwL5xpcE3A==} + zustand@5.0.0: + resolution: {integrity: sha512-LE+VcmbartOPM+auOjCCLQOsQ05zUTp8RkgwRzefUk+2jISdMMFnxvyTjA4YNWr5ZGXYbVsEMZosttuxUBkojQ==} engines: {node: '>=12.20.0'} peerDependencies: '@types/react': '>=18.0.0' @@ -11513,7 +11516,7 @@ snapshots: '@biomejs/cli-win32-x64@1.9.3': optional: true - '@blocknote/core@0.15.11': + '@blocknote/core@0.16.0': dependencies: '@emoji-mart/data': 1.2.1 '@tiptap/core': 2.8.0(@tiptap/pm@2.8.0) @@ -11559,10 +11562,10 @@ snapshots: transitivePeerDependencies: - supports-color - '@blocknote/react@0.15.11(@tiptap/pm@2.8.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@blocknote/react@0.16.0(@tiptap/pm@2.8.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@blocknote/core': 0.15.11 - '@floating-ui/react': 0.26.24(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@blocknote/core': 0.16.0 + '@floating-ui/react': 0.26.25(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@tiptap/core': 2.8.0(@tiptap/pm@2.8.0) '@tiptap/react': 2.8.0(@tiptap/core@2.8.0(@tiptap/pm@2.8.0))(@tiptap/pm@2.8.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) lodash.merge: 4.6.2 @@ -11573,10 +11576,10 @@ snapshots: - '@tiptap/pm' - supports-color - '@blocknote/shadcn@0.15.11(@tiptap/pm@2.8.0)(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(ts-node@10.9.2(@swc/core@1.7.26)(@types/node@22.7.4)(typescript@5.6.2))': + '@blocknote/shadcn@0.16.0(@tiptap/pm@2.8.0)(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(ts-node@10.9.2(@swc/core@1.7.26)(@types/node@22.7.4)(typescript@5.6.2))': dependencies: - '@blocknote/core': 0.15.11 - '@blocknote/react': 0.15.11(@tiptap/pm@2.8.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@blocknote/core': 0.16.0 + '@blocknote/react': 0.16.0(@tiptap/pm@2.8.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@hookform/resolvers': 3.9.0(react-hook-form@7.53.0(react@18.3.1)) '@radix-ui/react-dropdown-menu': 2.1.2(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-label': 2.1.0(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -11594,7 +11597,7 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) react-hook-form: 7.53.0(react@18.3.1) - tailwind-merge: 2.5.2 + tailwind-merge: 2.5.3 tailwindcss: 3.4.13(ts-node@10.9.2(@swc/core@1.7.26)(@types/node@22.7.4)(typescript@5.6.2)) tailwindcss-animate: 1.0.7(tailwindcss@3.4.13(ts-node@10.9.2(@swc/core@1.7.26)(@types/node@22.7.4)(typescript@5.6.2))) zod: 3.23.8 @@ -12310,7 +12313,7 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - '@floating-ui/react@0.26.24(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@floating-ui/react@0.26.25(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@floating-ui/react-dom': 2.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@floating-ui/utils': 0.2.8 @@ -15065,7 +15068,7 @@ snapshots: postcss-selector-parser: 6.0.10 tailwindcss: 3.4.13(ts-node@10.9.2(@swc/core@1.7.26)(@types/node@22.7.4)(typescript@5.6.2)) - '@tanstack/history@1.58.15': {} + '@tanstack/history@1.61.1': {} '@tanstack/query-core@5.59.0': {} @@ -15097,9 +15100,9 @@ snapshots: '@tanstack/query-core': 5.59.0 react: 18.3.1 - '@tanstack/react-router@1.58.17(@tanstack/router-generator@1.58.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@tanstack/react-router@1.63.2(@tanstack/router-generator@1.58.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@tanstack/history': 1.58.15 + '@tanstack/history': 1.61.1 '@tanstack/react-store': 0.5.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) @@ -15115,9 +15118,9 @@ snapshots: react-dom: 18.3.1(react@18.3.1) use-sync-external-store: 1.2.2(react@18.3.1) - '@tanstack/router-devtools@1.58.17(@tanstack/react-router@1.58.17(@tanstack/router-generator@1.58.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(csstype@3.1.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@tanstack/router-devtools@1.63.2(@tanstack/react-router@1.63.2(@tanstack/router-generator@1.58.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(csstype@3.1.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@tanstack/react-router': 1.58.17(@tanstack/router-generator@1.58.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@tanstack/react-router': 1.63.2(@tanstack/router-generator@1.58.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) clsx: 2.1.1 goober: 2.1.14(csstype@3.1.3) react: 18.3.1 @@ -16679,7 +16682,7 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) react-easy-sort: 1.6.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - tailwind-merge: 2.5.3 + tailwind-merge: 2.5.4 tsup: 6.7.0(@swc/core@1.7.26)(postcss@8.4.47)(ts-node@10.9.2(@swc/core@1.7.26)(@types/node@22.7.4)(typescript@5.6.2))(typescript@5.6.2) transitivePeerDependencies: - '@swc/core' @@ -20511,6 +20514,8 @@ snapshots: tailwind-merge@2.5.3: {} + tailwind-merge@2.5.4: {} + tailwindcss-animate@1.0.7(tailwindcss@3.4.13(ts-node@10.9.2(@swc/core@1.7.26)(@types/node@22.7.4)(typescript@5.6.2))): dependencies: tailwindcss: 3.4.13(ts-node@10.9.2(@swc/core@1.7.26)(@types/node@22.7.4)(typescript@5.6.2)) @@ -21463,7 +21468,7 @@ snapshots: zod@3.23.8: {} - zustand@5.0.0-rc.2(@types/react@18.3.11)(immer@10.1.1)(react@18.3.1)(use-sync-external-store@1.2.2(react@18.3.1)): + zustand@5.0.0(@types/react@18.3.11)(immer@10.1.1)(react@18.3.1)(use-sync-external-store@1.2.2(react@18.3.1)): optionalDependencies: '@types/react': 18.3.11 immer: 10.1.1