feat: migrate auth from supabase to better auth (#596)

* feat: add README for @cio/db package and remove existingAccounts.json

* load session, load profile and organization, add create profile hook

* chore: cache session request

The trick was passing all the cookies to the backend. Previously I was only passing the better-auth.session_token and not better-auth.session_data. Read about the difference here https://www.better-auth.com/docs/concepts/cookies#custom-cookies

* feat: login, logout, onboarding, verify email, reset password.

* feat: complete change email and email verification

* feat: standardise how we make rpc calls and create org endpoints

* fix ui errors

* fix: handle potential undefined customerId and standardize avatar URL property

* refactor: standardize custom domain handling and update telegram chat ID references

* chore: add tailwind prefix to @cio/ui

this fixed the media query issues because  @cio/ui classes were overriding this dashboard tailwind build

* chore: clean up org sidebar

* chore: persist mode state

* feat: implement order breadcrumbs

* chore: upgrade to better auth 1.4

* chore: address common auth flow bugs and setup app for others to setup locally.
This commit is contained in:
Rotimi Best
2025-11-24 01:00:50 +01:00
committed by GitHub
parent 24741644d6
commit a3924100dd
567 changed files with 22364 additions and 13437 deletions
@@ -1,5 +1,5 @@
<script lang="ts">
import { page } from '$app/stores';
import { page } from '$app/state';
import Folder from '$lib/components/ui/folder/folder.svelte';
import type { Section } from '$lib/utils/types/course';
@@ -10,7 +10,7 @@
let { sections, onFileClick }: Props = $props();
let [, courseSlug, sectionSlug, lessonSlug] = $derived($page.url.pathname.split('/').slice(1));
let [, courseSlug, sectionSlug, lessonSlug] = $derived(page.url.pathname.split('/').slice(1));
let root = $derived(
sections.map((section) => ({
@@ -1,6 +1,6 @@
<script lang="ts">
import { fade } from 'svelte/transition';
import { page } from '$app/stores';
import { page } from '$app/state';
interface Props {
children: import('svelte').Snippet;
@@ -9,10 +9,10 @@
let { children }: Props = $props();
</script>
{#if $page.url.pathname.includes('/course/')}
{#if page.url.pathname.includes('/course/')}
{@render children?.()}
{:else}
{#key $page.url.pathname}
{#key page.url.pathname}
<div class="transition" in:fade>
{@render children?.()}
</div>
@@ -1,6 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { page } from '$app/state';
import { homePage, sharedPage } from '$lib/utils/stores/pages';
import { courses } from '$lib/utils/stores/course';
import { getPageSection } from '$lib/utils/helpers/page';
@@ -43,13 +43,13 @@
{#if $homePage.id}
<main class="font-{import.meta.env.VITE_TEMPLATE}">
<Transition>
{#if !$page.url.pathname.includes('course/')}
{#if !page.url.pathname.includes('course/')}
<components.navigation />
{/if}
{@render children?.()}
{#if !$page.url.pathname.includes('course/')}
{#if !page.url.pathname.includes('course/')}
<components.footer />
{/if}
</Transition>
@@ -1,16 +1,13 @@
<script lang="ts">
import { blur } from 'svelte/transition';
import { page } from '$app/stores';
import { page } from '$app/state';
let { data } = $props();
</script>
{#key $page.url.pathname}
<article
class="max-w-4xl mx-auto p-4 md:p-10 md:border border-[#D0D1C9] rounded-lg min-h-[90vh]"
in:blur
>
<p class="font-semibold text-2xl text-center mb-4 capitalize">
{#key page.url.pathname}
<article class="mx-auto min-h-[90vh] max-w-4xl rounded-lg border-[#D0D1C9] p-4 md:border md:p-10" in:blur>
<p class="mb-4 text-center text-2xl font-semibold capitalize">
{@html data.meta.title}
</p>
<div class="prose mt-2">
+1
View File
@@ -1,5 +1,6 @@
{
"compilerOptions": {
"composite": true,
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
+8
View File
@@ -0,0 +1,8 @@
dist
node_modules
*.log
.env
.DS_Store
tsconfig.tsbuildinfo
+35
View File
@@ -0,0 +1,35 @@
# @cio/db
This package handles everything related to the database, schema, migrations and connection.
## Stack
1. Postgres - We use postgres for the database
2. Drizzle ORM - For managing and querying the database
3. Better Auth - For managing the auth tables (users, account, session).
## Workflow
We used Supabase in the past for managing the supabase but have now moved on to use postgres directly and auth managed by our backend. This means the workflow is a mix of the Database First approach (Option 1) and the Codebase First approach (Option 2). Learn more about each approach [from the drizzle migration docs](https://orm.drizzle.team/docs/migrations).
Here is how I do it:
### 1. Local DB to Drizzle Schema TS file
I have a postgres tool where I can make changes to the db as I want. I am not so familiar with drizzle that much so I am sticking with this for now.
```bash
pnpm db pull # this does sql -> drizzle ts code
```
### 2. Drizzle Schema file to Prod SQL
Now when I am ready to deploy the feature, I can use [option 2](https://orm.drizzle.team/docs/migrations) which is makes the typescript schema the source of truth. This command will only run on CI on every merge on a condition that there was a change to the schema file `packages/db/src/schema.ts`
```bash
pnpm db push # this does drizzle ts code to prod db
```
## OR
I can make changes directly to the schema file and run `pnpm db push` to push the changes to the source db.
-1
View File
@@ -1 +0,0 @@
[]
+18 -6
View File
@@ -1,23 +1,35 @@
{
"name": "@cio/db",
"version": "1.0.0",
"version": "0.0.1",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": [
"dist"
],
"scripts": {
"build": "rm -rf dist tsconfig.tsbuildinfo && tsc && tsc-alias",
"dev": "tsc --watch",
"db": "drizzle-kit",
"move-users": "tsx src/scripts/supabase-to-betterauth.ts",
"pull": "drizzle-kit pull --config drizzle.config.ts"
"pull": "drizzle-kit pull --config drizzle.config.ts",
"generate-types": "tsx scripts/generate-types.ts && pnpm format",
"seed": "tsx src/scripts/seed.ts",
"format": "prettier . --write"
},
"dependencies": {
"@cio/email": "workspace:*",
"@cio/tsconfig": "workspace:*",
"better-auth": "^1.3.28",
"@cio/utils": "workspace:*",
"better-auth": "^1.4.1",
"drizzle-kit": "^0.31.5",
"drizzle-orm": "^0.44.6",
"pg": "^8.16.3"
"drizzle-orm": "^0.44.7",
"postgres": "^3.4.7"
},
"devDependencies": {
"@types/node": "^24.8.1",
"@types/pg": "^8.15.5",
"dotenv": "^17.2.3",
"tsc-alias": "^1.8.16",
"tsx": "^4.20.3",
"typescript": "^5.9.3"
}
+127
View File
@@ -0,0 +1,127 @@
#!/usr/bin/env tsx
import { readFileSync, writeFileSync } from 'fs';
import { join } from 'path';
// Output to packages/db/src/types.ts
const outputPath = join(process.cwd(), 'src/types.ts');
// Get all table exports from the schema file
async function getTableExports() {
try {
const tables: string[] = [];
// Read the main schema file in the same package
const schemaPath = join(process.cwd(), 'src/schema.ts');
const schemaContent = readFileSync(schemaPath, 'utf-8');
// Look for table exports using regex
const tableExportRegex = /export\s+const\s+(\w+)\s*=\s*pgTable/g;
let match;
while ((match = tableExportRegex.exec(schemaContent)) !== null) {
tables.push(match[1]);
}
console.log(`🔍 Detected ${tables.length} tables from schema files`);
return tables;
} catch (error) {
console.error('❌ Could not read schema files:', error);
process.exit(1);
}
}
// Get all enum exports from the schema file
async function getEnumExports() {
try {
// Read the schema file directly and parse exports
const schemaPath = join(process.cwd(), 'src/schema.ts');
const schemaContent = readFileSync(schemaPath, 'utf-8');
// Look for enum exports using regex
const enumExportRegex = /export\s+const\s+(\w+)\s*=\s*pgEnum/g;
const enums: string[] = [];
let match;
while ((match = enumExportRegex.exec(schemaContent)) !== null) {
enums.push(match[1]);
}
console.log(`🏷️ Detected ${enums.length} enums from schema file`);
return enums;
} catch (error) {
console.error('❌ Could not read schema file:', error);
process.exit(1);
}
}
// Convert camelCase to SCREAMING_SNAKE_CASE
function toScreamingSnakeCase(str: string): string {
return str
.replace(/([A-Z])/g, '_$1')
.toUpperCase()
.replace(/^_/, '');
}
// Generate the types file content
function generateTypesContent(tableNames: string[], enumNames: string[]) {
const imports = `// Database types generated from schema using Drizzle's built-in type inference
// This file contains all TypeScript types for the database
// Naming convention: T prefix for types
import * as schema from './schema';
`;
const typeDefinitions = tableNames
.map((tableName) => {
const pascalCaseName = tableName.charAt(0).toUpperCase() + tableName.slice(1);
return `export type T${pascalCaseName} = typeof schema.${tableName}.$inferSelect;
export type TNew${pascalCaseName} = typeof schema.${tableName}.$inferInsert;`;
})
.join('\n\n');
const enumDefinitions =
enumNames.length > 0
? enumNames
.map((enumName) => {
const pascalCaseName = enumName.charAt(0).toUpperCase() + enumName.slice(1);
return `export type T${pascalCaseName} = (typeof schema.${enumName}.enumValues)[number];`;
})
.join('\n\n')
: '';
const enumSection = enumNames.length > 0 ? `\n\n${enumDefinitions}` : '';
return imports + typeDefinitions + enumSection;
}
// Main function
async function main() {
try {
console.log('🔍 Analyzing database schema...');
const tableNames = await getTableExports();
const enumNames = await getEnumExports();
console.log(`📊 Found ${tableNames.length} tables:`, tableNames.slice(0, 10).join(', '), '...');
console.log(`🏷️ Found ${enumNames.length} enums:`, enumNames.join(', '));
console.log('🏗️ Generating types...');
const content = generateTypesContent(tableNames, enumNames);
writeFileSync(outputPath, content, 'utf-8');
console.log('✅ Types generated successfully!');
console.log(`📝 Updated: ${outputPath}`);
console.log('');
console.log('💡 Types are now available from @cio/db/types');
} catch (error) {
console.error('❌ Error generating types:', error);
process.exit(1);
}
}
main();
-67
View File
@@ -1,67 +0,0 @@
import { pgTable, text, timestamp, boolean } from "drizzle-orm/pg-core";
export const user = pgTable("user", {
id: text("id").primaryKey(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
emailVerified: boolean("email_verified").default(false).notNull(),
image: text("image"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
role: text("role"),
banned: boolean("banned").default(false),
banReason: text("ban_reason"),
banExpires: timestamp("ban_expires"),
isAnonymous: boolean("is_anonymous"),
});
export const session = pgTable("session", {
id: text("id").primaryKey(),
expiresAt: timestamp("expires_at").notNull(),
token: text("token").notNull().unique(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
ipAddress: text("ip_address"),
userAgent: text("user_agent"),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
impersonatedBy: text("impersonated_by"),
});
export const account = pgTable("account", {
id: text("id").primaryKey(),
accountId: text("account_id").notNull(),
providerId: text("provider_id").notNull(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
accessToken: text("access_token"),
refreshToken: text("refresh_token"),
idToken: text("id_token"),
accessTokenExpiresAt: timestamp("access_token_expires_at"),
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
scope: text("scope"),
password: text("password"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
});
export const verification = pgTable("verification", {
id: text("id").primaryKey(),
identifier: text("identifier").notNull(),
value: text("value").notNull(),
expiresAt: timestamp("expires_at").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
});
+57 -6
View File
@@ -1,24 +1,75 @@
import * as CONSTANTS from './constants';
import * as schema from '@db/schema';
import { admin, anonymous } from 'better-auth/plugins';
import { sendChangeEmailConfirmation, sendVerificationEmail } from './auth/email-verification';
import { betterAuth } from 'better-auth';
import { betterAuth } from 'better-auth/minimal';
import { createProfileHook } from './auth/hooks/create-profile';
import { db } from '@db/drizzle';
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { config as emailAndPassword } from './auth/email-password';
import { syncUserWithProfile } from './auth/hooks/sync-user';
export const auth = betterAuth({
baseURL: CONSTANTS.BASE_URL,
database: drizzleAdapter(db, {
provider: 'pg',
schema
schema,
debugLogs: true
}),
emailVerification: {},
emailAndPassword: {
enabled: true
emailAndPassword: emailAndPassword,
user: {
changeEmail: {
enabled: true,
sendChangeEmailConfirmation
}
},
emailVerification: {
enabled: true,
sendVerificationEmail
},
socialProviders: {
google: {
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
accessType: 'offline',
prompt: 'select_account consent'
}
},
trustedOrigins: CONSTANTS.TRUSTED_ORIGINS,
advanced: {
cookiePrefix: 'classroomio',
crossSubDomainCookies: {
enabled: true
},
database: {
generateId: false
}
},
account: {
storeAccountCookie: true
},
session: {
expiresIn: 60 * 60 * 24 * 7, // 7 days
updateAge: 60 * 60 * 24, // 1 day (every 1 day the session expiration is updated)
cookieCache: {
enabled: true,
maxAge: 60 * 60 // 1 hour
}
},
databaseHooks: {
user: {
create: {
after: async (user) => {
await createProfileHook(user);
}
},
update: {
after: async (user) => {
await syncUserWithProfile(user);
}
}
}
},
plugins: [admin(), anonymous()]
+68
View File
@@ -0,0 +1,68 @@
import * as schema from '@db/schema';
import bcrypt from 'bcrypt';
import { db, eq } from '@db/drizzle';
import { type BetterAuthOptions } from 'better-auth';
import { sendEmail } from '@cio/email';
/**
* Configuration for the email and password options
* @see https://www.better-auth.com/docs/authentication/email-password
*/
export const config: BetterAuthOptions['emailAndPassword'] = {
enabled: true,
password: {
hash: async (password) => {
return await bcrypt.hash(password, 10);
},
verify: async ({ hash, password }) => {
return await bcrypt.compare(password, hash);
}
},
sendResetPassword: sendResetPassword,
onPasswordReset: onPasswordReset
};
/**
* Types for the email and password options
*/
type ResetPasswordOptions = Parameters<
NonNullable<NonNullable<BetterAuthOptions['emailAndPassword']>['sendResetPassword']>
>[0];
type OnPasswordResetEmail = Parameters<
NonNullable<NonNullable<BetterAuthOptions['emailAndPassword']>['onPasswordReset']>
>[0];
async function sendResetPassword(options: ResetPasswordOptions) {
const { user, url } = options;
const [profile] = await db.select().from(schema.profile).where(eq(schema.profile.id, user.id)).limit(1);
if (!profile) {
throw new Error('Profile not found');
}
await sendEmail('forgotPassword', {
to: user.email,
fields: {
name: profile.fullname,
email: user.email,
link: url
}
});
}
async function onPasswordReset(options: OnPasswordResetEmail) {
const { user } = options;
const [profile] = await db.select().from(schema.profile).where(eq(schema.profile.id, user.id)).limit(1);
if (!profile) {
throw new Error('Profile not found');
}
await sendEmail('onPasswordReset', {
to: user.email,
fields: {
name: profile.fullname
}
});
}
@@ -0,0 +1,64 @@
import { type BetterAuthOptions } from 'better-auth';
import { sendEmail } from '@cio/email';
type EmailVerificationOptions = Parameters<
NonNullable<NonNullable<BetterAuthOptions['emailVerification']>['sendVerificationEmail']>
>[0];
type ChangeEmailConfirmationOptions = Parameters<
NonNullable<NonNullable<NonNullable<BetterAuthOptions['user']>['changeEmail']>['sendChangeEmailConfirmation']>
>[0];
/**
* Email verification flow.
*
* 1. user says I want to change my email from [email protected] to [email protected]
* 2. we send an email to [email protected] asking user to verify if they really want to change their email to [email protected] `sendChangeEmailConfirmation()`. [better-auth].
* 3. user clicks the link in the email
* 4. we update the email in the database. [better-auth].
* 5. we send a confirmation email to [email protected] `sendVerificationEmail()`. [dashboard].
* 6. user clicks the link in the email
* 7. we mark [email protected] as verified. [better-auth].
*/
/**
* Sends a verification email to the user when they sign up or change their email address.
* @param options - The options for sending the verification email.
* @returns The verification email.
*/
export const sendVerificationEmail = async (options: EmailVerificationOptions) => {
const { user, url } = options;
console.log('\nsendVerificationEmail');
// The purpose of this is to prevent verification email trigger from better-auth because we already have a flow in the UI for triggering this email.
if (!url.includes('trigger=app') && !url.includes('trigger%3Dapp')) {
return;
}
await sendEmail('verifyEmail', {
to: user.email,
fields: {
link: url,
userName: user.name || undefined
}
});
};
/**
* Sends a confirmation email to the user when they change their email address.
* @param options - The options for sending the change email confirmation.
* @returns The confirmation email.
*/
export const sendChangeEmailConfirmation = async (options: ChangeEmailConfirmationOptions) => {
const { user, newEmail, url } = options;
console.log('\nsendChangeEmailConfirmation');
await sendEmail('verifyEmail', {
to: user.email,
fields: {
link: url,
newEmail,
userName: user.name || undefined
}
});
};
@@ -0,0 +1,51 @@
import * as schema from '@db/schema';
import { and, eq } from 'drizzle-orm';
import { User } from 'better-auth';
import { db } from '@db/drizzle';
/**
* This hook runs after user creation (email/password or OAuth signup).
*
* We use the profile table for everything related to the user because we heavily used supabase and supabase didn't allow you add fields to the auth.users table.
*
*/
export const createProfileHook = async (user: User) => {
console.debug('createProfileHook', user);
const existingProfile = await db.select().from(schema.profile).where(eq(schema.profile.id, user.id)).limit(1);
console.debug('existingProfile', existingProfile);
if (existingProfile.length) {
return;
}
const googleAccount = await db
.select()
.from(schema.account)
.where(and(eq(schema.account.userId, user.id), eq(schema.account.providerId, 'google')))
.limit(1);
const isEmailVerified = user.emailVerified || googleAccount.length > 0;
const verifiedAt = isEmailVerified ? new Date().toISOString() : undefined;
// Extract username from email (part before @)
const emailMatch = user.email?.match(/^([^@]+)@/);
const emailPrefix = emailMatch ? emailMatch[1] : 'user';
const username = `${emailPrefix}${new Date().getTime()}`;
const fullname = emailPrefix;
try {
const newProfile = await db.insert(schema.profile).values({
id: user.id,
username,
fullname,
email: user.email || undefined,
isEmailVerified,
verifiedAt
});
console.debug('newProfile', newProfile);
} catch (error) {
console.error('Error creating profile for user:', error);
}
};
+26
View File
@@ -0,0 +1,26 @@
import { createAuthMiddleware } from 'better-auth/api';
export type AuthMiddlewareContext = Parameters<Parameters<typeof createAuthMiddleware>[0]>[0];
/**
* Array of all hooks to run after better-auth requests
*
* Add new hooks here to register them
*/
const afterHooks: Array<(context: AuthMiddlewareContext) => Promise<unknown>> = [
// add hooks here
];
/**
* Combines all after hooks into a single function
* This function will be called by better-auth after each request
*/
export const combineAfterHooks = async (context: AuthMiddlewareContext) => {
for (const hook of afterHooks) {
try {
await hook(context);
} catch (error) {
console.error('Error executing hook:', error);
}
}
};
+26
View File
@@ -0,0 +1,26 @@
import * as schema from '@db/schema';
import { User } from 'better-auth';
import { db } from '@db/drizzle';
import { eq } from 'drizzle-orm';
export const syncUserWithProfile = async (user: User) => {
console.debug('syncUserWithProfile', user);
if (!user?.id) {
return;
}
try {
// update profile and mark as verified
await db
.update(schema.profile)
.set({
email: user.email,
isEmailVerified: user.emailVerified
})
.where(eq(schema.profile.id, user.id));
} catch (error) {
console.error('Error marking profile as verified:', error);
}
};
+7
View File
@@ -0,0 +1,7 @@
const { TRUSTED_ORIGINS: TRUSTED_ORIGINS_STRING, SERVER_URL } = process.env;
export const TRUSTED_ORIGINS = TRUSTED_ORIGINS_STRING
? TRUSTED_ORIGINS_STRING.split(',')
: ['http://localhost:5173', 'http://*.classroomio.*'];
export const BASE_URL = SERVER_URL || 'http://localhost:3002';
+4 -6
View File
@@ -1,16 +1,14 @@
import 'dotenv/config';
import * as schema from './schema';
import { SQL, sql } from 'drizzle-orm';
import { drizzle } from 'drizzle-orm/node-postgres';
import { drizzle } from 'drizzle-orm/postgres-js';
// import * as schema from './schema';
const connectionString = process.env.DATABASE_URL ?? process.env.PRIVATE_DATABASE_URL ?? '';
export const db = drizzle(connectionString, {
schema
});
export const db = drizzle(connectionString);
export * from 'drizzle-orm';
export * from './schema';
+6
View File
@@ -0,0 +1,6 @@
// Main entry point for @cio/db package
export * from './drizzle';
export * from './schema';
export * from './relations';
export * from './auth';
export * from './types';
@@ -0,0 +1,22 @@
CREATE TABLE "verification" (
"id" uuid PRIMARY KEY DEFAULT uuid_generate_v4() NOT NULL,
"identifier" text NOT NULL,
"value" text NOT NULL,
"expires_at" timestamp NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "account" DROP CONSTRAINT "account_user_id_user_id_fk";--> statement-breakpoint
ALTER TABLE "account" ALTER COLUMN "id" SET DEFAULT uuid_generate_v4();--> statement-breakpoint
ALTER TABLE "account" ALTER COLUMN "user_id" SET DATA TYPE uuid USING user_id::uuid;--> statement-breakpoint
ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user" ALTER COLUMN "id" SET DATA TYPE uuid USING id::uuid;--> statement-breakpoint
ALTER TABLE "user" ALTER COLUMN "id" SET DEFAULT uuid_generate_v4();--> statement-breakpoint
ALTER TABLE "session" DROP CONSTRAINT "session_user_id_user_id_fk";--> statement-breakpoint
ALTER TABLE "session" ALTER COLUMN "id" SET DATA TYPE uuid USING id::uuid;--> statement-breakpoint
ALTER TABLE "session" ALTER COLUMN "id" SET DEFAULT uuid_generate_v4();--> statement-breakpoint
ALTER TABLE "session" ALTER COLUMN "user_id" SET DATA TYPE uuid USING user_id::uuid;--> statement-breakpoint
ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -15,6 +15,13 @@
"when": 1760912065001,
"tag": "0001_tranquil_thunderbolts",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1762733677656,
"tag": "0002_many_revanche",
"breakpoints": true
}
]
}
}
+21
View File
@@ -0,0 +1,21 @@
import { customSession } from 'better-auth/plugins';
import { getProfileById } from './profile';
import { getOrganizationByProfileId } from '../organization';
export const getCustomSession = () =>
customSession(async ({ user, session }) => {
const profile = await getProfileById(user.id);
const organizations = await getOrganizationByProfileId(user.id);
return {
user: {
id: user.id,
email: user.email,
createdAt: user.createdAt,
updatedAt: user.updatedAt
},
profile,
session,
organizations
};
});
+2
View File
@@ -0,0 +1,2 @@
export * from './auth';
export * from './profile';
+17
View File
@@ -0,0 +1,17 @@
import * as schema from '@db/schema';
import type { TProfile } from '@db/types';
import { db } from '@db/drizzle';
import { eq } from 'drizzle-orm';
export const getProfileById = async (id: string) => {
const [profile] = await db.select().from(schema.profile).where(eq(schema.profile.id, id)).limit(1);
return profile;
};
export const updateProfile = async (id: string, data: Partial<Omit<TProfile, 'id' | 'createdAt' | 'updatedAt'>>) => {
const [updatedProfile] = await db.update(schema.profile).set(data).where(eq(schema.profile.id, id)).returning();
return updatedProfile;
};
+50
View File
@@ -0,0 +1,50 @@
import * as schema from '@db/schema';
import { and, eq, sql } from 'drizzle-orm';
import { db } from '@db/drizzle';
/**
* Gets courses by organization siteName
* @param siteName Organization site name
* @returns Array of courses with lesson counts and organization info
*/
export const getCoursesBySiteName = async (siteName: string) => {
const result = await db
.select({
course: schema.course,
organization: {
id: schema.organization.id,
name: schema.organization.name,
siteName: schema.organization.siteName,
avatarUrl: schema.organization.avatarUrl
},
lessonCount: sql<number>`COUNT(${schema.lesson.id})`.as('lesson_count')
})
.from(schema.course)
.innerJoin(schema.group, eq(schema.course.groupId, schema.group.id))
.innerJoin(schema.organization, eq(schema.group.organizationId, schema.organization.id))
.leftJoin(schema.lesson, eq(schema.course.id, schema.lesson.courseId))
.where(
and(
eq(schema.organization.siteName, siteName),
eq(schema.course.status, 'ACTIVE'),
eq(schema.course.isPublished, true)
)
)
.groupBy(
schema.course.id,
schema.organization.id,
schema.organization.name,
schema.organization.siteName,
schema.organization.avatarUrl
);
return result.map((row) => ({
...row.course,
lessons: [{ count: Number(row.lessonCount) }],
group: {
organization: row.organization
}
}));
};
+1
View File
@@ -0,0 +1 @@
export * from './course';
+3
View File
@@ -0,0 +1,3 @@
export * from './organization';
export * from './auth';
export * from './course';
@@ -0,0 +1 @@
export * from './organization';
@@ -0,0 +1,307 @@
import * as schema from '@db/schema';
import type { OrganizationPlan, OrganizationWithMemberAndPlans, OrganizationWithPlans } from './types';
import type {
TNewOrganization,
TNewOrganizationPlan,
TNewOrganizationmember,
TOrganization,
TOrganizationPlan
} from '@db/types';
import { and, eq, sql } from 'drizzle-orm';
import { ROLE } from '@cio/utils/constants';
import { db } from '@db/drizzle';
export const getOrganizationByProfileId = async (profileId: string): Promise<OrganizationWithMemberAndPlans[]> => {
const result = await db
.select({
organization: schema.organization,
roleId: schema.organizationmember.roleId,
plan: {
planName: schema.organizationPlan.planName,
isActive: schema.organizationPlan.isActive,
provider: schema.organizationPlan.provider,
subscriptionId: schema.organizationPlan.subscriptionId,
customerId: sql`organization_plan.payload->>'customerId'`.as('customerId')
}
})
.from(schema.organization)
.leftJoin(schema.organizationmember, eq(schema.organization.id, schema.organizationmember.organizationId))
.leftJoin(schema.organizationPlan, eq(schema.organization.id, schema.organizationPlan.orgId))
.where(eq(schema.organizationmember.profileId, profileId));
// Group by organization and collect plans into an array
const organizationMap = new Map<
string,
{
organization: typeof schema.organization.$inferSelect;
roleId: number | undefined;
plans: Array<OrganizationPlan>;
}
>();
for (const row of result) {
const orgId = row.organization.id;
if (!organizationMap.has(orgId)) {
organizationMap.set(orgId, {
organization: row.organization,
roleId: row.roleId ?? undefined,
plans: []
});
}
const orgData = organizationMap.get(orgId)!;
// Add plan to array if it exists (not null)
if (
row.plan &&
(row.plan.planName !== null ||
row.plan.isActive !== null ||
row.plan.provider !== null ||
row.plan.subscriptionId !== null)
) {
orgData.plans.push({
planName: row.plan.planName,
isActive: row.plan.isActive,
provider: row.plan.provider,
subscriptionId: row.plan.subscriptionId,
customerId: row.plan.customerId as string | null
});
}
}
return Array.from(organizationMap.values()).map(({ organization, ...rest }) => ({
...(organization as TOrganization),
...rest
}));
};
export const createOrganization = async (data: TNewOrganization) => {
const [organization] = await db.insert(schema.organization).values(data).returning();
return organization;
};
export const createOrganizationMember = async (data: TNewOrganizationmember) => {
const [member] = await db.insert(schema.organizationmember).values(data).returning();
return member;
};
export const checkSiteNameExists = async (siteName: string): Promise<boolean> => {
const result = await db
.select({ id: schema.organization.id })
.from(schema.organization)
.where(eq(schema.organization.siteName, siteName))
.limit(1);
console.debug('checkSiteNameExists result:', result);
return result.length > 0;
};
export const deleteOrganizationById = async (id: string) => {
await db.delete(schema.organization).where(eq(schema.organization.id, id));
};
/**
* Gets organization team members (non-students)
* @param orgId Organization ID
* @returns Array of team members with profile information
*/
export const getOrganizationTeam = async (orgId: string) => {
const result = await db
.select({
id: schema.organizationmember.id,
email: schema.organizationmember.email,
verified: schema.organizationmember.verified,
roleId: schema.organizationmember.roleId,
profile: {
id: schema.profile.id,
fullname: schema.profile.fullname,
email: schema.profile.email
}
})
.from(schema.organizationmember)
.leftJoin(schema.profile, eq(schema.organizationmember.profileId, schema.profile.id))
.where(
and(
eq(schema.organizationmember.organizationId, orgId),
sql`${schema.organizationmember.roleId} != ${ROLE.STUDENT}`
)
)
.orderBy(sql`${schema.organizationmember.id} DESC`);
return result.map((member) => ({
id: member.id,
email: member.profile?.email || member.email || '',
verified: member.verified,
roleId: member.roleId,
profileId: member.profile?.id,
fullname: member.profile?.fullname || ''
}));
};
/**
* Gets organization audience (students who are participants in any course)
* @param orgId Organization ID
* @returns Array of student profiles
*/
export const getOrganizationAudience = async (orgId: string) => {
const result = await db
.select({
id: schema.profile.id,
fullname: schema.profile.fullname,
email: schema.profile.email,
avatarUrl: schema.profile.avatarUrl,
createdAt: schema.profile.createdAt
})
.from(schema.profile)
.innerJoin(schema.groupmember, eq(schema.profile.id, schema.groupmember.profileId))
.innerJoin(schema.group, eq(schema.groupmember.groupId, schema.group.id))
.where(and(eq(schema.group.organizationId, orgId), eq(schema.groupmember.roleId, ROLE.STUDENT)));
return result.map((profile) => ({
id: profile.id,
name: profile.fullname,
email: profile.email || '',
avatar_url: profile.avatarUrl || '',
date_joined: profile.createdAt ? new Date(profile.createdAt).toDateString() : ''
}));
};
/**
* Gets organizations with optional filters
* @param filters Filter options (siteName, customDomain, isCustomDomainVerified)
* @returns Array of organizations with plans
*/
export const getOrganizations = async (filters?: {
siteName?: string;
customDomain?: string;
isCustomDomainVerified?: boolean;
}): Promise<OrganizationWithPlans[]> => {
const conditions: Parameters<typeof and>[0][] = [];
if (filters?.siteName) {
conditions.push(eq(schema.organization.siteName, filters.siteName));
}
if (filters?.customDomain) {
const customDomainConditions: Parameters<typeof and>[0][] = [
eq(schema.organization.customDomain, filters.customDomain)
];
if (filters.isCustomDomainVerified !== undefined) {
customDomainConditions.push(eq(schema.organization.isCustomDomainVerified, filters.isCustomDomainVerified));
}
conditions.push(and(...customDomainConditions));
}
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
const result = await db
.select({
organization: schema.organization,
plan: {
planName: schema.organizationPlan.planName,
isActive: schema.organizationPlan.isActive,
provider: schema.organizationPlan.provider,
subscriptionId: schema.organizationPlan.subscriptionId,
customerId: sql`organization_plan.payload->>'customerId'`.as('customerId')
}
})
.from(schema.organization)
.leftJoin(schema.organizationPlan, eq(schema.organization.id, schema.organizationPlan.orgId))
.where(whereClause);
// Group by organization and collect plans into an array
const organizationMap = new Map<
string,
{
organization: typeof schema.organization.$inferSelect;
plans: Array<OrganizationPlan>;
}
>();
for (const row of result) {
const orgId = row.organization.id;
if (!organizationMap.has(orgId)) {
organizationMap.set(orgId, {
organization: row.organization,
plans: []
});
}
const orgData = organizationMap.get(orgId)!;
// Add plan to array if it exists (not null)
if (
row.plan &&
(row.plan.planName !== null ||
row.plan.isActive !== null ||
row.plan.provider !== null ||
row.plan.subscriptionId !== null)
) {
orgData.plans.push({
planName: row.plan.planName,
isActive: row.plan.isActive,
provider: row.plan.provider,
subscriptionId: row.plan.subscriptionId,
customerId: row.plan.customerId as string | null
});
}
}
return Array.from(organizationMap.values()).map(({ organization, plans }) => ({
...(organization as TOrganization),
plans
}));
};
/**
* Creates a new organization plan
* @param data Organization plan creation data
* @returns Created organization plan record
*/
export const createOrganizationPlan = async (data: TNewOrganizationPlan) => {
const [plan] = await db.insert(schema.organizationPlan).values(data).returning();
return plan;
};
/**
* Updates an organization plan by subscription ID
* @param subscriptionId Subscription ID
* @param payload Payload data to update
* @returns Updated organization plan record
*/
export const updateOrganizationPlan = async (subscriptionId: string, payload: TOrganizationPlan['payload']) => {
const [plan] = await db
.update(schema.organizationPlan)
.set({ payload, updatedAt: sql`timezone('utc'::text, now())` })
.where(eq(schema.organizationPlan.subscriptionId, subscriptionId))
.returning();
return plan;
};
/**
* Cancels an organization plan by subscription ID
* @param subscriptionId Subscription ID
* @param payload Payload data to update
* @returns Updated organization plan record
*/
export const cancelOrganizationPlan = async (subscriptionId: string, payload: TOrganizationPlan['payload']) => {
const [plan] = await db
.update(schema.organizationPlan)
.set({
isActive: false,
deactivatedAt: sql`timezone('utc'::text, now())`,
payload,
updatedAt: sql`timezone('utc'::text, now())`
})
.where(eq(schema.organizationPlan.subscriptionId, subscriptionId))
.returning();
return plan;
};
@@ -0,0 +1,14 @@
import { TOrganization, TOrganizationPlan, TOrganizationmember } from '@db/types';
export type OrganizationPlan = Pick<TOrganizationPlan, 'planName' | 'isActive' | 'provider' | 'subscriptionId'> & {
customerId: string | null;
};
export type OrganizationWithMemberAndPlans = TOrganization & {
roleId?: number;
plans: OrganizationPlan[];
};
export type OrganizationWithPlans = TOrganization & {
plans: OrganizationPlan[];
};
+2 -2
View File
@@ -32,11 +32,11 @@ import {
quizPlay,
role,
submission,
submissionstatus
submissionstatus,
user
} from './schema';
import { relations } from 'drizzle-orm/relations';
import { user } from './auth-schema';
export const analyticsLoginEventsRelations = relations(analyticsLoginEvents, ({ one }) => ({
usersInAuth: one(user, {
+83 -24
View File
@@ -1,4 +1,3 @@
import { account, session, user } from './auth-schema';
import {
bigint,
boolean,
@@ -27,7 +26,83 @@ export const courseVersion = pgEnum('COURSE_VERSION', ['V1', 'V2']);
export const locale = pgEnum('LOCALE', ['en', 'hi', 'fr', 'pt', 'de', 'vi', 'ru', 'es', 'pl', 'da']);
export const plan = pgEnum('PLAN', ['EARLY_ADOPTER', 'ENTERPRISE', 'BASIC']);
export { user, session, account };
export const user = pgTable('user', {
id: uuid()
.default(sql`uuid_generate_v4()`)
.primaryKey()
.notNull(),
name: text('name').notNull(),
email: text('email').notNull().unique(),
emailVerified: boolean('email_verified').default(false).notNull(),
image: text('image'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at')
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
role: text('role'),
banned: boolean('banned').default(false),
banReason: text('ban_reason'),
banExpires: timestamp('ban_expires'),
isAnonymous: boolean('is_anonymous')
});
export const session = pgTable('session', {
id: uuid()
.default(sql`uuid_generate_v4()`)
.primaryKey()
.notNull(),
expiresAt: timestamp('expires_at').notNull(),
token: text('token').notNull().unique(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at')
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
ipAddress: text('ip_address'),
userAgent: text('user_agent'),
userId: uuid('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
impersonatedBy: text('impersonated_by')
});
export const account = pgTable('account', {
id: uuid('id')
.default(sql`uuid_generate_v4()`)
.primaryKey()
.notNull(),
accountId: text('account_id').notNull(),
providerId: text('provider_id').notNull(),
userId: uuid('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
accessToken: text('access_token'),
refreshToken: text('refresh_token'),
idToken: text('id_token'),
accessTokenExpiresAt: timestamp('access_token_expires_at'),
refreshTokenExpiresAt: timestamp('refresh_token_expires_at'),
scope: text('scope'),
password: text('password'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at')
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull()
});
export const verification = pgTable('verification', {
id: uuid('id')
.default(sql`uuid_generate_v4()`)
.primaryKey()
.notNull(),
identifier: text('identifier').notNull(),
value: text('value').notNull(),
expiresAt: timestamp('expires_at').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at')
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull()
});
export const analyticsLoginEvents = pgTable(
'analytics_login_events',
@@ -127,7 +202,6 @@ export const groupAttendance = pgTable(
startWith: 1,
increment: 1,
minValue: 1,
maxValue: 9223372036854775807,
cache: 1
}),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow(),
@@ -170,7 +244,6 @@ export const currency = pgTable('currency', {
startWith: 1,
increment: 1,
minValue: 1,
maxValue: 9223372036854775807,
cache: 1
}),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow(),
@@ -186,7 +259,6 @@ export const appsPollOption = pgTable(
startWith: 1,
increment: 1,
minValue: 1,
maxValue: 9223372036854775807,
cache: 1
}),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
@@ -250,6 +322,8 @@ export const profile = pgTable(
}),
unique('profile_username_key').on(table.username),
unique('profile_email_key').on(table.email),
index('idx_profile_id').on(table.id),
index('idx_profile_email').on(table.email),
pgPolicy('Only auth users can read profile', {
as: 'permissive',
for: 'select',
@@ -272,7 +346,6 @@ export const option = pgTable(
startWith: 1,
increment: 1,
minValue: 1,
maxValue: 9223372036854775807,
cache: 1
}),
label: varchar().notNull(),
@@ -310,7 +383,6 @@ export const quizPlay = pgTable(
startWith: 1,
increment: 1,
minValue: 1,
maxValue: 9223372036854775807,
cache: 1
}),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow(),
@@ -351,7 +423,6 @@ export const organizationContacts = pgTable(
startWith: 1,
increment: 1,
minValue: 1,
maxValue: 9223372036854775807,
cache: 1
}),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
@@ -385,7 +456,6 @@ export const lessonComment = pgTable(
startWith: 1,
increment: 1,
minValue: 1,
maxValue: 9223372036854775807,
cache: 1
}),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
@@ -511,7 +581,6 @@ export const submissionstatus = pgTable(
startWith: 1,
increment: 1,
minValue: 1,
maxValue: 9223372036854775807,
cache: 1
}),
label: varchar().notNull(),
@@ -619,7 +688,6 @@ export const videoTranscripts = pgTable('video_transcripts', {
startWith: 1,
increment: 1,
minValue: 1,
maxValue: 9223372036854775807,
cache: 1
}),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
@@ -638,7 +706,6 @@ export const organizationEmaillist = pgTable(
startWith: 1,
increment: 1,
minValue: 1,
maxValue: 9223372036854775807,
cache: 1
}),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
@@ -720,7 +787,6 @@ export const appsPollSubmission = pgTable(
startWith: 1,
increment: 1,
minValue: 1,
maxValue: 9223372036854775807,
cache: 1
}),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
@@ -839,7 +905,6 @@ export const lessonCompletion = pgTable(
startWith: 1,
increment: 1,
minValue: 1,
maxValue: 9223372036854775807,
cache: 1
}),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
@@ -925,7 +990,6 @@ export const communityQuestion = pgTable(
startWith: 1,
increment: 1,
minValue: 1,
maxValue: 9223372036854775807,
cache: 1
}),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow(),
@@ -1021,7 +1085,6 @@ export const courseNewsfeedComment = pgTable(
startWith: 1,
increment: 1,
minValue: 1,
maxValue: 9223372036854775807,
cache: 1
}),
courseNewsfeedId: uuid('course_newsfeed_id')
@@ -1066,7 +1129,6 @@ export const organizationPlan = pgTable(
startWith: 1,
increment: 1,
minValue: 1,
maxValue: 9223372036854775807,
cache: 1
}),
activatedAt: timestamp('activated_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
@@ -1093,6 +1155,7 @@ export const organizationPlan = pgTable(
name: 'organization_plan_triggered_by_fkey'
}),
unique('organization_plan_subscription_id_key').on(table.subscriptionId),
index('idx_organization_plan_org_id').on(table.orgId),
pgPolicy('Enable read access for all users', { as: 'permissive', for: 'select', to: ['public'], using: sql`true` }),
pgPolicy('User must be an org member to DELETE', { as: 'permissive', for: 'delete', to: ['public'] }),
pgPolicy('User must be an org member to INSERT', { as: 'permissive', for: 'insert', to: ['public'] }),
@@ -1109,7 +1172,6 @@ export const lessonLanguage = pgTable(
startWith: 1,
increment: 1,
minValue: 1,
maxValue: 9223372036854775807,
cache: 1
}),
content: text(),
@@ -1171,7 +1233,6 @@ export const organizationmember = pgTable(
startWith: 1,
increment: 1,
minValue: 1,
maxValue: 9223372036854775807,
cache: 1
}),
organizationId: uuid('organization_id').notNull(),
@@ -1200,6 +1261,9 @@ export const organizationmember = pgTable(
foreignColumns: [role.id],
name: 'organizationmember_role_id_fkey'
}),
index('idx_organizationmember_profile_id').on(table.profileId),
index('idx_organizationmember_organization_id').on(table.organizationId),
index('idx_organizationmember_profile_org').on(table.profileId, table.organizationId),
pgPolicy('Allow authenticated users to read.', {
as: 'permissive',
for: 'select',
@@ -1227,7 +1291,6 @@ export const question = pgTable(
startWith: 1,
increment: 1,
minValue: 1,
maxValue: 9223372036854775807,
cache: 1
}),
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
@@ -1273,7 +1336,6 @@ export const questionAnswer = pgTable(
startWith: 1,
increment: 1,
minValue: 1,
maxValue: 9223372036854775807,
cache: 1
}),
answers: varchar().array(),
@@ -1322,7 +1384,6 @@ export const questionType = pgTable(
startWith: 1,
increment: 1,
minValue: 1,
maxValue: 9223372036854775807,
cache: 1
}),
label: varchar().notNull(),
@@ -1346,7 +1407,6 @@ export const role = pgTable(
startWith: 1,
increment: 1,
minValue: 1,
maxValue: 9223372036854775807,
cache: 1
}),
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }).defaultNow(),
@@ -1366,7 +1426,6 @@ export const waitinglist = pgTable(
startWith: 1,
increment: 1,
minValue: 1,
maxValue: 9223372036854775807,
cache: 1
}),
email: varchar().notNull(),
+104
View File
@@ -0,0 +1,104 @@
import 'dotenv/config';
import { db, questionType, role, submissionstatus } from '../drizzle';
async function seed() {
try {
console.log('🌱 Starting seed...');
// Seed roles
console.log('📝 Seeding roles...');
const existingRoles = await db.select().from(role);
const existingRoleIds = existingRoles.map((r) => r.id);
const rolesToInsert = [
{
id: 1,
type: 'ADMIN',
description: 'The main controller'
},
{
id: 2,
type: 'TUTOR',
description: 'Can make changes to content, courses, but cant control passwords and cant add other tutors'
},
{
id: 3,
type: 'STUDENT',
description: 'A student role, can interact with application but cant make changes'
}
].filter((r) => !existingRoleIds.includes(r.id));
if (rolesToInsert.length > 0) {
await db.insert(role).values(rolesToInsert);
console.log(` ✓ Inserted ${rolesToInsert.length} role(s)`);
} else {
console.log(' ✓ Roles already exist, skipping');
}
// Seed submission statuses
console.log('📝 Seeding submission statuses...');
const existingStatuses = await db.select().from(submissionstatus);
const statusLabels = existingStatuses.map((s) => s.label);
const statusesToInsert = [
{
label: 'Submitted'
},
{
label: 'In Progress'
},
{
label: 'Graded'
}
].filter((s) => !statusLabels.includes(s.label));
if (statusesToInsert.length > 0) {
await db.insert(submissionstatus).values(statusesToInsert);
console.log(` ✓ Inserted ${statusesToInsert.length} submission status(es)`);
} else {
console.log(' ✓ Submission statuses already exist, skipping');
}
// Seed question types
console.log('📝 Seeding question types...');
const existingQuestionTypes = await db.select().from(questionType);
const questionTypeNames = existingQuestionTypes.map((q) => q.typename);
const questionTypesToInsert = [
{
label: 'Single answer',
typename: 'RADIO',
createdAt: '2021-08-07 18:49:46.246529+00',
updatedAt: '2021-08-15 00:57:08.12069+00'
},
{
label: 'Multiple answers',
typename: 'CHECKBOX',
createdAt: '2021-08-07 18:49:46.246529+00',
updatedAt: '2021-08-15 00:57:27.935478+00'
},
{
label: 'Paragraph',
typename: 'TEXTAREA',
createdAt: '2021-08-07 18:49:46.246529+00',
updatedAt: '2021-08-15 00:57:38.634665+00'
}
].filter((q) => !questionTypeNames.includes(q.typename));
if (questionTypesToInsert.length > 0) {
await db.insert(questionType).values(questionTypesToInsert);
console.log(` ✓ Inserted ${questionTypesToInsert.length} question type(s)`);
} else {
console.log(' ✓ Question types already exist, skipping');
}
console.log('✅ Seed completed successfully!');
process.exit(0);
} catch (error) {
console.error('❌ Error seeding database:', error);
process.exit(1);
}
}
seed();
+139
View File
@@ -0,0 +1,139 @@
// Database types generated from schema using Drizzle's built-in type inference
// This file contains all TypeScript types for the database
// Naming convention: T prefix for types
import * as schema from './schema';
export type TUser = typeof schema.user.$inferSelect;
export type TNewUser = typeof schema.user.$inferInsert;
export type TSession = typeof schema.session.$inferSelect;
export type TNewSession = typeof schema.session.$inferInsert;
export type TAccount = typeof schema.account.$inferSelect;
export type TNewAccount = typeof schema.account.$inferInsert;
export type TVerification = typeof schema.verification.$inferSelect;
export type TNewVerification = typeof schema.verification.$inferInsert;
export type TAnalyticsLoginEvents = typeof schema.analyticsLoginEvents.$inferSelect;
export type TNewAnalyticsLoginEvents = typeof schema.analyticsLoginEvents.$inferInsert;
export type TLessonSection = typeof schema.lessonSection.$inferSelect;
export type TNewLessonSection = typeof schema.lessonSection.$inferInsert;
export type TGroup = typeof schema.group.$inferSelect;
export type TNewGroup = typeof schema.group.$inferInsert;
export type TGroupAttendance = typeof schema.groupAttendance.$inferSelect;
export type TNewGroupAttendance = typeof schema.groupAttendance.$inferInsert;
export type TCurrency = typeof schema.currency.$inferSelect;
export type TNewCurrency = typeof schema.currency.$inferInsert;
export type TAppsPollOption = typeof schema.appsPollOption.$inferSelect;
export type TNewAppsPollOption = typeof schema.appsPollOption.$inferInsert;
export type TProfile = typeof schema.profile.$inferSelect;
export type TNewProfile = typeof schema.profile.$inferInsert;
export type TOption = typeof schema.option.$inferSelect;
export type TNewOption = typeof schema.option.$inferInsert;
export type TQuizPlay = typeof schema.quizPlay.$inferSelect;
export type TNewQuizPlay = typeof schema.quizPlay.$inferInsert;
export type TOrganizationContacts = typeof schema.organizationContacts.$inferSelect;
export type TNewOrganizationContacts = typeof schema.organizationContacts.$inferInsert;
export type TLessonComment = typeof schema.lessonComment.$inferSelect;
export type TNewLessonComment = typeof schema.lessonComment.$inferInsert;
export type TQuiz = typeof schema.quiz.$inferSelect;
export type TNewQuiz = typeof schema.quiz.$inferInsert;
export type TSubmission = typeof schema.submission.$inferSelect;
export type TNewSubmission = typeof schema.submission.$inferInsert;
export type TSubmissionstatus = typeof schema.submissionstatus.$inferSelect;
export type TNewSubmissionstatus = typeof schema.submissionstatus.$inferInsert;
export type TCourse = typeof schema.course.$inferSelect;
export type TNewCourse = typeof schema.course.$inferInsert;
export type TAppsPoll = typeof schema.appsPoll.$inferSelect;
export type TNewAppsPoll = typeof schema.appsPoll.$inferInsert;
export type TVideoTranscripts = typeof schema.videoTranscripts.$inferSelect;
export type TNewVideoTranscripts = typeof schema.videoTranscripts.$inferInsert;
export type TOrganizationEmaillist = typeof schema.organizationEmaillist.$inferSelect;
export type TNewOrganizationEmaillist = typeof schema.organizationEmaillist.$inferInsert;
export type TLesson = typeof schema.lesson.$inferSelect;
export type TNewLesson = typeof schema.lesson.$inferInsert;
export type TAppsPollSubmission = typeof schema.appsPollSubmission.$inferSelect;
export type TNewAppsPollSubmission = typeof schema.appsPollSubmission.$inferInsert;
export type TExercise = typeof schema.exercise.$inferSelect;
export type TNewExercise = typeof schema.exercise.$inferInsert;
export type TGroupmember = typeof schema.groupmember.$inferSelect;
export type TNewGroupmember = typeof schema.groupmember.$inferInsert;
export type TLessonCompletion = typeof schema.lessonCompletion.$inferSelect;
export type TNewLessonCompletion = typeof schema.lessonCompletion.$inferInsert;
export type TCommunityAnswer = typeof schema.communityAnswer.$inferSelect;
export type TNewCommunityAnswer = typeof schema.communityAnswer.$inferInsert;
export type TCommunityQuestion = typeof schema.communityQuestion.$inferSelect;
export type TNewCommunityQuestion = typeof schema.communityQuestion.$inferInsert;
export type TCourseNewsfeed = typeof schema.courseNewsfeed.$inferSelect;
export type TNewCourseNewsfeed = typeof schema.courseNewsfeed.$inferInsert;
export type TCourseNewsfeedComment = typeof schema.courseNewsfeedComment.$inferSelect;
export type TNewCourseNewsfeedComment = typeof schema.courseNewsfeedComment.$inferInsert;
export type TTestTenant = typeof schema.testTenant.$inferSelect;
export type TNewTestTenant = typeof schema.testTenant.$inferInsert;
export type TOrganizationPlan = typeof schema.organizationPlan.$inferSelect;
export type TNewOrganizationPlan = typeof schema.organizationPlan.$inferInsert;
export type TLessonLanguage = typeof schema.lessonLanguage.$inferSelect;
export type TNewLessonLanguage = typeof schema.lessonLanguage.$inferInsert;
export type TLessonLanguageHistory = typeof schema.lessonLanguageHistory.$inferSelect;
export type TNewLessonLanguageHistory = typeof schema.lessonLanguageHistory.$inferInsert;
export type TOrganizationmember = typeof schema.organizationmember.$inferSelect;
export type TNewOrganizationmember = typeof schema.organizationmember.$inferInsert;
export type TQuestion = typeof schema.question.$inferSelect;
export type TNewQuestion = typeof schema.question.$inferInsert;
export type TQuestionAnswer = typeof schema.questionAnswer.$inferSelect;
export type TNewQuestionAnswer = typeof schema.questionAnswer.$inferInsert;
export type TQuestionType = typeof schema.questionType.$inferSelect;
export type TNewQuestionType = typeof schema.questionType.$inferInsert;
export type TRole = typeof schema.role.$inferSelect;
export type TNewRole = typeof schema.role.$inferInsert;
export type TWaitinglist = typeof schema.waitinglist.$inferSelect;
export type TNewWaitinglist = typeof schema.waitinglist.$inferInsert;
export type TOrganization = typeof schema.organization.$inferSelect;
export type TNewOrganization = typeof schema.organization.$inferInsert;
export type TCourseType = (typeof schema.courseType.enumValues)[number];
export type TCourseVersion = (typeof schema.courseVersion.enumValues)[number];
export type TLocale = (typeof schema.locale.enumValues)[number];
export type TPlan = (typeof schema.plan.enumValues)[number];
+17 -2
View File
@@ -1,11 +1,26 @@
{
"extends": "@cio/tsconfig/base.json",
"include": ["src"],
"exclude": ["node_modules"],
"exclude": ["node_modules", "dist"],
"compilerOptions": {
"composite": true,
"allowSyntheticDefaultImports": true,
"baseUrl": ".",
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"declarationMap": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"target": "ESNext",
"paths": {
"@db/*": ["./src/*"]
"@db/*": ["./src/*"],
"@cio/email": ["./node_modules/@cio/email/dist/index.d.ts"],
"@cio/email/*": ["./node_modules/@cio/email/dist/*"]
}
},
"tsc-alias": {
"resolveFullPaths": true,
"verbose": false
}
}
+8
View File
@@ -0,0 +1,8 @@
dist
node_modules
*.log
.env
.DS_Store
tsconfig.tsbuildinfo
+181
View File
@@ -0,0 +1,181 @@
# @cio/emails
## Overview
A unified email system using a single `send()` function that accepts an email ID string with full runtime validation via Zod schemas. All emails are defined declaratively with automatic registration.
## Key Features
- **Minimal imports**: Single `import { send } from '@cio/email'`
- **Type safety**: Type-safe email IDs with autocomplete (TypeScript enforces valid IDs)
- **Runtime validation**: Zod schemas validate fields at runtime
- **Auto-registration**: Emails are automatically registered when defined
- **Discoverability**: All emails accessible via string ID
- **Consistency**: Unified pattern for all email templates
- **Validation**: Automatic Zod validation of fields with helpful error messages
## Usage
### Sending an Email
```typescript
import { sendEmail } from '@cio/email';
await send('forgotPassword', {
to: '[email protected]',
fields: {
name: 'John Doe',
email: '[email protected]',
link: 'https://app.classroomio.com/reset?token=abc123'
}
});
```
### With Overrides
```typescript
await send('forgotPassword', {
to: '[email protected]',
fields: {
name: 'John Doe',
email: '[email protected]',
link: 'https://app.classroomio.com/reset?token=abc123'
},
from: '"Support Team" <[email protected]>',
replyTo: '[email protected]'
});
```
## Creating Email Templates
### Basic Example
```typescript
// packages/email/src/emails/forgot-password.ts
import { z } from 'zod';
import { defineEmail } from '../send';
import { getDefaultTemplate } from '../templates';
export const forgotPasswordEmail = defineEmail({
id: 'forgotPassword',
subject: 'Password reset notification - ClassroomIO',
schema: z.object({
name: z.string().min(1),
email: z.string().email(),
link: z.url()
}),
render: (fields) => {
const content = `Hello ${fields.name},
<p>You are receiving this email because you have requested a password reset.</p>
<div>
<a class="button" href="${fields.link}">Reset my password</a>
</div>`;
return getDefaultTemplate(content);
}
});
```
TypeScript will provide autocomplete for valid email IDs and show errors for invalid ones. The `EmailId` type is defined in `utils/types.ts` based on the `EMAIL_IDS` constant in `utils/constants.ts`.
**Note:** When adding new emails, update both:
1. The `EMAIL_IDS` array in `utils/constants.ts`
2. The `EmailSchemas` type mapping in `emails/index.ts`
This ensures full type safety and schema inference.
## Error Handling
The `send()` function automatically validates fields against the email's schema. If validation fails, a helpful error is thrown:
```typescript
try {
await send('forgotPassword', {
to: '[email protected]',
fields: {
name: '', // Invalid: empty string
link: 'not-a-url' // Invalid: not a URL
}
});
} catch (error) {
// Error: Invalid fields for email "forgotPassword": name: String must contain at least 1 character(s), link: Invalid url
}
```
If an email ID doesn't exist:
```typescript
try {
await send('nonexistentEmail', { ... });
} catch (error) {
// Error: Email template "nonexistentEmail" not found. Available emails: forgotPassword, welcome, verifyEmail
}
```
## Architecture
### Core Components
- **`core/types.ts`**: Type definitions (EmailTemplate, SendConfig, etc.)
- **`core/registry.ts`**: Email registry that stores all templates
- **`core/base.ts`**: Base send function that validates and sends
- **`send.ts`**: `defineEmail()` and `send()` functions
- **`utils/constants.ts`**: `EMAIL_IDS` constant array with all email IDs
- **`utils/types.ts`**: `EmailId` type derived from `EMAIL_IDS` for type safety, and `EmailSchemaFor` conditional type
- **`emails/*.ts`**: Individual email definitions
- **`emails/index.ts`**: Auto-exports all emails for registration and defines `EmailSchemas` type mapping
- **`index.ts`**: Main export point
### Flow
1. Email definitions use `defineEmail()` which automatically registers them
2. All emails are imported via `emails/index.ts` when the package is loaded
3. `send()` looks up the email by ID, validates fields, and sends via existing infrastructure
4. Existing `deliverEmail()` function from `send.ts` is reused for actual delivery
## Adding New Emails
When adding a new email template:
1. Create a new email file in `emails/` directory using `defineEmail()`
2. Export it from `emails/index.ts`
3. **Important**: Add the email ID to the `EMAIL_IDS` array in `utils/constants.ts`
4. **Important**: Add the schema mapping to `EmailSchemas` type in `emails/index.ts` for type inference
Example:
```typescript
// 1. Create emails/new-email.ts
export const newEmail = defineEmail({
id: 'newEmail',
subject: 'New Email',
schema: z.object({
name: z.string(),
code: z.string()
}),
render: (fields) => { ... }
});
// 2. Export from emails/index.ts
export * from './new-email';
// Import the email definition for type mapping
import type { newEmail } from './new-email';
// Add to EmailSchemas type mapping in emails/index.ts
export type EmailSchemas = {
forgotPassword: typeof forgotPasswordEmail.template.schema;
welcome: typeof welcomeEmail.template.schema;
verifyEmail: typeof verifyEmailEmail.template.schema;
newEmail: typeof newEmail.template.schema; // ← Add here for type inference
};
// 3. Add to utils/constants.ts
export const EMAIL_IDS = [
'forgotPassword',
'welcome',
'verifyEmail',
'newEmail' // ← Add here for type safety
] as const;
```
+42
View File
@@ -0,0 +1,42 @@
{
"name": "@cio/email",
"version": "0.0.1",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": [
"dist"
],
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"format": "prettier --write \"src/**/*.ts\""
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./send": {
"types": "./dist/send.d.ts",
"default": "./dist/send.js"
}
},
"peerDependencies": {
"zod": "^4.1.8"
},
"dependencies": {
"@cio/tsconfig": "workspace:*",
"@cio/utils": "workspace:*",
"nodemailer": "^6.9.5",
"zeptomail": "^6.0.0",
"zod": "^4.1.8"
},
"devDependencies": {
"@types/nodemailer": "^6.4.21",
"prettier": "^3.2.5",
"tsc-alias": "^1.8.16",
"tsx": "^4.7.1",
"typescript": "^5.5.2"
}
}
+12
View File
@@ -0,0 +1,12 @@
import { z } from 'zod';
const envSchema = z.object({
SMTP_HOST: z.string().optional(),
SMTP_PASSWORD: z.string().optional(),
SMTP_PORT: z.string().optional(),
SMTP_SENDER: z.string().optional(),
SMTP_USER: z.string().optional(),
ZOHO_TOKEN: z.string().optional()
});
export const env = envSchema.parse(process.env);
+25
View File
@@ -0,0 +1,25 @@
import * as z from 'zod';
import type { EmailResponse } from '../utils/types';
import type { SendTemplateConfig } from './types';
import { deliverEmail } from '../send';
/**
* Send a single email using a template
* This function validates fields, generates content, and sends the email
*/
export async function sendTemplateEmail<TSchema extends z.ZodType>(
config: SendTemplateConfig<TSchema>
): Promise<EmailResponse[]> {
const content = config.template.render(config.fields);
return deliverEmail([
{
to: config.to,
subject: config.template.subject,
content,
from: config.from ?? config.template.from,
replyTo: config.replyTo ?? config.template.replyTo
}
]);
}
+43
View File
@@ -0,0 +1,43 @@
import * as z from 'zod';
import type { EmailTemplate } from './types';
/**
* Internal registry that maps email IDs to templates
*/
class EmailRegistryClass {
private emails = new Map<string, EmailTemplate>();
/**
* Register an email template with an ID
*/
register<TSchema extends z.ZodType>(id: string, template: EmailTemplate<TSchema>): void {
if (this.emails.has(id)) {
console.warn(`Email template "${id}" is being registered multiple times. The last registration will be used.`);
}
this.emails.set(id, template);
}
/**
* Get an email template by ID
*/
get(id: string): EmailTemplate | undefined {
return this.emails.get(id);
}
/**
* Get all registered email IDs
*/
getAllIds(): string[] {
return Array.from(this.emails.keys());
}
/**
* Check if an email ID exists
*/
has(id: string): boolean {
return this.emails.has(id);
}
}
export const EmailRegistry = new EmailRegistryClass();
+51
View File
@@ -0,0 +1,51 @@
import * as z from 'zod';
import type { EmailId } from '../utils/types';
/**
* Base email template interface
*/
export interface EmailTemplate<TSchema extends z.ZodType = z.ZodType> {
subject: string;
schema: TSchema;
render: (fields: z.infer<TSchema>) => string;
from?: string;
replyTo?: string;
}
/**
* Email definition with ID for registry
*/
export interface EmailDefinition<TSchema extends z.ZodType = z.ZodType> {
id: EmailId;
template: EmailTemplate<TSchema>;
}
/**
* Configuration for sending an email
*/
export interface SendConfig<TSchema extends z.ZodType = z.ZodType> {
to: string;
fields: z.infer<TSchema>;
from?: string;
replyTo?: string;
}
/**
* Internal send configuration with template
*/
export interface SendTemplateConfig<TSchema extends z.ZodType = z.ZodType> extends SendConfig<TSchema> {
template: EmailTemplate<TSchema>;
}
/**
* Configuration for defining an email template
*/
export interface DefineEmailConfig<TSchema extends z.ZodType = z.ZodType> {
id: EmailId;
subject: string;
schema: TSchema;
render: (fields: z.infer<TSchema>) => string;
from?: string;
replyTo?: string;
}
@@ -0,0 +1,28 @@
import * as z from 'zod';
import { defineEmail } from '../send';
import { getDefaultTemplate } from '../templates';
export const forgotPasswordEmail = defineEmail({
id: 'forgotPassword',
subject: 'Password reset notification - ClassroomIO',
schema: z.object({
email: z.email(),
name: z.string().min(1),
link: z.url()
}),
render: (fields) => {
const content = `Hello ${fields.name},
<p>You are receiving this email because you have requested a password reset for your ClassroomIO account.</p>
<p>Please click the button below to reset your password:</p>
<div>
<a class="button" href="${fields.link}">Reset my password</a>
</div>
<p>PS: If you did not initiate this request, reply to this email or write to [email protected] so we can look into a possible attempt to breach your account.</p>
`;
return getDefaultTemplate(content);
}
});
+31
View File
@@ -0,0 +1,31 @@
/**
* Auto-import all email definitions to register them in the registry
*
* When this module is imported, all emails are automatically registered
* and available for use with the send() function.
*/
// Import all email definitions (side effect: registers them)
export * from './forgot-password';
export * from './welcome';
export * from './verify-email';
export * from './on-password-reset';
// Import types for schema mapping (must be after exports to avoid circular dependency)
import type { forgotPasswordEmail } from './forgot-password';
import type { onPasswordResetEmail } from './on-password-reset';
import type { verifyEmailEmail } from './verify-email';
import type { welcomeEmail } from './welcome';
/**
* Type mapping that maps each EmailId to its corresponding Zod schema type
* This is used for compile-time type inference in sendEmail()
*
* **Important**: When adding a new email, add its schema mapping here!
*/
export type EmailSchemas = {
forgotPassword: typeof forgotPasswordEmail.template.schema;
welcome: typeof welcomeEmail.template.schema;
verifyEmail: typeof verifyEmailEmail.template.schema;
onPasswordReset: typeof onPasswordResetEmail.template.schema;
};
@@ -0,0 +1,17 @@
import * as z from 'zod';
import { defineEmail } from '../send';
import { getDefaultTemplate } from '../templates';
export const onPasswordResetEmail = defineEmail({
id: 'onPasswordReset',
subject: 'Password Reset Successful - ClassroomIO',
schema: z.object({
name: z.string().min(1)
}),
render: (fields) => {
return getDefaultTemplate(`Heya ${fields.name},
<p>Your password has been reset successfully.</p>
`);
}
});
+40
View File
@@ -0,0 +1,40 @@
import * as z from 'zod';
import { defineEmail } from '../send';
import { getDefaultTemplate } from '../templates';
export const verifyEmailEmail = defineEmail({
id: 'verifyEmail',
subject: 'Action Required: Confirm your email',
schema: z.object({
link: z.url(),
newEmail: z.email().optional(),
userName: z.string().optional()
}),
render: (fields) => {
const isEmailChange = !!fields.newEmail;
const userName = fields.userName || 'there';
const content = isEmailChange
? `
<p><strong>Hey ${userName} 👋</strong></p>
<p>You have requested to change your email address to <strong>${fields.newEmail}</strong>.</p>
<p>To approve this change, please click the button below:</p>
<div>
<a class="button" href="${fields.link}">Approve Email Change</a>
</div>
<p>If you did not request this change, please ignore this email.</p>
`
: `
<p><strong>Hey ${userName} 👋</strong></p>
<p>Welcome to ClassroomIO! In order to get your account ready for usage, we need to verify your email. </p>
<p>We do this to make sure we don't get fake user emails in our signup. To get the best out of our product, we'll need you to verify your email by clicking the <strong>Verify</strong> button below
</p>
<div>
<a class="button" href="${fields.link}">Verify</a>
</div>
`;
return getDefaultTemplate(content);
}
});
+32
View File
@@ -0,0 +1,32 @@
import * as z from 'zod';
import { defineEmail } from '../send';
import { getDefaultTemplate } from '../templates';
export const welcomeEmail = defineEmail({
id: 'welcome',
subject: 'Welcome to ClassroomIO!',
schema: z.object({
name: z.string().min(1)
}),
render: (fields) => {
const content = `
<p>Dear ${fields.name},</p>
<p>My name is Best, the founder of ClassroomIO. I saw you signed up, any questions so far?</p>
<p>
Would you like to get a walkthrough of the platform in a 30 minutes product demo? It's free and we won't try to sell you anything, just want to learn from your use case and show you all the cool features and flows we thought about.
</p>
<p>
Interested? just book a 30-minute demo slot in our calendar!
</p>
<div>
<a class="button" href="https://classroomio.com/demo">Book demo</a>
</div>
<p>
PS: I reply personally to every email. We don't have an outsourced support team at the other end of the globe.. ;-)
</p>
`;
return getDefaultTemplate(content);
}
});
+36
View File
@@ -0,0 +1,36 @@
/**
* @cio/email - Unified Email Package
*
* Declarative schema-only email system with unified send() API
*
* @example
* ```ts
* import { sendEmail } from '@cio/email';
*
* await sendEmail('forgotPassword', {
* to: '[email protected]',
* fields: {
* name: 'John Doe',
* email: '[email protected]',
* link: 'https://app.classroomio.com/reset?token=abc123'
* }
* });
* ```
*/
// Pre-import all emails to register them in the registry
import './emails';
// Export unified API
export { sendEmail, defineEmail } from './send';
// Export types for advanced usage
export type { EmailTemplate, EmailDefinition, SendConfig, DefineEmailConfig } from './core/types';
export type { EmailId, EmailSchemaFor } from './utils/types';
// Re-export low-level utilities
export { deliverEmail } from './send';
export { getDefaultTemplate } from './templates/default';
// Export email registry for introspection (optional)
export { EmailRegistry } from './core/registry';
+141
View File
@@ -0,0 +1,141 @@
import * as z from 'zod';
import { ZDeliverEmail, type TDeliverEmail } from '@cio/utils/validation/mail';
import { env } from './config/env';
import { sendWithZoho } from './utils/services/zeptomail';
import { sendWithNodemailer } from './utils/services/nodemailer';
import type { EmailResponse, EmailId, EmailSchemaFor } from './utils/types';
import type { DefineEmailConfig, EmailDefinition, EmailTemplate, SendConfig } from './core/types';
import { EmailRegistry } from './core/registry';
import { ZodError } from 'zod';
import { sendTemplateEmail } from './core/base';
import { EMAIL_FROM, EMAIL_REPLY_TO } from './utils/constants';
/**
* Define an email template with schema validation
* The email is automatically registered in the registry for use with sendEmail()
*
* @example
* ```ts
* export const forgotPasswordEmail = defineEmail({
* id: 'forgotPassword',
* subject: 'Password reset notification',
* schema: z.object({
* name: z.string(),
* link: z.url()
* }),
* render: (fields) => `Hello ${fields.name}, reset here: ${fields.link}`
* });
* ```
*/
export function defineEmail<TSchema extends z.ZodType>(config: DefineEmailConfig<TSchema>): EmailDefinition<TSchema> {
const template: EmailTemplate<TSchema> = {
subject: config.subject,
schema: config.schema,
render: config.render,
from: config.from ?? EMAIL_FROM,
replyTo: config.replyTo ?? EMAIL_REPLY_TO
};
// Auto-register in registry
EmailRegistry.register(config.id, template);
return {
id: config.id,
template
};
}
/**
* Send an email template using its registered ID
*
* This function looks up the template by ID, validates the fields against the template's schema,
* renders the content, and delivers the email.
*
* The `fields` type is automatically inferred based on the `emailId` passed, providing
* full type safety and autocomplete.
*
* @param emailId - The ID of the email template (type-safe, only valid IDs accepted)
* @param config - Send configuration with recipient and fields (fields type is inferred from emailId)
* @returns Promise resolving to email send results
*
* @example
* ```ts
* await sendEmail('forgotPassword', {
* to: '[email protected]',
* fields: {
* name: 'John Doe',
* email: '[email protected]',
* link: 'https://app.classroomio.com/reset?token=abc123'
* }
* });
* ```
*/
export async function sendEmail<TEmailId extends EmailId>(
emailId: TEmailId,
config: SendConfig<EmailSchemaFor<TEmailId>>
): Promise<EmailResponse[]> {
const template = EmailRegistry.get(emailId);
if (!template) {
const availableIds = EmailRegistry.getAllIds();
throw new Error(
`Email template "${emailId}" not found. Available emails: ${availableIds.length > 0 ? availableIds.join(', ') : 'none registered'}`
);
}
try {
// Validate fields against template schema
const validatedFields = template.schema.parse(config.fields);
// Send using base function
return sendTemplateEmail({
to: config.to,
template,
fields: validatedFields,
from: config.from ?? template.from,
replyTo: config.replyTo ?? template.replyTo
});
} catch (error) {
if (error instanceof ZodError) {
console.log('error', error);
const errorMessages = error.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join(', ');
throw new Error(`Invalid fields for email "${emailId}": ${errorMessages}`);
}
throw error;
}
}
/**
* Deliver raw email data via email service (Zoho or Nodemailer)
*
* This is a low-level function that takes raw email data (to, subject, content, etc.)
* and delivers it through the configured email service. Use sendEmail() for
* sending template-based emails instead.
*
* @param args - Array of email data objects (to, subject, content, from, replyTo)
* @returns Promise resolving to email delivery results
*/
export const deliverEmail = async (args: TDeliverEmail): Promise<EmailResponse[]> => {
const emailItems = ZDeliverEmail.parse(args);
const results = await Promise.all(
emailItems.map(async (emailItem) => {
try {
const res = env.ZOHO_TOKEN ? await sendWithZoho(emailItem) : await sendWithNodemailer(emailItem);
console.log('Email status', res);
return res;
} catch (error) {
console.error('Error sending email:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred',
details: error
};
}
})
);
return results;
};
+191
View File
@@ -0,0 +1,191 @@
export const getDefaultTemplate = (content: string): string =>
`<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1" />
<base target="_blank" />
<style>
body {
background-color: #F6F9FF;
font-family: "Poppins", "Helvetica Neue", "Segoe UI", Helvetica,
sans-serif;
font-size: 15px;
font-weight: 400;
line-height: 26px;
margin: 0;
}
pre {
background: #f4f4f4;
padding: 2px;
}
hr {
border-top: 1px dashed #94a3b8;
margin-top: 1rem;
margin-bottom: 1rem;
}
table {
width: 100%;
}
table td {
padding: 5px 10px 0px;
vertical-align: middle;
}
.socialicons {
background-color: #fff;
border-radius: 5px;
padding: 5px;
width: initial;
margin: 0 auto;
max-width: 200px;
}
.brandcolor {
color: #00c4b8;
}
.tooltip {
background-color: #f1f5f9;
padding: 1rem;
border-radius: 1rem;
color: #475569;
margin-top: 15px;
margin-bottom: 15px;
}
.tooltip a {
color: #1e293b;
}
.button {
margin-top: 12px;
background: #0233BD;
border-radius: 8px;
text-decoration: none !important;
color: #fff !important;
font-weight: 500;
padding: 10px 30px;
display: inline-block;
font-size: 0.9em;
}
.button:hover {
background: #0233BD;
}
.footer {
text-align: center;
font-size: 12px;
color: #cbd5e1;
}
.footer a {
color: #cbd5e1;
}
.gutter {
padding: 5px 30px;
text-align: center;
background-color: #fff;
border-bottom: 1px solid rgb(221, 208, 208);
max-width: 525px;
margin: 0 auto;
}
img {
max-width: 100%;
height: auto;
}
.gutter img {
max-width: 280px;
}
h1,
h2,
h3,
h4 {
font-weight: 600;
}
@media screen and (max-width: 600px) {
.wrap {
max-width: auto;
}
.gutter {
padding: 10px;
}
}
</style>
</head>
<body style="
background-color: #F6F9FF;
font-family: 'Poppins', 'Helvetica Neue', 'Segoe UI', Helvetica,
sans-serif;
font-size: 15px;
line-height: 26px;
padding: 20px 0;
margin: 0;
color: #1e293b;
">
<div class="gutter">
<a href="https://classroomio.com" target="_blank">
<img src="https://brand.cdn.clsrio.com/cio-bg-transparent.png" alt="ClassroomIO Logo" /></a>
</div>
<div class="wrap" style="
background-color: #fff;
padding: 40px 30px;
max-width: 525px;
margin: 0 auto;
">
${content}
</div>
<div class="footer" style="
text-align: center;
font-size: 12px;
color: #fff;
padding: 30px;
background-color: #0542CC;
max-width: 525px;
margin: 0 auto;">
<p>Find us on</p>
<table class="socialicons">
<tr>
<td>
<a target="_blank" href="https://twitter.com/classroomio"><img title="X"
src="https://brand.cdn.clsrio.com/socials/twitter.png" alt="X" width="19" /></a>
</td>
<td>
<a target="_blank" href="https://www.youtube.com/@ClassroomIO"><img title="Youtube"
src="https://brand.cdn.clsrio.com/socials/youtube.png" alt="Youtube" width="24" /></a>
</td>
<td>
<a target="_blank" href="https://www.facebook.com/classroomiohq"><img title="Facebook"
src="https://brand.cdn.clsrio.com/socials/facebook.png" alt="Facebook" width="24" /></a>
</td>
</tr>
</table>
<p style="padding-top: 8px; line-height: initial">
ClassroomIO ${new Date().getFullYear()}. All rights reserved.<br />
<a style="text-decoration: none" href="https://app.enzuzo.com/policies/tos/958fc978-5477-11ee-a03b-7b111830c594"
target="_blank">Terms</a>
|
<a style="text-decoration: none"
href="https://app.enzuzo.com/policies/privacy/958fc978-5477-11ee-a03b-7b111830c594" target="_blank">Privacy
Policy</a>
</p>
</div>
</body>
</html>
`;
+1
View File
@@ -0,0 +1 @@
export * from './default';
+4
View File
@@ -0,0 +1,4 @@
export const EMAIL_IDS = ['forgotPassword', 'welcome', 'verifyEmail', 'onPasswordReset'] as const;
export const EMAIL_FROM = '"Best from ClassroomIO" <[email protected]>';
export const EMAIL_REPLY_TO = '"Best from ClassroomIO" <[email protected]>';
@@ -0,0 +1,18 @@
import { FromData } from '../types';
// format: "ClassroomIO Developers (via ClassroomIO.com)" <[email protected]>
export function extractNameAndEmail(str: string): FromData | undefined {
// Use regular expressions to match the name and email
const regex = /"(.*?)"\s+<\s*(.*?)@(.*?)\s*>/;
const match = str.match(regex);
if (match) {
// Extract the name and email from the match groups
const name = match[1];
const email = match[2] + '@' + match[3];
return { name, email };
} else {
// Return undefined if the format doesn't match
return { name: str, email: str };
}
}
@@ -0,0 +1,65 @@
import { EmailResponse } from '../types';
import type { TEmailData } from '@cio/utils/validation/mail';
import type { Transporter } from 'nodemailer';
import { env } from '../../config/env';
import nodemailer from 'nodemailer';
let transporter: Transporter | undefined;
const setupTransporter = async () => {
if (!env.SMTP_HOST || !env.SMTP_PORT || !env.SMTP_USER || !env.SMTP_PASSWORD) {
console.error('SMTP configuration missing');
}
try {
transporter = nodemailer.createTransport({
host: env.SMTP_HOST,
port: parseInt(env.SMTP_PORT || '465', 10),
secure: true,
auth: {
user: env.SMTP_USER,
pass: env.SMTP_PASSWORD
}
});
await transporter.verify();
return transporter;
} catch (error) {
console.error('Transporter error:', error);
}
};
setupTransporter();
export async function sendWithNodemailer(emailData: TEmailData): Promise<EmailResponse> {
const { from, to, subject, content, replyTo } = emailData;
if (!transporter) {
return {
success: false,
error: 'Email transporter not initialized'
};
}
try {
const result = await transporter.sendMail({
from: from || env.SMTP_SENDER,
to,
subject,
replyTo,
html: content
});
return {
success: true,
details: result
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred',
details: error
};
}
}
@@ -0,0 +1,69 @@
import { EmailResponse } from '../types';
import { SendMailClient } from 'zeptomail';
import type { TEmailData } from '@cio/utils/validation/mail';
import { env } from '../../config/env';
import { extractNameAndEmail } from '../functions/email-helpers';
export const zohoClient = new SendMailClient({
url: 'api.zeptomail.eu/',
token: env.ZOHO_TOKEN!
});
export async function sendWithZoho(emailData: TEmailData): Promise<EmailResponse> {
const { from, to, subject, content, replyTo } = emailData;
const fromData = extractNameAndEmail(from || '');
if (!fromData) {
return {
success: false,
error: 'Invalid from data'
};
}
try {
const result = await zohoClient.sendMail({
from: {
address: fromData.email,
name: fromData.name
},
to: [
{
email_address: {
address: to
}
}
],
subject,
htmlbody: content,
reply_to: getReplyTo(replyTo)
});
return {
success: true,
details: result
};
} catch (error) {
console.error('Error sending email with Zoho:', error);
console.log(error.error.details?.[0]);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred',
details: error
};
}
}
function getReplyTo(replyTo?: string): { address: string; name: string }[] | undefined {
if (!replyTo) return undefined;
const fromData = extractNameAndEmail(replyTo);
if (!fromData) return undefined;
return [
{
address: fromData.email,
name: fromData.name
}
];
}
+24
View File
@@ -0,0 +1,24 @@
import { EMAIL_IDS } from './constants';
import type { EmailSchemas } from '../emails';
export interface EmailResponse {
success: boolean;
error?: string;
details?: unknown;
}
export interface FromData {
name: string;
email: string;
}
export type EmailId = (typeof EMAIL_IDS)[number];
/**
* Conditional type that looks up the schema type for a given EmailId
*
* @example
* type ForgotPasswordSchema = EmailSchemaFor<'forgotPassword'>;
*/
export type EmailSchemaFor<TEmailId extends EmailId> = TEmailId extends keyof EmailSchemas
? EmailSchemas[TEmailId]
: never;
+25
View File
@@ -0,0 +1,25 @@
{
"extends": "@cio/tsconfig/base.json",
"compilerOptions": {
"composite": true,
"allowSyntheticDefaultImports": true,
"strict": false,
"baseUrl": ".",
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"declarationMap": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"target": "ESNext",
"typeRoots": ["../../node_modules/@types", "./node_modules/@types"],
"types": ["node"]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "tsconfig.tsbuildinfo"],
"references": [{ "path": "../utils" }],
"tsc-alias": {
"resolveFullPaths": true,
"verbose": false
}
}
+3 -1
View File
@@ -1,6 +1,7 @@
{
"extends": "@cio/tsconfig/base.json",
"compilerOptions": {
"composite": true,
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
@@ -32,5 +33,6 @@
}
]
},
"include": ["src/**/*", ".storybook/**/*", "vite.config.ts"]
"include": ["src/**/*", ".storybook/**/*", "vite.config.ts"],
"exclude": ["node_modules", "dist"]
}
+333
View File
@@ -0,0 +1,333 @@
#!/usr/bin/env node
/**
* Script to add 'ui:' prefix to Tailwind classes in packages/ui
* Usage: node add-ui-prefix.cjs [--dry-run]
*
* This script will:
* - Find all .svelte, .ts, .tsx, .js, .jsx files in packages/ui/src
* - Add 'ui:' prefix to Tailwind utility classes
* - Skip classes that already have the prefix
* - Preserve existing code structure
*/
const fs = require('fs');
const path = require('path');
const DRY_RUN = process.argv.includes('--dry-run');
const UI_PREFIX = 'ui:';
// Check if a class looks like a Tailwind utility class
function isTailwindClass(className) {
// Skip if already has ui: prefix
if (className.startsWith(UI_PREFIX)) {
return false;
}
// Skip if it's clearly not a Tailwind class (contains special chars that aren't Tailwind)
// Allow brackets for arbitrary values like w-[100px] and colons for variant prefixes like after:
if (/[=;{}]/.test(className) && !className.includes('[') && !className.includes(']')) {
return false;
}
// Common Tailwind patterns
const patterns = [
// Variant prefixes (must come first to catch all variant combinations)
// This catches any class that starts with a variant prefix followed by a colon
/^[a-z-]+(\[.*?\])?(\/.*?)?:/,
// Layout & Display (including compound classes like inline-flex)
/^(flex|grid|block|inline|hidden|absolute|relative|fixed|sticky|contents|list-item|table|table-row|table-cell|inline-flex|inline-grid|inline-block|inline-table)$/,
// Flexbox utilities
/^(shrink|grow|flex-|basis-)/,
// Spacing (m-, p-, gap-, space-, inset-)
/^(m|p|mx|my|mt|mr|mb|ml|px|py|pt|pr|pb|pl|gap|space-[xy]|inset)-/,
// Negative spacing (e.g., -inset-2, -top-1)
/^-\w+/,
// Sizing (w-, h-, size-, min-, max-)
/^(w|h|min-w|min-h|max-w|max-h|size)-/,
// Typography
/^(text|font|leading|tracking|text-|font-)/,
// Colors
/^(bg|text|border|ring|outline|divide|from|via|to|fill|stroke)-/,
// Effects
/^(opacity|shadow|blur|brightness|contrast|grayscale|hue-rotate|invert|saturate|sepia|backdrop-)/,
// Transforms
/^(scale|rotate|translate|skew|transform|origin)-/,
// Transitions
/^(transition|duration|ease|delay|animate)-/,
// Interactivity (specific variants)
/^(hover|focus|active|disabled|group|peer|aria-|data-):/,
// Responsive
/^(sm|md|lg|xl|2xl):/,
// Dark mode
/^dark:/,
// Arbitrary values (must come before general patterns)
/^[a-z]+-\[/,
// Arbitrary variants (like [&_svg]:pointer-events-none, after:absolute, etc.)
/^\[.*\]:|^(after|before|first|last|only|nth|marker|placeholder|selection|backdrop):/,
// Common utilities
/^(rounded|border|outline|shadow|ring|overflow|z-|order-|col-|row-|justify-|items-|content-|self-|place-|object-|cursor-|select-|resize-|appearance-|pointer-events-|visibility-|whitespace-|break-|overflow-|overscroll-|scroll-|will-change-|transform|transition|top|right|bottom|left)/
];
return patterns.some((pattern) => pattern.test(className));
}
// Process a single class string
function processClassString(classString) {
// Split by whitespace and process each class
const classes = classString.split(/\s+/).filter((c) => c.trim());
const processed = classes.map((cls) => {
if (isTailwindClass(cls)) {
return `${UI_PREFIX}${cls}`;
}
return cls;
});
return processed.join(' ');
}
// Process file content
function processFileContent(content) {
let modified = content;
let changes = 0;
// Helper to process and count changes
function processAndReplace(pattern, replacer) {
modified = modified.replace(pattern, (match, ...args) => {
const result = replacer(match, ...args);
if (result !== match) {
changes++;
}
return result;
});
}
// Pattern 1: class="..." or className="..."
processAndReplace(/(class|className)="([^"]*)"/g, (match, attr, classes) => {
const processed = processClassString(classes);
return processed !== classes ? `${attr}="${processed}"` : match;
});
// Pattern 1b: class='...' or className='...'
processAndReplace(/(class|className)='([^']*)'/g, (match, attr, classes) => {
const processed = processClassString(classes);
return processed !== classes ? `${attr}='${processed}'` : match;
});
// Pattern 2: class={`...`} or className={`...`}
processAndReplace(/(class|className)=\{`([^`]+)`\}/g, (match, attr, classes) => {
const processed = processClassString(classes);
return processed !== classes ? `${attr}={\`${processed}\`}` : match;
});
// Pattern 3: class={'...'} or className={'...'}
processAndReplace(/(class|className)=\{['"]([^'"]+)['"]\}/g, (match, attr, classes) => {
const processed = processClassString(classes);
return processed !== classes ? `${attr}={\`${processed}\`}` : match;
});
// Pattern 4: DISABLED - Too broad, was catching JavaScript object properties
// Pattern 4: String literals in object properties: base: "...", default: '...', etc.
// This handles tv() calls and similar object literals
// Match any property followed by a string that looks like it contains Tailwind classes
// DISABLED: This was catching JavaScript object properties incorrectly
// processAndReplace(/(\w+)\s*:\s*["']([^"']+)["']/g, (match, key, value) => {
// // Check if value contains Tailwind classes
// // Skip if it's clearly not a class string (contains =, :, ; that aren't part of Tailwind syntax)
// if (/[=:;]/.test(value) && !value.includes('[') && !value.includes(']')) {
// return match;
// }
// const classes = value.split(/\s+/).filter((c) => c.trim());
// const hasTailwind = classes.some((c) => isTailwindClass(c));
// if (hasTailwind) {
// const processed = processClassString(value);
// // Preserve original quote style
// const quote = match.includes("'") ? "'" : '"';
// return processed !== value ? `${key}: ${quote}${processed}${quote}` : match;
// }
// return match;
// });
// Pattern 5: DISABLED - Too broad, was catching JavaScript object properties
// Pattern 5: Template literals in object properties: base: `...`
// DISABLED: This was catching JavaScript object properties incorrectly
// processAndReplace(/(\w+)\s*:\s*`([^`]+)`/g, (match, key, value) => {
// // Similar check as above
// if (/[=:;]/.test(value) && !value.includes('[') && !value.includes(']')) {
// return match;
// }
// const classes = value.split(/\s+/).filter((c) => c.trim());
// const hasTailwind = classes.some((c) => isTailwindClass(c));
// if (hasTailwind) {
// const processed = processClassString(value);
// return processed !== value ? `${key}: \`${processed}\`` : match;
// }
// return match;
// });
// Pattern 6: Simple string literals in function calls: cn("..."), tv({...})
// Only process specific functions that are known to contain Tailwind classes
// This pattern matches strings that are the first argument and have no complex expressions
const tailwindFunctionNames = ['cn', 'tv', 'clsx', 'classNames'];
processAndReplace(
new RegExp(`(${tailwindFunctionNames.join('|')})\\s*\\(\\s*['"]([^'"]+)['"]\\s*([,)])`, 'g'),
(match, fn, classes, separator) => {
const processed = processClassString(classes);
return processed !== classes ? `${fn}("${processed}"${separator}` : match;
}
);
// Pattern 7: Template literals in function calls: cn(`...`)
processAndReplace(/(cn|tv)\s*\(`([^`]+)`/g, (match, fn, classes) => {
const processed = processClassString(classes);
return processed !== classes ? `${fn}(\`${processed}\`` : match;
});
// Pattern 9: tv() function calls with object properties containing Tailwind classes
// This specifically targets tv() calls and their variant/base properties
processAndReplace(/tv\s*\(\s*\{([\s\S]*?)\}\s*\)/g, (match, objContent) => {
// Process all string literals in the tv() object, regardless of nesting level
let processedObj = objContent;
// Process double-quoted strings
processedObj = processedObj.replace(/"([^"]*)"/g, (match, value) => {
const processed = processClassString(value);
return processed !== value ? `"${processed}"` : match;
});
// Process single-quoted strings
processedObj = processedObj.replace(/'([^']*)'/g, (match, value) => {
const processed = processClassString(value);
return processed !== value ? `'${processed}'` : match;
});
// Process template literals
processedObj = processedObj.replace(/`([^`]*)`/g, (match, value) => {
const processed = processClassString(value);
return processed !== value ? `\`${processed}\`` : match;
});
return `tv({${processedObj}})`;
});
// Pattern 8: class: cn(...) patterns (like in derived props)
// Matches: class: cn('string1', 'string2', ...)
processAndReplace(/class:\s*cn\s*\(([^)]+)\)/g, (match, args) => {
// Use a more sophisticated approach to handle multi-line strings and comments
let processedArgs = args;
// Process single-quoted strings: 'content'
processedArgs = processedArgs.replace(/'([^']*)'/g, (match, content) => {
const processed = processClassString(content);
return processed !== content ? `'${processed}'` : match;
});
// Process double-quoted strings: "content"
processedArgs = processedArgs.replace(/"([^"]*)"/g, (match, content) => {
const processed = processClassString(content);
return processed !== content ? `"${processed}"` : match;
});
return `class: cn(${processedArgs})`;
});
return { content: modified, changes };
}
// Process a single file
function processFile(filePath) {
try {
const content = fs.readFileSync(filePath, 'utf8');
const { content: modified, changes } = processFileContent(content);
if (changes > 0) {
if (DRY_RUN) {
console.log(`[DRY RUN] Would update ${filePath} (${changes} changes)`);
} else {
fs.writeFileSync(filePath, modified, 'utf8');
console.log(`✓ Updated ${filePath} (${changes} changes)`);
}
return changes;
}
return 0;
} catch (error) {
console.error(`✗ Error processing ${filePath}:`, error.message);
return 0;
}
}
// Recursively find all relevant files
function findFiles(dir, extensions = ['.svelte', '.ts', '.tsx', '.js', '.jsx']) {
const files = [];
// Files to exclude
const excludedFiles = ['output.css'];
try {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
// Skip node_modules and other ignored directories
if (entry.isDirectory()) {
if (entry.name === 'node_modules' || entry.name.startsWith('.')) {
continue;
}
files.push(...findFiles(fullPath, extensions));
} else if (entry.isFile()) {
// Skip excluded files
if (excludedFiles.includes(entry.name)) {
continue;
}
const ext = path.extname(entry.name);
if (extensions.includes(ext)) {
files.push(fullPath);
}
}
}
} catch (error) {
console.error(`Error reading directory ${dir}:`, error.message);
}
return files;
}
// Main execution
function main() {
const uiDir = path.join(__dirname);
const srcDir = path.join(uiDir, 'src');
if (!fs.existsSync(srcDir)) {
console.error(`Error: ${srcDir} does not exist`);
process.exit(1);
}
console.log(`Searching for files in ${srcDir}...`);
const files = findFiles(srcDir);
console.log(`Found ${files.length} files to process\n`);
if (DRY_RUN) {
console.log('DRY RUN MODE - No files will be modified\n');
}
let totalChanges = 0;
let filesChanged = 0;
for (const file of files) {
const changes = processFile(file);
if (changes > 0) {
filesChanged++;
totalChanges += changes;
}
}
console.log(`\n${DRY_RUN ? 'Would process' : 'Processed'} ${filesChanged} files with ${totalChanges} total changes`);
}
main();
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "https://shadcn-svelte.com/schema.json",
"tailwind": {
"css": "src/index.css",
"baseColor": "slate"
"baseColor": "stone"
},
"aliases": {
"components": "$src",
+8 -4
View File
@@ -5,14 +5,17 @@
"scripts": {
"generate-css": "echo 'generating CSS' && pnpx @tailwindcss/cli -i ./src/index.css -o ./src/output.css --minify",
"build": "pnpm generate-css",
"dev": "pnpm generate-css --watch"
"dev": "pnpm generate-css --watch",
"format": "prettier --write .",
"prefix:list": "node add-ui-prefix.cjs --dry-run",
"prefix": "node add-ui-prefix.cjs && pnpm format"
},
"devDependencies": {
"@internationalized/date": "^3.8.1",
"@lucide/svelte": "^0.544.0",
"@sveltejs/adapter-auto": "^3.0.0",
"@sveltejs/kit": "^2.5.27",
"@sveltejs/vite-plugin-svelte": "^4.0.4",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tailwindcss/typography": "^0.5.18",
"bits-ui": "^2.11.7",
"clsx": "^2.1.1",
@@ -23,7 +26,8 @@
"tailwind-merge": "^3.3.1",
"tailwind-variants": "^3.1.1",
"tailwindcss": "^4.1.14",
"tw-animate-css": "^1.4.0"
"tw-animate-css": "^1.4.0",
"vaul-svelte": "1.0.0-next.7"
},
"dependencies": {
"@aarkue/tiptap-math-extension": "^1.4.0",
@@ -55,6 +59,6 @@
"tiptap-markdown": "^0.9.0"
},
"peerDependencies": {
"bits-ui": "^2.11.0"
"bits-ui": "^2.14.1"
}
}
@@ -1,22 +1,22 @@
<script lang="ts">
import { Accordion as AccordionPrimitive } from "bits-ui";
import { cn, type WithoutChild } from "../../tools";
import { Accordion as AccordionPrimitive } from 'bits-ui';
import { cn, type WithoutChild } from '../../tools';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithoutChild<AccordionPrimitive.ContentProps> = $props();
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithoutChild<AccordionPrimitive.ContentProps> = $props();
</script>
<AccordionPrimitive.Content
bind:ref
data-slot="accordion-content"
class="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
{...restProps}
bind:ref
data-slot="accordion-content"
class="ui:data-[state=closed]:animate-accordion-up ui:data-[state=open]:animate-accordion-down ui:overflow-hidden ui:text-sm"
{...restProps}
>
<div class={cn("pb-4 pt-0", className)}>
{@render children?.()}
</div>
<div class={cn('ui:pb-4 ui:pt-0', className)}>
{@render children?.()}
</div>
</AccordionPrimitive.Content>
@@ -1,17 +1,13 @@
<script lang="ts">
import { Accordion as AccordionPrimitive } from "bits-ui";
import { cn } from "../../tools";
import { Accordion as AccordionPrimitive } from 'bits-ui';
import { cn } from '../../tools';
let {
ref = $bindable(null),
class: className,
...restProps
}: AccordionPrimitive.ItemProps = $props();
let { ref = $bindable(null), class: className, ...restProps }: AccordionPrimitive.ItemProps = $props();
</script>
<AccordionPrimitive.Item
bind:ref
data-slot="accordion-item"
class={cn("border-b last:border-b-0", className)}
{...restProps}
bind:ref
data-slot="accordion-item"
class={cn('ui:border-b ui:last:border-b-0', className)}
{...restProps}
/>
@@ -1,32 +1,32 @@
<script lang="ts">
import { Accordion as AccordionPrimitive } from "bits-ui";
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
import { cn, type WithoutChild } from "../../tools";
import { Accordion as AccordionPrimitive } from 'bits-ui';
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
import { cn, type WithoutChild } from '../../tools';
let {
ref = $bindable(null),
class: className,
level = 3,
children,
...restProps
}: WithoutChild<AccordionPrimitive.TriggerProps> & {
level?: AccordionPrimitive.HeaderProps["level"];
} = $props();
let {
ref = $bindable(null),
class: className,
level = 3,
children,
...restProps
}: WithoutChild<AccordionPrimitive.TriggerProps> & {
level?: AccordionPrimitive.HeaderProps['level'];
} = $props();
</script>
<AccordionPrimitive.Header {level} class="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
bind:ref
class={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium outline-none transition-all hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
className
)}
{...restProps}
>
{@render children?.()}
<ChevronDownIcon
class="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200"
/>
</AccordionPrimitive.Trigger>
<AccordionPrimitive.Header {level} class="ui:flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
bind:ref
class={cn(
'ui:focus-visible:border-ring ui:focus-visible:ring-ring/50 ui:flex ui:flex-1 ui:items-start ui:justify-between ui:gap-4 ui:rounded-md ui:py-4 ui:text-left ui:text-sm ui:font-medium ui:outline-none ui:transition-all ui:hover:underline ui:focus-visible:ring-[3px] ui:disabled:pointer-events-none ui:disabled:opacity-50 ui:[&[data-state=open]>svg]:rotate-180',
className
)}
{...restProps}
>
{@render children?.()}
<ChevronDownIcon
class="ui:text-muted-foreground ui:pointer-events-none ui:size-4 ui:shrink-0 ui:translate-y-0.5 ui:transition-transform ui:duration-200"
/>
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
@@ -1,16 +1,7 @@
<script lang="ts">
import { Accordion as AccordionPrimitive } from "bits-ui";
import { Accordion as AccordionPrimitive } from 'bits-ui';
let {
ref = $bindable(null),
value = $bindable(),
...restProps
}: AccordionPrimitive.RootProps = $props();
let { ref = $bindable(null), value = $bindable(), ...restProps }: AccordionPrimitive.RootProps = $props();
</script>
<AccordionPrimitive.Root
bind:ref
bind:value={value as never}
data-slot="accordion"
{...restProps}
/>
<AccordionPrimitive.Root bind:ref bind:value={value as never} data-slot="accordion" {...restProps} />
+13 -13
View File
@@ -1,16 +1,16 @@
import Root from "./accordion.svelte";
import Content from "./accordion-content.svelte";
import Item from "./accordion-item.svelte";
import Trigger from "./accordion-trigger.svelte";
import Root from './accordion.svelte';
import Content from './accordion-content.svelte';
import Item from './accordion-item.svelte';
import Trigger from './accordion-trigger.svelte';
export {
Root,
Content,
Item,
Trigger,
//
Root as Accordion,
Content as AccordionContent,
Item as AccordionItem,
Trigger as AccordionTrigger,
Root,
Content,
Item,
Trigger,
//
Root as Accordion,
Content as AccordionContent,
Item as AccordionItem,
Trigger as AccordionTrigger
};
@@ -8,6 +8,6 @@
<AvatarPrimitive.Fallback
bind:ref
data-slot="avatar-fallback"
class={cn('bg-muted flex size-full items-center justify-center rounded-full', className)}
class={cn('ui:bg-muted ui:flex ui:size-full ui:items-center ui:justify-center ui:rounded-full', className)}
{...restProps}
/>
@@ -8,6 +8,6 @@
<AvatarPrimitive.Image
bind:ref
data-slot="avatar-image"
class={cn('aspect-square size-full', className)}
class={cn('ui:size-full aspect-square', className)}
{...restProps}
/>
+1 -1
View File
@@ -14,6 +14,6 @@
bind:ref
bind:loadingStatus
data-slot="avatar"
class={cn('relative flex size-8 shrink-0 overflow-hidden rounded-full', className)}
class={cn('ui:relative ui:flex ui:size-8 ui:shrink-0 ui:overflow-hidden ui:rounded-full', className)}
{...restProps}
/>
+10 -10
View File
@@ -1,13 +1,13 @@
import Root from "./avatar.svelte";
import Image from "./avatar-image.svelte";
import Fallback from "./avatar-fallback.svelte";
import Root from './avatar.svelte';
import Image from './avatar-image.svelte';
import Fallback from './avatar-fallback.svelte';
export {
Root,
Image,
Fallback,
//
Root as Avatar,
Image as AvatarImage,
Fallback as AvatarFallback,
Root,
Image,
Fallback,
//
Root as Avatar,
Image as AvatarImage,
Fallback as AvatarFallback
};
+5 -5
View File
@@ -2,14 +2,14 @@
import { type VariantProps, tv } from 'tailwind-variants';
export const badgeVariants = tv({
base: 'focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden whitespace-nowrap rounded-full border px-2 py-0.5 text-xs font-medium transition-[color,box-shadow] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3',
base: 'ui:focus-visible:border-ring ui:focus-visible:ring-ring/50 ui:aria-invalid:ring-destructive/20 ui:dark:aria-invalid:ring-destructive/40 ui:aria-invalid:border-destructive ui:inline-flex ui:w-fit ui:shrink-0 ui:items-center ui:justify-center ui:gap-1 ui:overflow-hidden ui:whitespace-nowrap ui:rounded-full ui:border ui:px-2 ui:py-0.5 ui:text-xs ui:font-medium ui:transition-[color,box-shadow] ui:focus-visible:ring-[3px] ui:[&>svg]:pointer-events-none ui:[&>svg]:size-3',
variants: {
variant: {
default: 'bg-primary text-primary-foreground [a&]:hover:bg-primary/90 border-transparent',
secondary: 'bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90 border-transparent',
default: 'ui:bg-primary ui:text-primary-foreground ui:[a&]:hover:bg-primary/90 ui:border-transparent',
secondary: 'ui:bg-secondary ui:text-secondary-foreground ui:[a&]:hover:bg-secondary/90 ui:border-transparent',
destructive:
'bg-destructive [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/70 border-transparent text-white',
outline: 'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground'
'ui:bg-destructive ui:[a&]:hover:bg-destructive/90 ui:focus-visible:ring-destructive/20 ui:dark:focus-visible:ring-destructive/40 ui:dark:bg-destructive/70 ui:border-transparent ui:text-white',
outline: 'ui:text-foreground ui:[a&]:hover:bg-accent ui:[a&]:hover:text-accent-foreground'
}
},
defaultVariants: {
+2 -2
View File
@@ -1,2 +1,2 @@
export { default as Badge } from "./badge.svelte";
export { badgeVariants, type BadgeVariant } from "./badge.svelte";
export { default as Badge } from './badge.svelte';
export { badgeVariants, type BadgeVariant } from './badge.svelte';
@@ -15,9 +15,9 @@
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
class={cn('flex size-9 items-center justify-center', className)}
class={cn('ui:flex ui:size-9 ui:items-center ui:justify-center', className)}
{...restProps}
>
<EllipsisIcon class="size-4" />
<EllipsisIcon class="ui:size-4" />
<span class="sr-only">More</span>
</span>
@@ -1,20 +1,15 @@
<script lang="ts">
import type { HTMLLiAttributes } from "svelte/elements";
import { cn, type WithElementRef } from '../../tools';
import type { HTMLLiAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '../../tools';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLLiAttributes> = $props();
let { ref = $bindable(null), class: className, children, ...restProps }: WithElementRef<HTMLLiAttributes> = $props();
</script>
<li
bind:this={ref}
data-slot="breadcrumb-item"
class={cn("inline-flex items-center gap-1.5", className)}
{...restProps}
bind:this={ref}
data-slot="breadcrumb-item"
class={cn('ui:inline-flex ui:items-center ui:gap-1.5', className)}
{...restProps}
>
{@render children?.()}
{@render children?.()}
</li>
@@ -16,7 +16,7 @@
const attrs = $derived({
'data-slot': 'breadcrumb-link',
class: cn('hover:text-foreground transition-colors', className),
class: cn('ui:hover:text-foreground ui:transition-colors', className),
href,
...restProps
});
@@ -8,7 +8,10 @@
<ol
bind:this={ref}
data-slot="breadcrumb-list"
class={cn('text-muted-foreground flex flex-wrap items-center gap-1.5 break-words text-sm sm:gap-2.5', className)}
class={cn(
'ui:text-muted-foreground ui:flex ui:flex-wrap ui:items-center ui:gap-1.5 ui:break-words ui:text-sm ui:sm:gap-2.5',
className
)}
{...restProps}
>
{@render children?.()}
@@ -16,7 +16,7 @@
role="link"
aria-disabled="true"
aria-current="page"
class={cn('text-foreground font-normal', className)}
class={cn('ui:text-foreground ui:font-normal', className)}
{...restProps}
>
{@render children?.()}
@@ -11,7 +11,7 @@
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
class={cn('[&>svg]:size-3.5', className)}
class={cn('ui:[&>svg]:size-3.5', className)}
{...restProps}
>
{#if children}
+22 -22
View File
@@ -1,25 +1,25 @@
import Root from "./breadcrumb.svelte";
import Ellipsis from "./breadcrumb-ellipsis.svelte";
import Item from "./breadcrumb-item.svelte";
import Separator from "./breadcrumb-separator.svelte";
import Link from "./breadcrumb-link.svelte";
import List from "./breadcrumb-list.svelte";
import Page from "./breadcrumb-page.svelte";
import Root from './breadcrumb.svelte';
import Ellipsis from './breadcrumb-ellipsis.svelte';
import Item from './breadcrumb-item.svelte';
import Separator from './breadcrumb-separator.svelte';
import Link from './breadcrumb-link.svelte';
import List from './breadcrumb-list.svelte';
import Page from './breadcrumb-page.svelte';
export {
Root,
Ellipsis,
Item,
Separator,
Link,
List,
Page,
//
Root as Breadcrumb,
Ellipsis as BreadcrumbEllipsis,
Item as BreadcrumbItem,
Separator as BreadcrumbSeparator,
Link as BreadcrumbLink,
List as BreadcrumbList,
Page as BreadcrumbPage,
Root,
Ellipsis,
Item,
Separator,
Link,
List,
Page,
//
Root as Breadcrumb,
Ellipsis as BreadcrumbEllipsis,
Item as BreadcrumbItem,
Separator as BreadcrumbSeparator,
Link as BreadcrumbLink,
List as BreadcrumbList,
Page as BreadcrumbPage
};
+13 -13
View File
@@ -5,25 +5,25 @@
import { Spinner } from '../spinner';
export const buttonVariants = tv({
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
base: "ui:focus-visible:border-ring ui:cursor-pointer ui:focus-visible:ring-ring/50 ui:aria-invalid:ring-destructive/20 ui:dark:aria-invalid:ring-destructive/40 ui:aria-invalid:border-destructive ui:inline-flex ui:shrink-0 ui:items-center ui:justify-center ui:gap-2 ui:whitespace-nowrap ui:rounded-md ui:text-sm ui:font-medium ui:outline-none ui:transition-all ui:focus-visible:ring-[3px] ui:disabled:pointer-events-none ui:disabled:opacity-50 ui:aria-disabled:pointer-events-none ui:aria-disabled:opacity-50 ui:[&_svg:not([class*='size-'])]:size-4 ui:[&_svg]:pointer-events-none ui:[&_svg]:shrink-0",
variants: {
variant: {
default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
default: 'ui:bg-primary ui:text-primary-foreground ui:shadow-xs ui:hover:bg-primary/90',
destructive:
'bg-destructive shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white',
'ui:bg-destructive ui:shadow-xs ui:hover:bg-destructive/90 ui:focus-visible:ring-destructive/20 ui:dark:focus-visible:ring-destructive/40 ui:dark:bg-destructive/60 ui:text-white',
outline:
'bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border',
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline'
'ui:bg-background ui:shadow-xs ui:hover:bg-accent ui:hover:text-accent-foreground ui:dark:bg-input/30 ui:dark:border-input ui:dark:hover:bg-input/50 ui:border',
secondary: 'ui:bg-secondary ui:text-secondary-foreground ui:shadow-xs ui:hover:bg-secondary/80',
ghost: 'ui:hover:bg-accent ui:hover:text-accent-foreground ui:dark:hover:bg-accent/50',
link: 'ui:text-primary ui:underline-offset-4 ui:hover:underline'
},
size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9',
'icon-sm': 'size-8',
'icon-lg': 'size-10'
default: 'ui:h-9 ui:px-4 ui:py-2 ui:has-[>svg]:px-3',
sm: 'ui:h-8 ui:gap-1.5 ui:rounded-md ui:px-3 ui:has-[>svg]:px-2.5',
lg: 'ui:h-10 ui:rounded-md ui:px-6 ui:has-[>svg]:px-4',
icon: 'ui:size-9',
'icon-sm': 'ui:size-8',
'icon-lg': 'ui:size-10'
}
},
defaultVariants: {
@@ -31,7 +31,7 @@
data-chart={chartId}
data-slot="chart"
class={cn(
'flex aspect-video justify-center overflow-visible text-xs',
'ui:flex ui:justify-center ui:overflow-visible ui:text-xs aspect-video',
// Overrides
//
// Stroke around dots/marks when hovering
+25 -25
View File
@@ -1,37 +1,37 @@
<script lang="ts">
import { THEMES, type ChartConfig } from "./chart-utils.js";
import { THEMES, type ChartConfig } from './chart-utils.js';
let { id, config }: { id: string; config: ChartConfig } = $props();
let { id, config }: { id: string; config: ChartConfig } = $props();
const colorConfig = $derived(
config ? Object.entries(config).filter(([, config]) => config.theme || config.color) : null
);
const colorConfig = $derived(
config ? Object.entries(config).filter(([, config]) => config.theme || config.color) : null
);
const themeContents = $derived.by(() => {
if (!colorConfig || !colorConfig.length) return;
const themeContents = $derived.by(() => {
if (!colorConfig || !colorConfig.length) return;
const themeContents = [];
for (let [_theme, prefix] of Object.entries(THEMES)) {
let content = `${prefix} [data-chart=${id}] {\n`;
const color = colorConfig.map(([key, itemConfig]) => {
const theme = _theme as keyof typeof itemConfig.theme;
const color = itemConfig.theme?.[theme] || itemConfig.color;
return color ? `\t--color-${key}: ${color};` : null;
});
const themeContents = [];
for (let [_theme, prefix] of Object.entries(THEMES)) {
let content = `${prefix} [data-chart=${id}] {\n`;
const color = colorConfig.map(([key, itemConfig]) => {
const theme = _theme as keyof typeof itemConfig.theme;
const color = itemConfig.theme?.[theme] || itemConfig.color;
return color ? `\t--color-${key}: ${color};` : null;
});
content += color.join("\n") + "\n}";
content += color.join('\n') + '\n}';
themeContents.push(content);
}
themeContents.push(content);
}
return themeContents.join("\n");
});
return themeContents.join('\n');
});
</script>
{#if themeContents}
{#key id}
<svelte:element this={"style"}>
{themeContents}
</svelte:element>
{/key}
{#key id}
<svelte:element this={'style'}>
{themeContents}
</svelte:element>
{/key}
{/if}
+16 -13
View File
@@ -73,7 +73,7 @@
{#snippet TooltipLabel()}
{#if formattedLabel}
<div class={cn('font-medium', labelClassName)}>
<div class={cn('ui:font-medium', labelClassName)}>
{#if typeof formattedLabel === 'function'}
{@render formattedLabel()}
{:else}
@@ -86,7 +86,7 @@
<TooltipPrimitive.Root variant="none">
<div
class={cn(
'border-border/50 bg-background grid min-w-[9rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl',
'ui:border-border/50 ui:bg-background ui:grid ui:min-w-[9rem] ui:items-start ui:gap-1.5 ui:rounded-lg ui:border ui:px-2.5 ui:py-1.5 ui:text-xs ui:shadow-xl',
className
)}
{...restProps}
@@ -94,14 +94,14 @@
{#if !nestLabel}
{@render TooltipLabel()}
{/if}
<div class="grid gap-1.5">
<div class="ui:grid ui:gap-1.5">
{#each tooltipCtx.payload as item, i (item.key + i)}
{@const key = `${nameKey || item.key || item.name || 'value'}`}
{@const itemConfig = getPayloadConfigFromPayload(chart.config, item, key)}
{@const indicatorColor = color || item.payload?.color || item.color}
<div
class={cn(
'[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:size-2.5',
'ui:[&>svg]:text-muted-foreground ui:flex ui:w-full ui:flex-wrap ui:items-stretch ui:gap-2 ui:[&>svg]:size-2.5',
indicator === 'dot' && 'items-center'
)}
>
@@ -119,27 +119,30 @@
{:else if !hideIndicator}
<div
style="--color-bg: {indicatorColor}; --color-border: {indicatorColor};"
class={cn('border-(--color-border) bg-(--color-bg) shrink-0 rounded-[2px]', {
'size-2.5': indicator === 'dot',
'h-full w-1': indicator === 'line',
'w-0 border-[1.5px] border-dashed bg-transparent': indicator === 'dashed',
'my-0.5': nestLabel && indicator === 'dashed'
class={cn('ui:border-(--color-border) ui:bg-(--color-bg) ui:shrink-0 ui:rounded-[2px]', {
'ui:size-2.5': indicator === 'dot',
'ui:h-full ui:w-1': indicator === 'line',
'ui:w-0 ui:border-[1.5px] ui:border-dashed ui:bg-transparent': indicator === 'dashed',
'ui:my-0.5': nestLabel && indicator === 'dashed'
})}
></div>
{/if}
<div
class={cn('flex flex-1 shrink-0 justify-between leading-none', nestLabel ? 'items-end' : 'items-center')}
class={cn(
'ui:flex ui:flex-1 ui:shrink-0 ui:justify-between ui:leading-none',
nestLabel ? 'ui:items-end' : 'ui:items-center'
)}
>
<div class="grid gap-1.5">
<div class="ui:grid ui:gap-1.5">
{#if nestLabel}
{@render TooltipLabel()}
{/if}
<span class="text-muted-foreground">
<span class="ui:text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{#if item.value !== undefined}
<span class="text-foreground font-mono font-medium tabular-nums">
<span class="ui:text-foreground ui:font-mono ui:font-medium tabular-nums">
{item.value.toLocaleString()}
</span>
{/if}
+33 -42
View File
@@ -1,66 +1,57 @@
import type { Tooltip } from "layerchart";
import { getContext, setContext, type Component, type ComponentProps, type Snippet } from "svelte";
import type { Tooltip } from 'layerchart';
import { getContext, setContext, type Component, type ComponentProps, type Snippet } from 'svelte';
export const THEMES = { light: "", dark: ".dark" } as const;
export const THEMES = { light: '', dark: '.dark' } as const;
export type ChartConfig = {
[k in string]: {
label?: string;
icon?: Component;
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
);
[k in string]: {
label?: string;
icon?: Component;
} & ({ color?: string; theme?: never } | { color?: never; theme: Record<keyof typeof THEMES, string> });
};
export type ExtractSnippetParams<T> = T extends Snippet<[infer P]> ? P : never;
export type TooltipPayload = ExtractSnippetParams<
ComponentProps<typeof Tooltip.Root>["children"]
>["payload"][number];
export type TooltipPayload = ExtractSnippetParams<ComponentProps<typeof Tooltip.Root>['children']>['payload'][number];
// Helper to extract item config from a payload.
export function getPayloadConfigFromPayload(
config: ChartConfig,
payload: TooltipPayload,
key: string
) {
if (typeof payload !== "object" || payload === null) return undefined;
export function getPayloadConfigFromPayload(config: ChartConfig, payload: TooltipPayload, key: string) {
if (typeof payload !== 'object' || payload === null) return undefined;
const payloadPayload =
"payload" in payload && typeof payload.payload === "object" && payload.payload !== null
? payload.payload
: undefined;
const payloadPayload =
'payload' in payload && typeof payload.payload === 'object' && payload.payload !== null
? payload.payload
: undefined;
let configLabelKey: string = key;
let configLabelKey: string = key;
if (payload.key === key) {
configLabelKey = payload.key;
} else if (payload.name === key) {
configLabelKey = payload.name;
} else if (key in payload && typeof payload[key as keyof typeof payload] === "string") {
configLabelKey = payload[key as keyof typeof payload] as string;
} else if (
payloadPayload !== undefined &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[key as keyof typeof payloadPayload] as string;
}
if (payload.key === key) {
configLabelKey = payload.key;
} else if (payload.name === key) {
configLabelKey = payload.name;
} else if (key in payload && typeof payload[key as keyof typeof payload] === 'string') {
configLabelKey = payload[key as keyof typeof payload] as string;
} else if (
payloadPayload !== undefined &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === 'string'
) {
configLabelKey = payloadPayload[key as keyof typeof payloadPayload] as string;
}
return configLabelKey in config ? config[configLabelKey] : config[key as keyof typeof config];
return configLabelKey in config ? config[configLabelKey] : config[key as keyof typeof config];
}
type ChartContextValue = {
config: ChartConfig;
config: ChartConfig;
};
const chartContextKey = Symbol("chart-context");
const chartContextKey = Symbol('chart-context');
export function setChartContext(value: ChartContextValue) {
return setContext(chartContextKey, value);
return setContext(chartContextKey, value);
}
export function useChart() {
return getContext<ChartContextValue>(chartContextKey);
return getContext<ChartContextValue>(chartContextKey);
}
@@ -17,7 +17,7 @@
bind:ref
data-slot="checkbox"
class={cn(
'border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive shadow-xs peer flex size-4 shrink-0 items-center justify-center rounded-[4px] border outline-none transition-shadow focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
'ui:border-input ui:dark:bg-input/30 ui:data-[state=checked]:bg-primary ui:data-[state=checked]:text-primary-foreground ui:dark:data-[state=checked]:bg-primary ui:data-[state=checked]:border-primary ui:focus-visible:border-ring ui:focus-visible:ring-ring/50 ui:aria-invalid:ring-destructive/20 ui:dark:aria-invalid:ring-destructive/40 ui:aria-invalid:border-destructive ui:shadow-xs ui:flex ui:size-4 ui:shrink-0 ui:items-center ui:justify-center ui:rounded-[4px] ui:border ui:outline-none ui:transition-shadow ui:focus-visible:ring-[3px] ui:disabled:cursor-not-allowed ui:disabled:opacity-50 peer',
className
)}
bind:checked
@@ -25,11 +25,11 @@
{...restProps}
>
{#snippet children({ checked, indeterminate })}
<div data-slot="checkbox-indicator" class="text-current transition-none">
<div data-slot="checkbox-indicator" class="ui:text-current ui:transition-none">
{#if checked}
<CheckIcon class="size-3.5" />
<CheckIcon class="ui:size-3.5" />
{:else if indeterminate}
<MinusIcon class="size-3.5" />
<MinusIcon class="ui:size-3.5" />
{/if}
</div>
{/snippet}
+4 -4
View File
@@ -1,6 +1,6 @@
import Root from "./checkbox.svelte";
import Root from './checkbox.svelte';
export {
Root,
//
Root as Checkbox,
Root,
//
Root as Checkbox
};
@@ -1,7 +1,7 @@
<script lang="ts">
import { Collapsible as CollapsiblePrimitive } from "bits-ui";
import { Collapsible as CollapsiblePrimitive } from 'bits-ui';
let { ref = $bindable(null), ...restProps }: CollapsiblePrimitive.ContentProps = $props();
let { ref = $bindable(null), ...restProps }: CollapsiblePrimitive.ContentProps = $props();
</script>
<CollapsiblePrimitive.Content bind:ref data-slot="collapsible-content" {...restProps} />
@@ -1,7 +1,7 @@
<script lang="ts">
import { Collapsible as CollapsiblePrimitive } from "bits-ui";
import { Collapsible as CollapsiblePrimitive } from 'bits-ui';
let { ref = $bindable(null), ...restProps }: CollapsiblePrimitive.TriggerProps = $props();
let { ref = $bindable(null), ...restProps }: CollapsiblePrimitive.TriggerProps = $props();
</script>
<CollapsiblePrimitive.Trigger bind:ref data-slot="collapsible-trigger" {...restProps} />
@@ -1,11 +1,7 @@
<script lang="ts">
import { Collapsible as CollapsiblePrimitive } from "bits-ui";
import { Collapsible as CollapsiblePrimitive } from 'bits-ui';
let {
ref = $bindable(null),
open = $bindable(false),
...restProps
}: CollapsiblePrimitive.RootProps = $props();
let { ref = $bindable(null), open = $bindable(false), ...restProps }: CollapsiblePrimitive.RootProps = $props();
</script>
<CollapsiblePrimitive.Root bind:ref bind:open data-slot="collapsible" {...restProps} />
+10 -10
View File
@@ -1,13 +1,13 @@
import Root from "./collapsible.svelte";
import Trigger from "./collapsible-trigger.svelte";
import Content from "./collapsible-content.svelte";
import Root from './collapsible.svelte';
import Trigger from './collapsible-trigger.svelte';
import Content from './collapsible-content.svelte';
export {
Root,
Content,
Trigger,
//
Root as Collapsible,
Content as CollapsibleContent,
Trigger as CollapsibleTrigger,
Root,
Content,
Trigger,
//
Root as Collapsible,
Content as CollapsibleContent,
Trigger as CollapsibleTrigger
};
@@ -28,9 +28,9 @@
<Dialog.Title>{title}</Dialog.Title>
<Dialog.Description>{description}</Dialog.Description>
</Dialog.Header>
<Dialog.Content class="overflow-hidden p-0" {portalProps}>
<Dialog.Content class="ui:overflow-hidden ui:p-0" {portalProps}>
<Command
class="**:data-[slot=command-input-wrapper]:h-12 [&_[data-command-group]:not([hidden])_~[data-command-group]]:pt-0 [&_[data-command-group]]:px-2 [&_[data-command-input-wrapper]_svg]:h-5 [&_[data-command-input-wrapper]_svg]:w-5 [&_[data-command-input]]:h-12 [&_[data-command-item]]:px-2 [&_[data-command-item]]:py-3 [&_[data-command-item]_svg]:h-5 [&_[data-command-item]_svg]:w-5"
class="**:data-[slot=command-input-wrapper]:h-12 ui:[&_[data-command-group]:not([hidden])_~[data-command-group]]:pt-0 ui:**:data-command-group:px-2 ui:[&_[data-command-input-wrapper]_svg]:h-5 ui:[&_[data-command-input-wrapper]_svg]:w-5 ui:**:data-command-input:h-12 ui:**:data-command-item:px-2 ui:**:data-command-item:py-3 ui:[&_[data-command-item]_svg]:h-5 ui:[&_[data-command-item]_svg]:w-5"
{...restProps}
bind:value
bind:ref
@@ -8,6 +8,6 @@
<CommandPrimitive.Empty
bind:ref
data-slot="command-empty"
class={cn('py-6 text-center text-sm', className)}
class={cn('ui:py-6 ui:text-center ui:text-sm', className)}
{...restProps}
/>
@@ -17,12 +17,12 @@
<CommandPrimitive.Group
bind:ref
data-slot="command-group"
class={cn('text-foreground overflow-hidden p-1', className)}
class={cn('ui:text-foreground ui:overflow-hidden ui:p-1', className)}
value={value ?? heading ?? `----${useId()}`}
{...restProps}
>
{#if heading}
<CommandPrimitive.GroupHeading class="text-muted-foreground px-2 py-1.5 text-xs font-medium">
<CommandPrimitive.GroupHeading class="ui:text-muted-foreground ui:px-2 ui:py-1.5 ui:text-xs ui:font-medium">
{heading}
</CommandPrimitive.GroupHeading>
{/if}
@@ -11,12 +11,12 @@
}: CommandPrimitive.InputProps = $props();
</script>
<div class="flex h-9 items-center gap-2 border-b pl-3 pr-8" data-slot="command-input-wrapper">
<SearchIcon class="size-4 shrink-0 opacity-50" />
<div class="ui:flex ui:h-9 ui:items-center ui:gap-2 ui:border-b ui:pl-3 ui:pr-8" data-slot="command-input-wrapper">
<SearchIcon class="ui:size-4 ui:shrink-0 ui:opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
class={cn(
'placeholder:text-muted-foreground outline-hidden flex h-10 w-full rounded-md bg-transparent py-3 text-sm disabled:cursor-not-allowed disabled:opacity-50',
'ui:placeholder:text-muted-foreground ui:outline-hidden ui:flex ui:h-10 ui:w-full ui:rounded-md ui:bg-transparent ui:py-3 ui:text-sm ui:disabled:cursor-not-allowed ui:disabled:opacity-50',
className
)}
bind:ref
@@ -9,7 +9,7 @@
bind:ref
data-slot="command-item"
class={cn(
"aria-selected:bg-accent aria-selected:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground outline-hidden relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
"ui:aria-selected:bg-accent ui:aria-selected:text-accent-foreground ui:[&_svg:not([class*='text-'])]:text-muted-foreground ui:outline-hidden ui:relative ui:flex ui:cursor-default ui:select-none ui:items-center ui:gap-2 ui:rounded-sm ui:px-2 ui:py-1.5 ui:text-sm ui:data-disabled:pointer-events-none ui:data-disabled:opacity-50 ui:[&_svg:not([class*='size-'])]:size-4 ui:[&_svg]:pointer-events-none ui:[&_svg]:shrink-0",
className
)}
{...restProps}

Some files were not shown because too many files have changed in this diff Show More