This commit is contained in:
Ashwin 2026-10-21 08:47:20 +05:30
parent f3a7aa786b
commit 8e45236d5a
56 changed files with 590 additions and 4297 deletions

View file

@ -41,8 +41,8 @@ Frontend:
- Formatting: `#lib/v2/format.js` (`money(n, currency)`, which defaults to USD, so pass `data.org.currency`; `shortDate`, `longDate`, `relativeTime`). The org's "today": `todayIn(tz)` from `#lib/v2/dates.js` with `data.org.timezone`.
- Admin: `isOrgAdmin` from `#lib/admin.js`. Labels and tones: `#lib/v2/enums.js`. Classes: `cn` from `#lib/utils.js`. DRF errors to a sentence: `readableError` from `#lib/server/v2/form-errors.js`.
- Lists and filters: `#lib/v2/filters.js`, `#lib/server/v2/filter-params.js`, `#lib/server/v2/list-queries.js`, `#lib/server/v2/saved-views.js`. Custom fields: `#lib/server/v2/custom-fields.js`.
- Components: `#lib/v2/components/<Name>.svelte` (`PageHeader`, `Pill`, `EmptyState`, `StatCard`, `ConfirmAction`, `FilterBar`, ...). Many shadcn components in `components/ui/` and everything in `components/layout`, `components/dashboard` and `components/users` have no importers, and neither does `#lib/api.js`. Find a live caller before building on any of them.
- Lists and filters: `#lib/v2/filters.js`, `#lib/server/v2/filter-params.js`, `#lib/server/v2/list-queries.js`, `#lib/server/v2/saved-views.js`. Custom fields: `#lib/server/v2/custom-fields.js`. @mentions: `MentionTextarea` and `MentionBody` in `#lib/v2/components/`, fed by `mentionPeople()` from `#lib/v2/mentions.js`.
- Components: `#lib/v2/components/<Name>.svelte` (`PageHeader`, `Pill`, `EmptyState`, `StatCard`, `ConfirmAction`, `FilterBar`, ...). Many shadcn components in `components/ui/` and everything in `components/layout`, `components/dashboard` and `components/users` have no importers. Find a live caller before building on any of them.
## Commands
@ -94,7 +94,7 @@ Frontend:
- **Data loads on the server.** A route's `+page.server.js` calls `#lib/server/v2/<entity>.js`, which calls `apiRequest` from `#lib/api-helpers.js` with the cookies; writes are form actions with `use:enhance` and `fail()`. There are no `+page.js` files, because the JWT is an httpOnly cookie the browser cannot read. Browser code talks to `src/routes/api/*/+server.js` proxies or `?/action`, never to Django, and cannot import `#lib/server/*`.
- **A load's returned names are the page's contract.** `server/v2` returns `{ results, totals }` and each load renames them; a mismatch is an SSR 500 while the API answers 200, and `pnpm check` does not catch it. Load the page.
- **Forms drop values silently.** A `<select>` with no option matching the stored value submits nothing and clears it on save (use `missingOptions` from `#lib/v2/pickers.js`); an unchecked checkbox submits nothing, so send an explicit `false`.
- **There are no toasts.** No live code calls `toast()` and no `<Toaster>` is mounted; `svelte-sonner` and the notifications store survive only in the unused v1 shell (`components/layout`). Feedback is `fail()` plus `form?.error` or `form?.message`.
- **There are no toasts and no toast library.** Feedback is `fail()` plus `form?.error` or `form?.message`.
- Env vars are declared in `src/env.js` and read through `$app/env/public` or `$app/env/private`. In SvelteKit 3 a string starting with `/` is a route ID: link with `resolve()` from `$app/paths`, or `asInternalPath` for a built string.
- Vitest runs in Node: it cannot compile `.svelte` or resolve `$app/*` beyond the env stubs, so a testable module imports neither. Tests sit beside the code as `*.test.js`; run them from `frontend/`.
- Never log a raw axios error in `hooks.server.js`, since it carries the tokens: use `describeError`. `/help` (the signed-in user's tickets) is not `/help-center` (public) and stays out of `PUBLIC_ROUTES`.

View file

@ -31,13 +31,11 @@
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.23.1",
"globals": "^17.13.0",
"mode-watcher": "^1.1.0",
"prettier": "^3.9.10",
"prettier-plugin-svelte": "^4.1.1",
"prettier-plugin-tailwindcss": "^0.8.1",
"svelte": "^5.57.2",
"svelte-check": "^4.7.6",
"svelte-sonner": "^1.2.1",
"tailwindcss": "^4.3.3",
"typescript": "^6.0.3",
"vite": "^8.3.4",

View file

@ -82,9 +82,6 @@ importers:
globals:
specifier: ^17.13.0
version: 17.13.0
mode-watcher:
specifier: ^1.1.0
version: 1.1.0(svelte@5.57.2)
prettier:
specifier: ^3.9.10
version: 3.9.10
@ -100,9 +97,6 @@ importers:
svelte-check:
specifier: ^4.7.6
version: 4.7.6(picomatch@4.0.7)(svelte@5.57.2)(typescript@6.0.3)
svelte-sonner:
specifier: ^1.2.1
version: 1.2.1(svelte@5.57.2)
tailwindcss:
specifier: ^4.3.3
version: 4.3.3
@ -1589,11 +1583,6 @@ packages:
resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==}
engines: {node: '>=16 || 14 >=14.17'}
mode-watcher@1.1.0:
resolution: {integrity: sha512-mUT9RRGPDYenk59qJauN1rhsIMKBmWA3xMF+uRwE8MW/tjhaDSCCARqkSuDTq8vr4/2KcAxIGVjACxTjdk5C3g==}
peerDependencies:
svelte: ^5.27.0
mri@1.2.0:
resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==}
engines: {node: '>=4'}
@ -1789,21 +1778,6 @@ packages:
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true
runed@0.23.4:
resolution: {integrity: sha512-9q8oUiBYeXIDLWNK5DfCWlkL0EW3oGbk845VdKlPeia28l751VpfesaB/+7pI6rnbx1I6rqoZ2fZxptOJLxILA==}
peerDependencies:
svelte: ^5.7.0
runed@0.25.0:
resolution: {integrity: sha512-7+ma4AG9FT2sWQEA0Egf6mb7PBT2vHyuHail1ie8ropfSjvZGtEAx8YTmUjv/APCsdRRxEVvArNjALk9zFSOrg==}
peerDependencies:
svelte: ^5.7.0
runed@0.28.0:
resolution: {integrity: sha512-k2xx7RuO9hWcdd9f+8JoBeqWtYrm5CALfgpkg2YDB80ds/QE4w0qqu34A7fqiAwiBBSBQOid7TLxwxVC27ymWQ==}
peerDependencies:
svelte: ^5.7.0
runed@0.35.1:
resolution: {integrity: sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q==}
peerDependencies:
@ -1883,23 +1857,12 @@ packages:
svelte:
optional: true
svelte-sonner@1.2.1:
resolution: {integrity: sha512-N00LOWb60fjKXULbpngku+7E9Krb16KpNq4/Eax5Vu7o1Dpbca55QpaLPsB+yp82JIEdFhfJuv5cVXLoe0ZtVA==}
peerDependencies:
svelte: ^5.0.0
svelte-toolbelt@0.10.6:
resolution: {integrity: sha512-YWuX+RE+CnWYx09yseAe4ZVMM7e7GRFZM6OYWpBKOb++s+SQ8RBIMMe+Bs/CznBMc0QPLjr+vDBxTAkozXsFXQ==}
engines: {node: '>=18', pnpm: '>=8.7.0'}
peerDependencies:
svelte: ^5.30.2
svelte-toolbelt@0.7.1:
resolution: {integrity: sha512-HcBOcR17Vx9bjaOceUvxkY3nGmbBmCBBbuWLLEWO6jtmWH8f/QoWmbyUfQZrpDINH39en1b8mptfPQT9VKQ1xQ==}
engines: {node: '>=18', pnpm: '>=8.7.0'}
peerDependencies:
svelte: ^5.0.0
svelte@5.57.2:
resolution: {integrity: sha512-1YiPCAXT0Ct6g1vOMmSLa0Nsc3QRMvtDF32ihgglGVrugIuIK38RyDiFNXVDhtQ81TJjSO57NdMw6DyYLqSGDQ==}
engines: {node: '>=18'}
@ -3365,12 +3328,6 @@ snapshots:
minipass@7.1.3: {}
mode-watcher@1.1.0(svelte@5.57.2):
dependencies:
runed: 0.25.0(svelte@5.57.2)
svelte: 5.57.2
svelte-toolbelt: 0.7.1(svelte@5.57.2)
mri@1.2.0: {}
mrmime@2.0.1: {}
@ -3547,21 +3504,6 @@ snapshots:
fsevents: 2.3.3
optional: true
runed@0.23.4(svelte@5.57.2):
dependencies:
esm-env: 1.2.2
svelte: 5.57.2
runed@0.25.0(svelte@5.57.2):
dependencies:
esm-env: 1.2.2
svelte: 5.57.2
runed@0.28.0(svelte@5.57.2):
dependencies:
esm-env: 1.2.2
svelte: 5.57.2
runed@0.35.1(@sveltejs/kit@3.0.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2)(vite@8.3.4(@types/node@26.6.5)(jiti@2.7.0)))(svelte@5.57.2)(typescript@6.0.3)(vite@8.3.4(@types/node@26.6.5)(jiti@2.7.0)))(svelte@5.57.2):
dependencies:
dequal: 2.0.3
@ -3641,11 +3583,6 @@ snapshots:
optionalDependencies:
svelte: 5.57.2
svelte-sonner@1.2.1(svelte@5.57.2):
dependencies:
runed: 0.28.0(svelte@5.57.2)
svelte: 5.57.2
svelte-toolbelt@0.10.6(@sveltejs/kit@3.0.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2)(vite@8.3.4(@types/node@26.6.5)(jiti@2.7.0)))(svelte@5.57.2)(typescript@6.0.3)(vite@8.3.4(@types/node@26.6.5)(jiti@2.7.0)))(svelte@5.57.2):
dependencies:
clsx: 2.1.1
@ -3655,13 +3592,6 @@ snapshots:
transitivePeerDependencies:
- '@sveltejs/kit'
svelte-toolbelt@0.7.1(svelte@5.57.2):
dependencies:
clsx: 2.1.1
runed: 0.23.4(svelte@5.57.2)
style-to-object: 1.0.14
svelte: 5.57.2
svelte@5.57.2:
dependencies:
'@jridgewell/remapping': 2.3.5

View file

@ -1,480 +0,0 @@
import { resolve } from '$app/paths';
/**
* API Client for Django REST Framework Backend
*
* This module provides a centralized API client for making requests to the Django backend.
* It handles JWT authentication, organization context, and common API patterns.
*
* @module lib/api
*/
import * as env from '$app/env/public';
import { goto } from '$app/navigation';
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
// API Base URL from environment variables
// Note: VITE_ prefix is required for client-side env vars
const API_BASE_URL = env.PUBLIC_DJANGO_API_URL
? `${env.PUBLIC_DJANGO_API_URL}/api`
: 'http://localhost:8000/api';
/**
* Storage keys for tokens and org
*/
const STORAGE_KEYS = {
ACCESS_TOKEN: 'access_token',
REFRESH_TOKEN: 'refresh_token',
ORG_ID: 'org_id',
USER: 'user'
};
/**
* Get token from localStorage (client-side) or return null (server-side)
* @param {string} key - Storage key
* @returns {string|null}
*/
function getFromStorage(key) {
if (typeof window === 'undefined') return null;
return localStorage.getItem(key);
}
/**
* Set token in localStorage (client-side only)
* @param {string} key - Storage key
* @param {string} value - Value to store
*/
function setInStorage(key, value) {
if (typeof window !== 'undefined') {
localStorage.setItem(key, value);
}
}
/**
* Remove token from localStorage (client-side only)
* @param {string} key - Storage key
*/
function removeFromStorage(key) {
if (typeof window !== 'undefined') {
localStorage.removeItem(key);
}
}
/**
* Clear all auth data from storage
*/
export function clearAuthData() {
removeFromStorage(STORAGE_KEYS.ACCESS_TOKEN);
removeFromStorage(STORAGE_KEYS.REFRESH_TOKEN);
removeFromStorage(STORAGE_KEYS.ORG_ID);
removeFromStorage(STORAGE_KEYS.USER);
}
/**
* Get current access token
* @returns {string|null}
*/
export function getAccessToken() {
return getFromStorage(STORAGE_KEYS.ACCESS_TOKEN);
}
/**
* Get current refresh token
* @returns {string|null}
*/
export function getRefreshToken() {
return getFromStorage(STORAGE_KEYS.REFRESH_TOKEN);
}
/**
* Get current organization ID
* @returns {string|null}
*/
export function getOrgId() {
return getFromStorage(STORAGE_KEYS.ORG_ID);
}
/**
* Set organization ID
* @param {string} orgId - Organization UUID
*/
export function setOrgId(orgId) {
setInStorage(STORAGE_KEYS.ORG_ID, orgId);
}
/**
* Get current user data
* @returns {Object|null}
*/
export function getCurrentUser() {
const userData = getFromStorage(STORAGE_KEYS.USER);
return userData ? JSON.parse(userData) : null;
}
/**
* In-flight refresh shared by concurrent callers, or null when idle.
* @type {Promise<string|null>|null}
*/
let refreshInFlight = null;
/**
* Refresh the access token using refresh token.
*
* The backend rotates refresh tokens: the token we send is blacklisted and a
* replacement comes back, so concurrent callers must not each spend the same
* one. Whichever request landed second would get a 401 and log the user out,
* so all callers share a single in-flight refresh.
*
* @returns {Promise<string|null>} New access token or null if refresh failed
*/
async function refreshAccessToken() {
if (refreshInFlight) {
return refreshInFlight;
}
refreshInFlight = performTokenRefresh().finally(() => {
refreshInFlight = null;
});
return refreshInFlight;
}
/**
* Perform the actual refresh round-trip. Use refreshAccessToken() instead.
* It deduplicates concurrent callers.
* @returns {Promise<string|null>} New access token or null if refresh failed
*/
async function performTokenRefresh() {
const refreshToken = getRefreshToken();
if (!refreshToken) {
return null;
}
try {
const response = await fetch(`${API_BASE_URL}/auth/refresh-token/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ refresh: refreshToken })
});
if (response.ok) {
const data = await response.json();
if (!data.access) {
clearAuthData();
return null;
}
setInStorage(STORAGE_KEYS.ACCESS_TOKEN, data.access);
// Persist the rotated refresh token. The one we just spent is
// blacklisted server-side, so keeping it would 401 the next refresh.
if (data.refresh) {
setInStorage(STORAGE_KEYS.REFRESH_TOKEN, data.refresh);
}
return data.access;
}
// Refresh token expired or invalid
clearAuthData();
if (typeof window !== 'undefined') {
goto(resolve('login'));
}
return null;
} catch (error) {
console.error('Token refresh failed:', error);
clearAuthData();
return null;
}
}
/**
* Make an authenticated API request
*
* @param {string} endpoint - API endpoint (e.g., '/accounts/', '/leads/123/')
* @param {{ method?: string, body?: Record<string, unknown> | null, headers?: Record<string, string>, requiresAuth?: boolean }} [options] - Fetch options
* @returns {Promise<any>} Response data
* @throws {Error} If request fails
*/
export async function apiRequest(endpoint, options = {}) {
const { method = 'GET', body = null, headers = {}, requiresAuth = true } = options;
const url = `${API_BASE_URL}${endpoint}`;
// Build headers
/** @type {Record<string, string>} */
const requestHeaders = {
'Content-Type': 'application/json',
...headers
};
// Add authentication if required
// Note: Organization context is now embedded in JWT token, not sent as header
if (requiresAuth) {
const accessToken = getAccessToken();
if (accessToken) {
requestHeaders['Authorization'] = `Bearer ${accessToken}`;
}
}
// Build fetch options
/** @type {RequestInit} */
const fetchOptions = {
method,
headers: requestHeaders
};
if (body && method !== 'GET') {
fetchOptions.body = JSON.stringify(body);
}
try {
let response = await fetch(url, fetchOptions);
// If unauthorized and we have a refresh token, try to refresh
if (response.status === 401 && requiresAuth) {
const newAccessToken = await refreshAccessToken();
if (newAccessToken) {
// Retry request with new token
requestHeaders['Authorization'] = `Bearer ${newAccessToken}`;
fetchOptions.headers = requestHeaders;
response = await fetch(url, fetchOptions);
}
}
// Handle response
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(
errorData.detail || errorData.error || `HTTP ${response.status}: ${response.statusText}`
);
}
// Return JSON response
return await response.json();
} catch (error) {
console.error(`API request failed: ${method} ${endpoint}`, error);
throw error;
}
}
/**
* Authentication API methods
*/
export const auth = {
/**
* Switch to a different organization
* This issues new JWT tokens with the new org context
* @param {string} orgId - Organization UUID to switch to
* @returns {Promise<any>} New tokens and org data
*/
async switchOrg(orgId) {
if (!orgId || !UUID_RE.test(orgId)) {
throw new Error('Invalid organization ID format');
}
// Hand over the outgoing refresh token so the backend retires it; we replace
// it below either way, and leaving it live keeps a stolen copy working
// against the previous org.
const outgoingRefresh = getRefreshToken();
/** @type {any} */
const data = await apiRequest('/auth/switch-org/', {
method: 'POST',
body: outgoingRefresh ? { org_id: orgId, refresh: outgoingRefresh } : { org_id: orgId }
});
// Update tokens with new org context
setInStorage(STORAGE_KEYS.ACCESS_TOKEN, data.access_token);
setInStorage(STORAGE_KEYS.REFRESH_TOKEN, data.refresh_token);
setInStorage(STORAGE_KEYS.ORG_ID, data.current_org.id);
return data;
},
/**
* Get current user data
* @returns {Promise<Object>} Current user with organizations
*/
async me() {
const data = await apiRequest('/auth/me/');
// Update stored user data
setInStorage(STORAGE_KEYS.USER, JSON.stringify(data));
return data;
},
/**
* Get profile for current organization
* @returns {Promise<Object>} Profile data for current org
*/
async profile() {
return await apiRequest('/auth/profile/');
},
/**
* Logout (clear local data)
* Note: Django simplejwt doesn't have server-side logout by default
* Token blacklisting can be added later if needed
*/
logout() {
clearAuthData();
if (typeof window !== 'undefined') {
goto(resolve('login'));
}
}
};
/**
* Generic CRUD API factory
* Creates standard CRUD methods for an entity
*
* @param {string} entityPath - Entity path (e.g., 'accounts', 'leads')
* @returns {Object} CRUD methods
*/
function createCrudApi(entityPath) {
return {
/**
* List entities with pagination and filters
* @param {Record<string, string>} [params] - Query parameters
* @returns {Promise<any>} Paginated list of entities
*/
async list(params = {}) {
const queryString = new URLSearchParams(params).toString();
const endpoint = `/${entityPath}/${queryString ? `?${queryString}` : ''}`;
return await apiRequest(endpoint);
},
/**
* Get a single entity by ID
* @param {string} id - Entity UUID
* @returns {Promise<any>} Entity data
*/
async get(id) {
return await apiRequest(`/${entityPath}/${id}/`);
},
/**
* Create a new entity
* @param {Record<string, unknown>} data - Entity data
* @returns {Promise<any>} Created entity
*/
async create(data) {
return await apiRequest(`/${entityPath}/`, {
method: 'POST',
body: data
});
},
/**
* Update an entity
* @param {string} id - Entity UUID
* @param {Record<string, unknown>} data - Updated entity data
* @returns {Promise<any>} Updated entity
*/
async update(id, data) {
return await apiRequest(`/${entityPath}/${id}/`, {
method: 'PUT',
body: data
});
},
/**
* Partially update an entity
* @param {string} id - Entity UUID
* @param {Record<string, unknown>} data - Partial entity data
* @returns {Promise<any>} Updated entity
*/
async patch(id, data) {
return await apiRequest(`/${entityPath}/${id}/`, {
method: 'PATCH',
body: data
});
},
/**
* Delete an entity
* @param {string} id - Entity UUID
* @returns {Promise<any>}
*/
async delete(id) {
return await apiRequest(`/${entityPath}/${id}/`, {
method: 'DELETE'
});
},
/**
* Get comments for an entity
* @param {string} id - Entity UUID
* @returns {Promise<any>} List of comments
*/
async getComments(id) {
return await apiRequest(`/${entityPath}/comment/${id}/`);
},
/**
* Add comment to an entity
* @param {string} id - Entity UUID
* @param {string} comment - Comment text
* @returns {Promise<any>} Created comment
*/
async addComment(id, comment) {
return await apiRequest(`/${entityPath}/comment/${id}/`, {
method: 'POST',
body: { comment }
});
},
/**
* Delete a comment
* @param {string} commentId - Comment UUID
* @returns {Promise<any>} Response
*/
async deleteComment(commentId) {
return await apiRequest(`/${entityPath}/comment/${commentId}/`, {
method: 'DELETE'
});
},
/**
* Get attachments for an entity
* @param {string} id - Entity UUID
* @returns {Promise<any>} List of attachments
*/
async getAttachments(id) {
return await apiRequest(`/${entityPath}/attachment/${id}/`);
}
};
}
/**
* CRM Entity APIs
*/
export const accounts = createCrudApi('accounts');
export const leads = createCrudApi('leads');
export const contacts = createCrudApi('contacts');
export const opportunities = createCrudApi('opportunity');
export const tickets = createCrudApi('cases');
export const tasks = createCrudApi('tasks');
export const events = createCrudApi('events');
export const invoices = createCrudApi('invoices');
/**
* Export all as default
*/
export default {
auth,
accounts,
leads,
contacts,
opportunities,
tickets,
tasks,
events,
invoices,
apiRequest,
getAccessToken,
getRefreshToken,
getOrgId,
setOrgId,
getCurrentUser,
clearAuthData
};

View file

@ -1,184 +0,0 @@
<script>
import { resolve } from '$app/paths';
import { enhance } from '$app/forms';
import { refreshAll } from '$app/navigation';
import { toast } from 'svelte-sonner';
import { Sliders, Loader2, Check } from '@lucide/svelte';
import { Button } from '#lib/components/ui/button/index.js';
import { Input } from '#lib/components/ui/input/index.js';
import { Label } from '#lib/components/ui/label/index.js';
/**
* @typedef {Object} CustomFieldDef
* @property {string} id
* @property {string} key
* @property {string} label
* @property {string} field_type
* @property {Array<{value: string, label: string}>=} options
* @property {boolean=} is_required
* @property {number=} display_order
* @property {boolean=} is_active
*
* @typedef {{
* target: string,
* definitions: CustomFieldDef[],
* values: Record<string, unknown>,
* formAction?: string,
* title?: string,
* extraFields?: Record<string, string>,
* }} Props
*/
/** @type {Props} */
let {
target,
definitions,
values = {},
formAction = '?/updateCustomFields',
title = 'Details',
extraFields = /** @type {Record<string, string>} */ ({})
} = $props();
// Local editable copy of values, keyed by definition key.
/** @type {Record<string, any>} */
let draft = $state({});
let saving = $state(false);
$effect(() => {
// Reseed when the upstream values change (load() invalidation).
draft = { ...values };
for (const defn of definitions) {
if (!(defn.key in draft)) draft[defn.key] = defaultFor(defn);
}
});
/** @param {CustomFieldDef} defn */
function defaultFor(defn) {
if (defn.field_type === 'checkbox') return false;
if (defn.field_type === 'number') return '';
return '';
}
const activeDefs = $derived(
(definitions || [])
.filter((d) => d.is_active !== false)
.slice()
.sort((a, b) => (a.display_order ?? 0) - (b.display_order ?? 0))
);
const isEmpty = $derived(activeDefs.length === 0);
</script>
<section class="rounded-lg border border-[var(--border-default)] bg-[var(--surface-default)] p-4">
<header class="mb-3 flex items-center gap-2">
<Sliders class="h-4 w-4 text-[var(--text-secondary)]" />
<h3 class="text-sm font-medium text-[var(--text-secondary)]">
{title}
</h3>
</header>
{#if isEmpty}
<p class="text-sm text-[var(--text-secondary)]">
No custom fields configured for {target.toLowerCase()}.
<a class="underline" href={resolve(`settings/custom-fields?target=${target}`)}>Manage</a>
</p>
{:else}
<form
method="POST"
action={formAction}
use:enhance={() => {
saving = true;
return async ({ result, update }) => {
await update();
saving = false;
if (result.type === 'success') {
toast.success(`${title} saved`);
await refreshAll();
} else if (result.type === 'failure') {
toast.error(String(result.data?.error || 'Failed to save'));
}
};
}}
class="space-y-4"
>
<input type="hidden" name="custom_fields" value={JSON.stringify(draft || {})} />
{#each Object.entries(extraFields) as [extraKey, extraValue] (extraKey)}
<input type="hidden" name={extraKey} value={extraValue} />
{/each}
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
{#each activeDefs as defn (defn.id)}
<div class="space-y-1.5">
<Label for={`cf_${defn.key}`} class="text-xs">
{defn.label}
{#if defn.is_required}
<span class="text-[var(--color-danger-default)]">*</span>
{/if}
</Label>
{#if defn.field_type === 'text'}
<Input
id={`cf_${defn.key}`}
bind:value={draft[defn.key]}
required={!!defn.is_required}
/>
{:else if defn.field_type === 'textarea'}
<textarea
id={`cf_${defn.key}`}
rows={3}
required={!!defn.is_required}
bind:value={draft[defn.key]}
class="w-full rounded-md border border-[var(--border-default)] bg-[var(--surface-default)] px-3 py-2 text-sm focus:ring-2 focus:ring-[var(--color-primary-default)]"
></textarea>
{:else if defn.field_type === 'number'}
<Input
id={`cf_${defn.key}`}
type="number"
step="any"
required={!!defn.is_required}
bind:value={draft[defn.key]}
/>
{:else if defn.field_type === 'date'}
<Input
id={`cf_${defn.key}`}
type="date"
required={!!defn.is_required}
bind:value={draft[defn.key]}
/>
{:else if defn.field_type === 'checkbox'}
<label class="flex items-center gap-2 text-sm">
<input id={`cf_${defn.key}`} type="checkbox" bind:checked={draft[defn.key]} />
<span class="text-[var(--text-secondary)]">{defn.label}</span>
</label>
{:else if defn.field_type === 'dropdown'}
<select
id={`cf_${defn.key}`}
required={!!defn.is_required}
bind:value={draft[defn.key]}
class="w-full rounded-md border border-[var(--border-default)] bg-[var(--surface-default)] px-3 py-2 text-sm focus:ring-2 focus:ring-[var(--color-primary-default)]"
>
<option value="">, None, </option>
{#each defn.options || [] as opt (opt.value)}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
{:else}
<Input id={`cf_${defn.key}`} bind:value={draft[defn.key]} />
{/if}
</div>
{/each}
</div>
<div class="flex justify-end">
<Button type="submit" size="sm" disabled={saving} class="gap-1">
{#if saving}
<Loader2 class="h-3.5 w-3.5 animate-spin" />
{:else}
<Check class="h-3.5 w-3.5" />
{/if}
Save
</Button>
</div>
</form>
{/if}
</section>

View file

@ -1 +0,0 @@
export { default as CustomFieldsPanel } from './CustomFieldsPanel.svelte';

View file

@ -1,36 +0,0 @@
<script>
import * as Sidebar from '#lib/components/ui/sidebar/index.js';
import AppSidebar from './AppSidebar.svelte';
/**
* @typedef {Object} Props
* @property {Object} user - User object with name, email, profilePhoto
* @property {string} [org_name] - Organization name
* @property {Object} [org_settings] - Org settings (tier, currency, etc.)
* @property {import('svelte').Snippet} [children] - Child content
*/
/** @type {Props & { children?: import('svelte').Snippet }} */
let { user = {}, org_name = 'BottleCRM', org_settings = {}, children } = $props();
// Get initial sidebar state from cookie (set by server or previous session)
let defaultOpen = $state(true);
$effect(() => {
if (typeof document !== 'undefined') {
const cookies = document.cookie.split(';');
const sidebarCookie = cookies.find((c) => c.trim().startsWith('sidebar:state='));
if (sidebarCookie) {
const value = sidebarCookie.split('=')[1];
defaultOpen = value === 'true';
}
}
});
</script>
<Sidebar.Provider open={defaultOpen} class="" style="">
<AppSidebar {user} {org_name} {org_settings} />
<Sidebar.Inset class="">
{@render children?.()}
</Sidebar.Inset>
</Sidebar.Provider>

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,3 @@
export { default as AppShell } from './AppShell.svelte';
export { default as AppSidebar } from './AppSidebar.svelte';
export { default as FilterPopover } from './FilterPopover.svelte';
export { default as PageHeader } from './PageHeader.svelte';
export { default as FilterPill } from './FilterPill.svelte';

View file

@ -1,51 +0,0 @@
<script>
import { Bell as BellIcon } from '@lucide/svelte';
import * as Popover from '#lib/components/ui/popover/index.js';
import { Button } from '#lib/components/ui/button/index.js';
import { notifications } from '#lib/stores/notifications.svelte.js';
import NotificationPanel from './NotificationPanel.svelte';
let open = $state(false);
$effect(() => {
notifications.start();
return () => notifications.stop();
});
// Opening the panel is an explicit "show me now", so don't make the user
// wait out the poll interval.
$effect(() => {
if (open) notifications.fetch();
});
/** @param {number} n */
const fmt = (n) => (n > 99 ? '99+' : String(n));
</script>
<Popover.Root bind:open>
<Popover.Trigger asChild class="">
{#snippet child({ props })}
<Button
{...props}
variant="ghost"
size="icon"
class="relative h-9 w-9 rounded-full"
aria-label={notifications.unread_count > 0
? `Notifications (${notifications.unread_count} unread)`
: 'Notifications'}
>
<BellIcon class="h-5 w-5" />
{#if notifications.unread_count > 0}
<span
class="absolute -top-0.5 -right-0.5 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-[var(--color-primary-default)] px-1 text-[10px] leading-none font-semibold text-white"
>
{fmt(notifications.unread_count)}
</span>
{/if}
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content align="end" class="w-auto p-0">
<NotificationPanel onClose={() => (open = false)} />
</Popover.Content>
</Popover.Root>

View file

@ -1,183 +0,0 @@
<script>
import {
AlertTriangle,
AtSign,
Bell,
History,
Loader2,
Mail,
MessageSquare,
RotateCcw,
Trash2,
UserPlus
} from '@lucide/svelte';
import { formatRelativeDate } from '#lib/utils/formatting.js';
import { notifications } from '#lib/stores/notifications.svelte.js';
/**
* @type {{
* onClose?: () => void
* }}
*/
let { onClose } = $props();
// Map machine verbs to icons. Unknown verbs fall back to the bell.
// NOTE: `case.*` keys are backend wire-format strings. Do not rename.
const VERB_ICONS = {
'case.commented': MessageSquare,
'case.mentioned': AtSign,
'case.assigned': UserPlus,
'case.sla_breached': AlertTriangle,
'case.escalated': AlertTriangle,
'case.reopened': RotateCcw,
'case.email_received': Mail,
'support.replied': MessageSquare,
'support.status_changed': Bell
};
/** @param {any} n */
function verbIcon(n) {
return VERB_ICONS[n.verb] || Bell;
}
/** @param {any} n */
function verbLabel(n) {
if (n.verb === 'case.mentioned') return 'mentioned you';
if (n.verb === 'case.assigned') return 'assigned you';
if (n.verb === 'case.commented') return 'commented';
if (n.verb === 'case.sla_breached') return 'SLA breached';
if (n.verb === 'case.escalated') return 'escalated';
if (n.verb === 'case.reopened') return 'reopened';
if (n.verb === 'case.email_received') return 'received an email';
if (n.verb === 'support.replied') return 'replied to';
if (n.verb === 'support.status_changed') return 'updated';
return n.verb.replace(/^[^.]+\./, '').replace(/_/g, ' ');
}
/** @param {any} n */
function actorLabel(n) {
if (n.verb?.startsWith('support.')) return 'BottleCRM Support';
return n.actor?.user_details?.email || 'System';
}
/** @param {unknown} link */
function safeLink(link) {
if (typeof link !== 'string') return '';
const ticket = link.match(/^\/(?:cases|tickets)\/([^/?#]+)\/?$/);
if (ticket) return `/tickets/${encodeURIComponent(ticket[1])}`;
const help = link.match(/^\/(?:support|help)\/([^/?#]+)\/?$/);
return help ? `/help/${encodeURIComponent(help[1])}` : '';
}
/** @param {any} n */
async function open(n) {
await notifications.markRead(n.id);
const link = safeLink(n.link);
if (link) {
onClose?.();
window.location.href = link;
}
}
</script>
<div class="flex w-[360px] max-w-[90vw] flex-col" role="dialog" aria-label="Notifications">
<header
class="flex items-center justify-between border-b border-[var(--border-default)] px-3 py-2"
>
<div class="flex items-center gap-2">
<Bell class="h-4 w-4 text-[var(--text-secondary)]" />
<span class="text-sm font-semibold">Notifications</span>
{#if notifications.unread_count > 0}
<span
class="rounded-full bg-[var(--color-primary-default)]/10 px-1.5 text-[10px] font-medium text-[var(--color-primary-default)]"
>
{notifications.unread_count}
</span>
{/if}
</div>
{#if notifications.unread_count > 0}
<button
type="button"
class="text-xs text-[var(--color-primary-default)] hover:underline"
onclick={() => notifications.markAllRead()}
>
Mark all read
</button>
{/if}
</header>
<div class="max-h-[60vh] min-h-[120px] overflow-y-auto">
{#if notifications.loading && notifications.notifications.length === 0}
<div class="flex items-center justify-center py-8 text-sm text-[var(--text-secondary)]">
<Loader2 class="mr-2 h-4 w-4 animate-spin" /> Loading…
</div>
{:else if notifications.notifications.length === 0}
<div
class="flex flex-col items-center justify-center gap-1 py-8 text-sm text-[var(--text-secondary)]"
>
<History class="h-5 w-5 opacity-60" />
<span>You're all caught up.</span>
</div>
{:else}
<ul class="divide-y divide-[var(--border-default)]">
{#each notifications.notifications as n (n.id)}
{@const Icon = verbIcon(n)}
{@const isUnread = !n.read_at}
<li
class="group relative flex items-start gap-2 px-3 py-2 hover:bg-[var(--surface-muted)] {isUnread
? 'bg-[var(--color-primary-default)]/[0.03]'
: ''}"
>
<button
type="button"
class="flex flex-1 items-start gap-2 text-left"
onclick={() => open(n)}
>
<span
class="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-[var(--color-primary-light)] text-[var(--color-primary-default)]"
>
<Icon class="h-3.5 w-3.5" />
</span>
<span class="min-w-0 flex-1">
<span class="block text-sm leading-snug">
<span class="font-medium">{actorLabel(n)}</span>
{verbLabel(n)}{n.entity_name ? `, ${n.entity_name}` : ''}
</span>
{#if n.data?.comment_excerpt}
<span class="mt-0.5 line-clamp-2 block text-xs text-[var(--text-secondary)]">
{n.data.comment_excerpt}
</span>
{/if}
<span class="mt-0.5 block text-[11px] text-[var(--text-secondary)]">
{formatRelativeDate(n.created_at)}
</span>
</span>
{#if isUnread}
<span
class="mt-2 h-2 w-2 shrink-0 rounded-full bg-[var(--color-primary-default)]"
aria-label="unread"
></span>
{/if}
</button>
<button
type="button"
class="invisible absolute top-2 right-2 rounded-md p-1 text-[var(--text-secondary)] group-hover:visible hover:bg-[var(--surface-default)]"
aria-label="Delete notification"
onclick={() => notifications.remove(n.id)}
>
<Trash2 class="h-3.5 w-3.5" />
</button>
</li>
{/each}
</ul>
{/if}
</div>
{#if notifications.error}
<div
class="border-t border-[var(--border-default)] px-3 py-1.5 text-xs text-[var(--color-danger-default)]"
>
{notifications.error}
</div>
{/if}
</div>

View file

@ -1,2 +0,0 @@
export { default as Bell } from './Bell.svelte';
export { default as NotificationPanel } from './NotificationPanel.svelte';

View file

@ -1,7 +0,0 @@
<script>
import { Collapsible as CollapsiblePrimitive } from 'bits-ui';
let { ref = $bindable(null), ...restProps } = $props();
</script>
<CollapsiblePrimitive.Content bind:ref data-slot="collapsible-content" {...restProps} />

View file

@ -1,7 +0,0 @@
<script>
import { Collapsible as CollapsiblePrimitive } from 'bits-ui';
let { ref = $bindable(null), ...restProps } = $props();
</script>
<CollapsiblePrimitive.Trigger bind:ref data-slot="collapsible-trigger" {...restProps} />

View file

@ -1,7 +0,0 @@
<script>
import { Collapsible as CollapsiblePrimitive } from 'bits-ui';
let { ref = $bindable(null), open = $bindable(false), ...restProps } = $props();
</script>
<CollapsiblePrimitive.Root bind:ref bind:open data-slot="collapsible" {...restProps} />

View file

@ -1,13 +0,0 @@
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
};

View file

@ -1,475 +0,0 @@
<script>
import { toast } from 'svelte-sonner';
import { MessageSquare, Send, Trash2, Loader2, ChevronDown, ChevronUp } from '@lucide/svelte';
import { Button } from '#lib/components/ui/button/index.js';
import { Textarea } from '#lib/components/ui/textarea/index.js';
import * as AlertDialog from '#lib/components/ui/alert-dialog/index.js';
import { formatRelativeDate, getInitials } from '#lib/utils/formatting.js';
import * as api from '#lib/api.js';
import MentionBody from './MentionBody.svelte';
/**
* @type {{
* entityId: string,
* entityType: 'leads' | 'accounts' | 'contacts' | 'opportunity' | 'cases' | 'tasks',
* initialComments?: Array<{
* id: string,
* comment: string,
* commented_on: string,
* commented_by?: {
* id: string,
* user_details?: { email?: string }
* }
* }>,
* currentUserEmail?: string,
* isAdmin?: boolean,
* initialDisplayCount?: number,
* showAddComment?: boolean,
* mentionCandidates?: Array<{ username: string, id?: string, email?: string, name?: string }>
* }}
*/
let {
entityId,
entityType,
initialComments = [],
currentUserEmail = '',
isAdmin = false,
initialDisplayCount = 5,
showAddComment = true,
mentionCandidates = []
} = $props();
// State
let comments = $derived(initialComments);
let showAllComments = $state(false);
let newComment = $state('');
let isSubmitting = $state(false);
let deletingCommentId = $state(null);
let deleteDialogOpen = $state(false);
let commentToDelete = $state(null);
// Derived
const displayedComments = $derived(
showAllComments ? comments : comments.slice(0, initialDisplayCount)
);
const hasMoreComments = $derived(comments.length > initialDisplayCount);
// Get API module based on entity type
function getApiModule() {
const modules = {
leads: api.leads,
accounts: api.accounts,
contacts: api.contacts,
opportunity: api.opportunities,
cases: api.tickets,
tasks: api.tasks
};
return modules[entityType];
}
// Check if user can delete a comment (by email match)
function canDeleteComment(comment) {
if (isAdmin) return true;
const commentEmail = comment.commented_by?.user_details?.email;
return commentEmail && currentUserEmail && commentEmail === currentUserEmail;
}
// Get commenter display name from email
function getCommenterName(comment) {
const email = comment.commented_by?.user_details?.email;
if (!email) return 'Unknown';
// Extract name from email (before @)
return email.split('@')[0].replace(/[._]/g, ' ');
}
// Get commenter initials
function getCommenterInitials(comment) {
const name = getCommenterName(comment);
return getInitials(name);
}
// Get avatar background color based on user id
function getAvatarColor(comment) {
const colors = [
'bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300',
'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/50 dark:text-emerald-300',
'bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300',
'bg-purple-100 text-purple-700 dark:bg-purple-900/50 dark:text-purple-300',
'bg-rose-100 text-rose-700 dark:bg-rose-900/50 dark:text-rose-300',
'bg-cyan-100 text-cyan-700 dark:bg-cyan-900/50 dark:text-cyan-300'
];
const id = comment.commented_by?.id || '';
const hash = id.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
return colors[hash % colors.length];
}
// Add comment handler
async function handleAddComment() {
if (!newComment.trim() || isSubmitting) return;
const commentText = newComment.trim();
isSubmitting = true;
// Create optimistic comment
const tempId = `temp-${Date.now()}`;
const optimisticComment = {
id: tempId,
comment: commentText,
commented_on: new Date().toISOString(),
commented_by: {
id: tempId,
user_details: { email: currentUserEmail || 'You' }
}
};
// Optimistic update - prepend new comment
comments = [optimisticComment, ...comments];
newComment = '';
try {
const apiModule = getApiModule();
const result = await apiModule.addComment(entityId, commentText);
// Replace temp comment with real one if response contains it
if (result?.id) {
comments = comments.map((c) => (c.id === tempId ? { ...result, comment: commentText } : c));
}
toast.success('Comment added');
} catch (err) {
// Revert on error
comments = comments.filter((c) => c.id !== tempId);
newComment = commentText; // Restore the text
console.error('Failed to add comment:', err);
toast.error(err?.message || 'Failed to add comment');
} finally {
isSubmitting = false;
}
}
// Delete confirmation
function confirmDelete(comment) {
commentToDelete = comment;
deleteDialogOpen = true;
}
// Delete comment handler
async function handleDeleteComment() {
if (!commentToDelete || deletingCommentId) return;
const commentId = commentToDelete.id;
deletingCommentId = commentId;
deleteDialogOpen = false;
// Store for rollback
const originalComments = [...comments];
// Optimistic remove
comments = comments.filter((c) => c.id !== commentId);
try {
const apiModule = getApiModule();
await apiModule.deleteComment(commentId);
toast.success('Comment deleted');
} catch (err) {
// Revert on error
comments = originalComments;
console.error('Failed to delete comment:', err);
toast.error(err?.message || 'Failed to delete comment');
} finally {
deletingCommentId = null;
commentToDelete = null;
}
}
// --- @mention typeahead ----------------------------------------------------
// Detects an in-progress `@partial` token at the cursor and surfaces a
// dropdown of matching candidates. No network calls, caller passes the
// resolved candidate list via the `mentionCandidates` prop.
const MENTION_TYPING_RE = /(?:^|[^A-Za-z0-9])@([A-Za-z0-9._-]*)$/;
/** @type {HTMLTextAreaElement | undefined} */
/** @type {HTMLTextAreaElement | null} */
let textareaEl = $state(null);
let mentionOpen = $state(false);
let mentionQuery = $state('');
let mentionStart = $state(-1);
let mentionIndex = $state(0);
const mentionMatches = $derived(() => {
if (!mentionOpen) return [];
const q = mentionQuery.toLowerCase();
const list = mentionCandidates || [];
if (!q) return list.slice(0, 6);
return list.filter((c) => c.username.toLowerCase().startsWith(q)).slice(0, 6);
});
function _findMentionStart() {
if (!textareaEl) return;
const caret = textareaEl.selectionStart ?? newComment.length;
const before = newComment.slice(0, caret);
const m = before.match(MENTION_TYPING_RE);
if (!m) {
mentionOpen = false;
mentionStart = -1;
mentionQuery = '';
return;
}
// Position of the `@` character inside `before`.
mentionStart = m.index + (m[0].length - m[1].length - 1);
mentionQuery = m[1];
mentionOpen = true;
mentionIndex = 0;
}
function handleInput() {
_findMentionStart();
}
/** @param {{ username: string }} cand */
function applyMention(cand) {
if (!textareaEl || mentionStart < 0) {
mentionOpen = false;
return;
}
const caret = textareaEl.selectionStart ?? newComment.length;
const before = newComment.slice(0, mentionStart);
const after = newComment.slice(caret);
const insert = `@${cand.username} `;
newComment = before + insert + after;
mentionOpen = false;
mentionStart = -1;
mentionQuery = '';
// Restore caret right after the inserted mention.
queueMicrotask(() => {
if (!textareaEl) return;
const pos = before.length + insert.length;
textareaEl.selectionStart = textareaEl.selectionEnd = pos;
textareaEl.focus();
});
}
// Handle keyboard submit + mention navigation
function handleKeyDown(event) {
if (mentionOpen && mentionMatches().length > 0) {
if (event.key === 'ArrowDown') {
event.preventDefault();
mentionIndex = (mentionIndex + 1) % mentionMatches().length;
return;
}
if (event.key === 'ArrowUp') {
event.preventDefault();
mentionIndex = (mentionIndex - 1 + mentionMatches().length) % mentionMatches().length;
return;
}
if (event.key === 'Enter' || event.key === 'Tab') {
event.preventDefault();
applyMention(mentionMatches()[mentionIndex]);
return;
}
if (event.key === 'Escape') {
event.preventDefault();
mentionOpen = false;
return;
}
}
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
handleAddComment();
}
}
</script>
<div class="space-y-4">
<!-- Header -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-2 text-sm font-medium text-gray-600 dark:text-gray-400">
<MessageSquare class="h-4 w-4" />
<span>Comments</span>
{#if comments.length > 0}
<span
class="rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-600 dark:bg-gray-800 dark:text-gray-400"
>
{comments.length}
</span>
{/if}
</div>
</div>
<!-- Add Comment Form -->
{#if showAddComment}
<div class="space-y-3">
<div class="relative">
<Textarea
bind:ref={textareaEl}
bind:value={newComment}
placeholder={mentionCandidates.length
? 'Write a comment… type @ to mention'
: 'Write a comment...'}
onkeydown={handleKeyDown}
oninput={handleInput}
class="min-h-[80px] resize-none pr-4 pb-8"
disabled={isSubmitting}
/>
{#if mentionOpen && mentionMatches().length > 0}
<ul
class="absolute top-full right-2 left-2 z-30 mt-1 max-h-56 overflow-y-auto rounded-md border border-[var(--border-default)] bg-[var(--surface-default)] shadow-md"
>
{#each mentionMatches() as cand, i (cand.username)}
<li>
<button
type="button"
onmousedown={(ev) => {
ev.preventDefault();
applyMention(cand);
}}
class="flex w-full items-center justify-between gap-2 px-3 py-1.5 text-left text-sm hover:bg-[var(--surface-muted)] {i ===
mentionIndex
? 'bg-[var(--surface-muted)]'
: ''}"
>
<span class="font-medium">@{cand.username}</span>
{#if cand.email && cand.email !== cand.username}
<span class="truncate text-xs text-[var(--text-secondary)]">{cand.email}</span>
{/if}
</button>
</li>
{/each}
</ul>
{/if}
<div class="absolute right-3 bottom-2 left-3 flex items-center justify-between">
<span class="text-[10px] text-gray-400 dark:text-gray-500">
{#if newComment.trim()}
Press <kbd
class="rounded border border-gray-200 bg-gray-50 px-1 py-0.5 text-[10px] font-medium dark:border-gray-700 dark:bg-gray-800"
>Ctrl</kbd
>+<kbd
class="rounded border border-gray-200 bg-gray-50 px-1 py-0.5 text-[10px] font-medium dark:border-gray-700 dark:bg-gray-800"
>Enter</kbd
> to send
{/if}
</span>
<Button
size="sm"
onclick={handleAddComment}
disabled={!newComment.trim() || isSubmitting}
class="h-7 gap-1.5 px-3 text-xs"
>
{#if isSubmitting}
<Loader2 class="h-3 w-3 animate-spin" />
{:else}
<Send class="h-3 w-3" />
{/if}
Send
</Button>
</div>
</div>
</div>
{/if}
<!-- Comments List -->
{#if comments.length === 0}
<div
class="flex flex-col items-center justify-center rounded-lg border border-dashed border-gray-200 py-8 dark:border-gray-800"
>
<div
class="flex h-12 w-12 items-center justify-center rounded-full bg-gray-50 dark:bg-gray-900"
>
<MessageSquare class="h-5 w-5 text-gray-400 dark:text-gray-600" />
</div>
<p class="mt-3 text-sm font-medium text-gray-500 dark:text-gray-400">No comments yet</p>
<p class="mt-1 text-xs text-gray-400 dark:text-gray-500">Be the first to comment</p>
</div>
{:else}
<div class="space-y-1">
{#each displayedComments as comment (comment.id)}
<div
class="group relative rounded-lg border border-transparent px-3 py-3 transition-colors hover:border-gray-100 hover:bg-gray-50/50 dark:hover:border-gray-800 dark:hover:bg-gray-900/30"
>
<div class="flex gap-3">
<!-- Avatar -->
<div
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-semibold uppercase {getAvatarColor(
comment
)}"
>
{getCommenterInitials(comment)}
</div>
<!-- Content -->
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">
{getCommenterName(comment)}
</span>
<span class="text-xs text-gray-400 dark:text-gray-500">
{formatRelativeDate(comment.commented_on)}
</span>
</div>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
<MentionBody body={comment.comment} candidates={mentionCandidates} />
</p>
</div>
<!-- Delete button -->
{#if canDeleteComment(comment)}
<button
type="button"
onclick={() => confirmDelete(comment)}
class="absolute top-2 right-2 rounded-md p-1.5 text-gray-400 opacity-0 transition-all group-hover:opacity-100 hover:bg-red-50 hover:text-red-500 dark:hover:bg-red-950/50 dark:hover:text-red-400"
disabled={deletingCommentId === comment.id}
title="Delete comment"
>
{#if deletingCommentId === comment.id}
<Loader2 class="h-4 w-4 animate-spin" />
{:else}
<Trash2 class="h-4 w-4" />
{/if}
</button>
{/if}
</div>
</div>
{/each}
</div>
<!-- Show More/Less Toggle -->
{#if hasMoreComments}
<button
type="button"
onclick={() => (showAllComments = !showAllComments)}
class="flex w-full items-center justify-center gap-2 rounded-lg border border-gray-200 py-2 text-sm font-medium text-gray-600 transition-colors hover:border-gray-300 hover:bg-gray-50 hover:text-gray-900 dark:border-gray-800 dark:text-gray-400 dark:hover:border-gray-700 dark:hover:bg-gray-900 dark:hover:text-gray-200"
>
{#if showAllComments}
<ChevronUp class="h-4 w-4" />
Show less
{:else}
<ChevronDown class="h-4 w-4" />
View all {comments.length} comments
{/if}
</button>
{/if}
{/if}
</div>
<!-- Delete Confirmation Dialog -->
<AlertDialog.Root bind:open={deleteDialogOpen}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>Delete comment</AlertDialog.Title>
<AlertDialog.Description>
Are you sure you want to delete this comment? This action cannot be undone.
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
<AlertDialog.Action
onclick={handleDeleteComment}
class="bg-red-600 text-white hover:bg-red-700 dark:bg-red-600 dark:hover:bg-red-700"
>
Delete
</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>

View file

@ -1,60 +0,0 @@
<script>
import { SvelteMap } from 'svelte/reactivity';
import { resolve } from '$app/paths';
import { asInternalPath } from '#lib/utils/paths.js';
/**
* Render a comment body with `@username` tokens upgraded to styled chips.
*
* - Tokens with no leading alphanumeric (so emails are skipped).
* - Resolved usernames link to /users/<id>; unresolved ones render as
* plain text so we never break out of the comment thread.
*
* @type {{
* body: string,
* candidates?: Array<{ username: string, id?: string, email?: string }>
* }}
*/
let { body = '', candidates = [] } = $props();
// Mirror the backend regex (cases/notifications.py).
const MENTION_RE = /(?<![A-Za-z0-9])@([A-Za-z0-9._-]+)/g;
const lookup = $derived(() => {
/** @type {Map<string, { id?: string, email?: string }>} */
const m = new SvelteMap();
for (const c of candidates) {
if (!c?.username) continue;
m.set(c.username.toLowerCase(), { id: c.id, email: c.email });
}
return m;
});
/** @returns {Array<any>} */
function tokenize(/** @type {string} */ s) {
const out = [];
let last = 0;
let match;
const re = new RegExp(MENTION_RE.source, MENTION_RE.flags);
while ((match = re.exec(s)) !== null) {
if (match.index > last) {
out.push({ type: 'text', value: s.slice(last, match.index) });
}
const username = match[1];
const resolved = lookup().get(username.toLowerCase()) || null;
out.push({ type: 'mention', username, resolved });
last = match.index + match[0].length;
}
if (last < s.length) out.push({ type: 'text', value: s.slice(last) });
return out;
}
const tokens = $derived(tokenize(body || ''));
</script>
<span class="whitespace-pre-wrap"
>{#each tokens as t, index (index)}{#if t.type === 'text'}{t.value}{:else if t.resolved && t.resolved.id}<a
href={resolve(asInternalPath(`/users/${t.resolved.id}`))}
class="rounded bg-[var(--color-primary-default)]/10 px-1 font-medium text-[var(--color-primary-default)] hover:underline"
>@{t.username}</a
>{:else}@{t.username}{/if}{/each}</span
>

View file

@ -1,139 +0,0 @@
<script>
import { Textarea } from '#lib/components/ui/textarea/index.js';
/**
* Textarea with `@username` typeahead. Accepts the same props as the
* underlying `Textarea` plus a `candidates` list. When the cursor sits
* just after a typed `@`, a small popup surfaces matching usernames;
* Enter / Tab inserts the selected candidate.
*
* @type {{
* value: string,
* candidates?: Array<{ username: string, email?: string }>,
* placeholder?: string,
* name?: string,
* class?: string,
* disabled?: boolean
* }}
*/
let {
value = $bindable(''),
candidates = [],
placeholder = '',
name = undefined,
class: cls = '',
disabled = false
} = $props();
const MENTION_TYPING_RE = /(?:^|[^A-Za-z0-9])@([A-Za-z0-9._-]*)$/;
/** @type {HTMLTextAreaElement | null} */
let textareaEl = $state(null);
let open = $state(false);
let query = $state('');
let startPos = $state(-1);
let cursorIdx = $state(0);
const matches = $derived(() => {
if (!open) return [];
const q = query.toLowerCase();
const list = candidates || [];
if (!q) return list.slice(0, 6);
return list.filter((c) => c.username.toLowerCase().startsWith(q)).slice(0, 6);
});
function _detect() {
if (!textareaEl) return;
const caret = textareaEl.selectionStart ?? value.length;
const before = value.slice(0, caret);
const m = before.match(MENTION_TYPING_RE);
if (!m) {
open = false;
startPos = -1;
query = '';
return;
}
startPos = m.index + (m[0].length - m[1].length - 1);
query = m[1];
open = true;
cursorIdx = 0;
}
/** @param {{ username: string }} cand */
function apply(cand) {
if (!textareaEl || startPos < 0) {
open = false;
return;
}
const caret = textareaEl.selectionStart ?? value.length;
const before = value.slice(0, startPos);
const after = value.slice(caret);
const insert = `@${cand.username} `;
value = before + insert + after;
open = false;
startPos = -1;
query = '';
queueMicrotask(() => {
if (!textareaEl) return;
const pos = before.length + insert.length;
textareaEl.selectionStart = textareaEl.selectionEnd = pos;
textareaEl.focus();
});
}
function onkeydown(/** @type {KeyboardEvent} */ ev) {
if (!open || matches().length === 0) return;
if (ev.key === 'ArrowDown') {
ev.preventDefault();
cursorIdx = (cursorIdx + 1) % matches().length;
} else if (ev.key === 'ArrowUp') {
ev.preventDefault();
cursorIdx = (cursorIdx - 1 + matches().length) % matches().length;
} else if (ev.key === 'Enter' || ev.key === 'Tab') {
ev.preventDefault();
apply(matches()[cursorIdx]);
} else if (ev.key === 'Escape') {
ev.preventDefault();
open = false;
}
}
</script>
<div class="relative">
<Textarea
bind:ref={textareaEl}
bind:value
{name}
{placeholder}
class={cls}
{disabled}
oninput={_detect}
{onkeydown}
/>
{#if open && matches().length > 0}
<ul
class="absolute top-full right-2 left-2 z-30 mt-1 max-h-56 overflow-y-auto rounded-md border border-[var(--border-default)] bg-[var(--surface-default)] shadow-md"
>
{#each matches() as cand, i (cand.username)}
<li>
<button
type="button"
onmousedown={(ev) => {
ev.preventDefault();
apply(cand);
}}
class="flex w-full items-center justify-between gap-2 px-3 py-1.5 text-left text-sm hover:bg-[var(--surface-muted)] {i ===
cursorIdx
? 'bg-[var(--surface-muted)]'
: ''}"
>
<span class="font-medium">@{cand.username}</span>
{#if cand.email && cand.email !== cand.username}
<span class="truncate text-xs text-[var(--text-secondary)]">{cand.email}</span>
{/if}
</button>
</li>
{/each}
</ul>
{/if}
</div>

View file

@ -1,3 +0,0 @@
export { default as CommentSection } from './CommentSection.svelte';
export { default as MentionBody } from './MentionBody.svelte';
export { default as MentionTextarea } from './MentionTextarea.svelte';

View file

@ -1,6 +0,0 @@
export const SIDEBAR_COOKIE_NAME = 'sidebar:state';
export const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
export const SIDEBAR_WIDTH = '248px';
export const SIDEBAR_WIDTH_MOBILE = '18rem';
export const SIDEBAR_WIDTH_ICON = '56px';
export const SIDEBAR_KEYBOARD_SHORTCUT = 'b';

View file

@ -1,61 +0,0 @@
import { IsMobile } from '#lib/hooks/is-mobile.svelte.js';
import { getContext, setContext } from 'svelte';
import { SIDEBAR_KEYBOARD_SHORTCUT } from './constants.js';
class SidebarState {
props;
open = $derived.by(() => this.props.open());
openMobile = $state(false);
setOpen;
#isMobile;
state = $derived.by(() => (this.open ? 'expanded' : 'collapsed'));
constructor(props) {
this.setOpen = props.setOpen;
this.#isMobile = new IsMobile();
this.props = props;
}
// Convenience getter for checking if the sidebar is mobile
// without this, we would need to use `sidebar.isMobile.current` everywhere
get isMobile() {
return this.#isMobile.current;
}
// Event handler to apply to the `<svelte:window>`
handleShortcutKeydown = (e) => {
if (e.key === SIDEBAR_KEYBOARD_SHORTCUT && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
this.toggle();
}
};
setOpenMobile = (value) => {
this.openMobile = value;
};
toggle = () => {
return this.#isMobile.current ? (this.openMobile = !this.openMobile) : this.setOpen(!this.open);
};
}
const SYMBOL_KEY = 'scn-sidebar';
/**
* Instantiates a new `SidebarState` instance and sets it in the context.
*
* @param props The constructor props for the `SidebarState` class.
* @returns The `SidebarState` instance.
*/
export function setSidebar(props) {
return setContext(Symbol.for(SYMBOL_KEY), new SidebarState(props));
}
/**
* Retrieves the `SidebarState` instance from the context. This is a class instance,
* so you cannot destructure it.
* @returns The `SidebarState` instance.
*/
export function useSidebar() {
return getContext(Symbol.for(SYMBOL_KEY));
}

View file

@ -1,54 +0,0 @@
import { useSidebar } from './context.svelte.js';
import Content from './sidebar-content.svelte';
import Footer from './sidebar-footer.svelte';
import GroupContent from './sidebar-group-content.svelte';
import GroupLabel from './sidebar-group-label.svelte';
import Group from './sidebar-group.svelte';
import Header from './sidebar-header.svelte';
import Inset from './sidebar-inset.svelte';
import MenuButton from './sidebar-menu-button.svelte';
import MenuItem from './sidebar-menu-item.svelte';
import MenuSubButton from './sidebar-menu-sub-button.svelte';
import MenuSubItem from './sidebar-menu-sub-item.svelte';
import MenuSub from './sidebar-menu-sub.svelte';
import Menu from './sidebar-menu.svelte';
import Provider from './sidebar-provider.svelte';
import Rail from './sidebar-rail.svelte';
import Root from './sidebar.svelte';
export {
Content,
Footer,
Group,
GroupContent,
GroupLabel,
Header,
Inset,
Menu,
MenuButton,
MenuItem,
MenuSub,
MenuSubButton,
MenuSubItem,
Provider,
Rail,
Root,
//
Root as Sidebar,
Content as SidebarContent,
Footer as SidebarFooter,
Group as SidebarGroup,
GroupContent as SidebarGroupContent,
GroupLabel as SidebarGroupLabel,
Header as SidebarHeader,
Inset as SidebarInset,
Menu as SidebarMenu,
MenuButton as SidebarMenuButton,
MenuItem as SidebarMenuItem,
MenuSub as SidebarMenuSub,
MenuSubButton as SidebarMenuSubButton,
MenuSubItem as SidebarMenuSubItem,
Provider as SidebarProvider,
Rail as SidebarRail,
useSidebar
};

View file

@ -1,26 +0,0 @@
<script>
import { cn } from '#lib/utils.js';
/**
* @type {{
* ref?: HTMLElement | null,
* class?: string,
* children?: import('svelte').Snippet,
* [key: string]: any
* }}
*/
let { ref = $bindable(null), class: className, children, ...restProps } = $props();
</script>
<div
bind:this={ref}
data-slot="sidebar-content"
data-sidebar="content"
class={cn(
'flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden',
className
)}
{...restProps}
>
{@render children?.()}
</div>

View file

@ -1,23 +0,0 @@
<script>
import { cn } from '#lib/utils.js';
/**
* @type {{
* ref?: HTMLElement | null,
* class?: string,
* children?: import('svelte').Snippet,
* [key: string]: any
* }}
*/
let { ref = $bindable(null), class: className, children, ...restProps } = $props();
</script>
<div
bind:this={ref}
data-slot="sidebar-footer"
data-sidebar="footer"
class={cn('flex flex-col gap-2 p-2', className)}
{...restProps}
>
{@render children?.()}
</div>

View file

@ -1,22 +0,0 @@
<script>
import { cn } from '#lib/utils.js';
/**
* @type {{
* ref?: HTMLElement | null,
* class?: string,
* children?: import('svelte').Snippet,
* [key: string]: any
* }}
*/
let { ref = $bindable(null), class: className, children, ...restProps } = $props();
</script>
<div
bind:this={ref}
data-slot="sidebar-group-content"
data-sidebar="group-content"
class={cn('w-full text-sm', className)}
{...restProps}
>
{@render children?.()}
</div>

View file

@ -1,32 +0,0 @@
<script>
import { cn } from '#lib/utils.js';
/**
* @type {{
* ref?: HTMLElement | null,
* children?: import('svelte').Snippet,
* child?: import('svelte').Snippet<[{ props: Record<string, any> }]>,
* class?: string,
* [key: string]: any
* }}
*/
let { ref = $bindable(null), children, child, class: className, ...restProps } = $props();
const mergedProps = $derived({
class: cn(
'text-sidebar-foreground/70 ring-sidebar-ring outline-hidden flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
'group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0',
className
),
'data-slot': 'sidebar-group-label',
'data-sidebar': 'group-label',
...restProps
});
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div bind:this={ref} {...mergedProps}>
{@render children?.()}
</div>
{/if}

View file

@ -1,23 +0,0 @@
<script>
import { cn } from '#lib/utils.js';
/**
* @type {{
* ref?: HTMLElement | null,
* class?: string,
* children?: import('svelte').Snippet,
* [key: string]: any
* }}
*/
let { ref = $bindable(null), class: className, children, ...restProps } = $props();
</script>
<div
bind:this={ref}
data-slot="sidebar-group"
data-sidebar="group"
class={cn('relative flex w-full min-w-0 flex-col p-2', className)}
{...restProps}
>
{@render children?.()}
</div>

View file

@ -1,23 +0,0 @@
<script>
import { cn } from '#lib/utils.js';
/**
* @type {{
* ref?: HTMLElement | null,
* class?: string,
* children?: import('svelte').Snippet,
* [key: string]: any
* }}
*/
let { ref = $bindable(null), class: className, children, ...restProps } = $props();
</script>
<div
bind:this={ref}
data-slot="sidebar-header"
data-sidebar="header"
class={cn('flex flex-col gap-2 p-2', className)}
{...restProps}
>
{@render children?.()}
</div>

View file

@ -1,17 +0,0 @@
<script>
import { cn } from '#lib/utils.js';
let { ref = $bindable(null), class: className, children, ...restProps } = $props();
</script>
<main
bind:this={ref}
data-slot="sidebar-inset"
class={cn(
'bg-background relative flex w-full flex-1 flex-col',
'md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ms-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ms-2',
className
)}
{...restProps}
>
{@render children?.()}
</main>

View file

@ -1,103 +0,0 @@
<script module>
import { tv } from 'tailwind-variants';
export const sidebarMenuButtonVariants = tv({
base: 'peer/menu-button outline-hidden ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground group-has-data-[sidebar=menu-action]/menu-item:pe-8 data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-start text-sm transition-[width,height,padding] focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:font-medium [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
variants: {
variant: {
default: 'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
outline:
'bg-background hover:bg-sidebar-accent hover:text-sidebar-accent-foreground shadow-[0_0_0_1px_var(--sidebar-border)] hover:shadow-[0_0_0_1px_var(--sidebar-accent)]'
},
size: {
default: 'h-8 text-sm',
sm: 'h-7 text-xs',
lg: 'group-data-[collapsible=icon]:p-0! h-12 text-sm'
}
},
defaultVariants: {
variant: 'default',
size: 'default'
}
});
</script>
<script>
import * as Tooltip from '#lib/components/ui/tooltip/index.js';
import { cn } from '#lib/utils.js';
import { mergeProps } from 'bits-ui';
import { useSidebar } from './context.svelte.js';
/**
* @type {{
* ref?: HTMLElement | null,
* class?: string,
* children?: import('svelte').Snippet,
* child?: import('svelte').Snippet<[{ props: Record<string, any> }]>,
* variant?: 'default' | 'outline',
* size?: 'default' | 'sm' | 'lg',
* isActive?: boolean,
* tooltipContent?: string | import('svelte').Snippet,
* tooltipContentProps?: Record<string, any>,
* [key: string]: any
* }}
*/
let {
ref = $bindable(null),
class: className,
children,
child,
variant = 'default',
size = 'default',
isActive = false,
tooltipContent,
tooltipContentProps,
...restProps
} = $props();
const sidebar = useSidebar();
const buttonProps = $derived({
class: cn(sidebarMenuButtonVariants({ variant, size }), className),
'data-slot': 'sidebar-menu-button',
'data-sidebar': 'menu-button',
'data-size': size,
'data-active': isActive,
...restProps
});
</script>
{#snippet Button({ props })}
{@const mergedProps = mergeProps(buttonProps, props)}
{#if child}
{@render child({ props: mergedProps })}
{:else}
<button bind:this={ref} {...mergedProps}>
{@render children?.()}
</button>
{/if}
{/snippet}
{#if !tooltipContent}
{@render Button({ props: {} })}
{:else}
<Tooltip.Root>
<Tooltip.Trigger>
{#snippet child({ props })}
{@render Button({ props })}
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content
side="right"
align="center"
hidden={sidebar.state !== 'collapsed' || sidebar.isMobile}
{...tooltipContentProps}
>
{#if typeof tooltipContent === 'string'}
{tooltipContent}
{:else if tooltipContent}
{@render tooltipContent()}
{/if}
</Tooltip.Content>
</Tooltip.Root>
{/if}

View file

@ -1,22 +0,0 @@
<script>
import { cn } from '#lib/utils.js';
/**
* @type {{
* ref?: HTMLElement | null,
* class?: string,
* children?: import('svelte').Snippet,
* [key: string]: any
* }}
*/
let { ref = $bindable(null), class: className, children, ...restProps } = $props();
</script>
<li
bind:this={ref}
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
class={cn('group/menu-item relative', className)}
{...restProps}
>
{@render children?.()}
</li>

View file

@ -1,44 +0,0 @@
<script>
import { cn } from '#lib/utils.js';
/**
* @type {{
* ref?: HTMLElement | null,
* children?: import('svelte').Snippet,
* child?: import('svelte').Snippet<[{ props: Record<string, any> }]>,
* class?: string,
* size?: 'sm' | 'md',
* isActive?: boolean,
* [key: string]: any
* }}
*/
let {
ref = $bindable(null),
children,
child,
class: className,
size = 'md',
isActive = false,
...restProps
} = $props();
const mergedProps = $derived({
class: cn(
'ring-sidebar-ring outline-hidden flex min-w-0 items-center gap-2.5 overflow-hidden rounded-md font-medium transition-colors duration-150 focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate',
'group-data-[collapsible=icon]:hidden',
className
),
'data-slot': 'sidebar-menu-sub-button',
'data-sidebar': 'menu-sub-button',
'data-size': size,
'data-active': isActive,
...restProps
});
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<a bind:this={ref} {...mergedProps}>
{@render children?.()}
</a>
{/if}

View file

@ -1,22 +0,0 @@
<script>
import { cn } from '#lib/utils.js';
/**
* @type {{
* ref?: HTMLElement | null,
* children?: import('svelte').Snippet,
* class?: string,
* [key: string]: any
* }}
*/
let { ref = $bindable(null), children, class: className, ...restProps } = $props();
</script>
<li
bind:this={ref}
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
class={cn('group/menu-sub-item relative', className)}
{...restProps}
>
{@render children?.()}
</li>

View file

@ -1,26 +0,0 @@
<script>
import { cn } from '#lib/utils.js';
/**
* @type {{
* ref?: HTMLElement | null,
* class?: string,
* children?: import('svelte').Snippet,
* [key: string]: any
* }}
*/
let { ref = $bindable(null), class: className, children, ...restProps } = $props();
</script>
<ul
bind:this={ref}
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
class={cn(
'border-sidebar-border/40 ml-[22px] flex min-w-0 flex-col gap-0.5 border-l-2 py-1 pl-3.5',
'group-data-[collapsible=icon]:hidden',
className
)}
{...restProps}
>
{@render children?.()}
</ul>

View file

@ -1,22 +0,0 @@
<script>
import { cn } from '#lib/utils.js';
/**
* @type {{
* ref?: HTMLElement | null,
* class?: string,
* children?: import('svelte').Snippet,
* [key: string]: any
* }}
*/
let { ref = $bindable(null), class: className, children, ...restProps } = $props();
</script>
<ul
bind:this={ref}
data-slot="sidebar-menu"
data-sidebar="menu"
class={cn('flex w-full min-w-0 flex-col gap-1', className)}
{...restProps}
>
{@render children?.()}
</ul>

View file

@ -1,49 +0,0 @@
<script>
import * as Tooltip from '#lib/components/ui/tooltip/index.js';
import { cn } from '#lib/utils.js';
import {
SIDEBAR_COOKIE_MAX_AGE,
SIDEBAR_COOKIE_NAME,
SIDEBAR_WIDTH,
SIDEBAR_WIDTH_ICON
} from './constants.js';
import { setSidebar } from './context.svelte.js';
let {
ref = $bindable(null),
open = $bindable(true),
onOpenChange = () => {},
class: className,
style,
children,
...restProps
} = $props();
const sidebar = setSidebar({
open: () => open,
setOpen: (value) => {
open = value;
onOpenChange(value);
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${open}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
}
});
</script>
<svelte:window onkeydown={sidebar.handleShortcutKeydown} />
<Tooltip.Provider delayDuration={0}>
<div
data-slot="sidebar-wrapper"
style="--sidebar-width: {SIDEBAR_WIDTH}; --sidebar-width-icon: {SIDEBAR_WIDTH_ICON}; {style}"
class={cn(
'group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full',
className
)}
bind:this={ref}
{...restProps}
>
{@render children?.()}
</div>
</Tooltip.Provider>

View file

@ -1,38 +0,0 @@
<script>
import { cn } from '#lib/utils.js';
import { useSidebar } from './context.svelte.js';
/**
* @type {{
* ref?: HTMLElement | null,
* class?: string,
* children?: import('svelte').Snippet,
* [key: string]: any
* }}
*/
let { ref = $bindable(null), class: className, children, ...restProps } = $props();
const sidebar = useSidebar();
</script>
<button
bind:this={ref}
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onclick={sidebar.toggle}
title="Toggle Sidebar"
class={cn(
'hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-end-4 group-data-[side=right]:start-0 after:absolute after:inset-y-0 after:start-[calc(1/2*100%-1px)] after:w-[2px] sm:flex',
'in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize',
'[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize',
'hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:start-full',
'[[data-side=left][data-collapsible=offcanvas]_&]:-end-2',
'[[data-side=right][data-collapsible=offcanvas]_&]:-start-2',
className
)}
{...restProps}
>
{@render children?.()}
</button>

View file

@ -1,107 +0,0 @@
<script>
import * as Sheet from '#lib/components/ui/sheet/index.js';
import { cn } from '#lib/utils.js';
import { SIDEBAR_WIDTH_MOBILE } from './constants.js';
import { useSidebar } from './context.svelte.js';
/**
* @type {{
* ref?: HTMLElement | null,
* side?: 'left' | 'right',
* variant?: 'sidebar' | 'floating' | 'inset',
* collapsible?: 'offcanvas' | 'icon' | 'none',
* class?: string,
* children?: import('svelte').Snippet,
* [key: string]: any
* }}
*/
let {
ref = $bindable(null),
side = 'left',
variant = 'sidebar',
collapsible = 'offcanvas',
class: className,
children,
...restProps
} = $props();
const sidebar = useSidebar();
</script>
{#if collapsible === 'none'}
<div
class={cn(
'bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col',
className
)}
bind:this={ref}
{...restProps}
>
{@render children?.()}
</div>
{:else if sidebar.isMobile}
<Sheet.Root bind:open={() => sidebar.openMobile, (v) => sidebar.setOpenMobile(v)} {...restProps}>
<Sheet.Content
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
class="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
style="--sidebar-width: {SIDEBAR_WIDTH_MOBILE};"
{side}
>
<Sheet.Header class="sr-only">
<Sheet.Title>Sidebar</Sheet.Title>
<Sheet.Description>Displays the mobile sidebar.</Sheet.Description>
</Sheet.Header>
<div class="flex h-full w-full flex-col">
{@render children?.()}
</div>
</Sheet.Content>
</Sheet.Root>
{:else}
<div
bind:this={ref}
class="text-sidebar-foreground group peer hidden md:block"
data-state={sidebar.state}
data-collapsible={sidebar.state === 'collapsed' ? collapsible : ''}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
<!-- This is what handles the sidebar gap on desktop -->
<div
data-slot="sidebar-gap"
class={cn(
'relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear',
'group-data-[collapsible=offcanvas]:w-0',
'group-data-[side=right]:rotate-180',
variant === 'floating' || variant === 'inset'
? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]'
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)'
)}
></div>
<div
data-slot="sidebar-container"
class={cn(
'fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex',
side === 'left'
? 'start-0 group-data-[collapsible=offcanvas]:start-[calc(var(--sidebar-width)*-1)]'
: 'end-0 group-data-[collapsible=offcanvas]:end-[calc(var(--sidebar-width)*-1)]',
// Adjust the padding for floating and inset variants.
variant === 'floating' || variant === 'inset'
? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]'
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-e group-data-[side=right]:border-s',
className
)}
{...restProps}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
class="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
>
{@render children?.()}
</div>
</div>
</div>
{/if}

View file

@ -1 +0,0 @@
export { default as Toaster } from './sonner.svelte';

View file

@ -1,13 +0,0 @@
<script>
import { Toaster as Sonner } from 'svelte-sonner';
import { mode } from 'mode-watcher';
let { ...restProps } = $props();
</script>
<Sonner
theme={mode.current}
class="toaster group"
style="--normal-bg: var(--color-popover); --normal-text: var(--color-popover-foreground); --normal-border: var(--color-border);"
{...restProps}
/>

View file

@ -1,21 +0,0 @@
import { Tooltip as TooltipPrimitive } from 'bits-ui';
import Trigger from './tooltip-trigger.svelte';
import Content from './tooltip-content.svelte';
const Root = TooltipPrimitive.Root;
const Provider = TooltipPrimitive.Provider;
const Portal = TooltipPrimitive.Portal;
export {
Root,
Trigger,
Content,
Provider,
Portal,
//
Root as Tooltip,
Content as TooltipContent,
Trigger as TooltipTrigger,
Provider as TooltipProvider,
Portal as TooltipPortal
};

View file

@ -1,58 +0,0 @@
<script>
import { Tooltip as TooltipPrimitive } from 'bits-ui';
import { cn } from '#lib/utils.js';
/**
* @type {{
* ref?: HTMLElement | null,
* class?: string,
* sideOffset?: number,
* side?: 'top' | 'right' | 'bottom' | 'left',
* children?: import('svelte').Snippet,
* arrowClasses?: string,
* align?: 'start' | 'center' | 'end',
* hidden?: boolean,
* [key: string]: any
* }}
*/
let {
ref = $bindable(null),
class: className,
sideOffset = 0,
side = 'top',
children,
arrowClasses,
...restProps
} = $props();
</script>
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
bind:ref
data-slot="tooltip-content"
{sideOffset}
{side}
class={cn(
'bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--bits-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance',
className
)}
{...restProps}
>
{@render children?.()}
<TooltipPrimitive.Arrow>
{#snippet child({ props })}
<div
class={cn(
'bg-primary z-50 size-2.5 rotate-45 rounded-[2px]',
'data-[side=top]:translate-x-1/2 data-[side=top]:translate-y-[calc(-50%_+_2px)]',
'data-[side=bottom]:-translate-x-1/2 data-[side=bottom]:-translate-y-[calc(-50%_+_1px)]',
'data-[side=right]:translate-x-[calc(50%_+_2px)] data-[side=right]:translate-y-1/2',
'data-[side=left]:-translate-y-[calc(50%_-_3px)]',
arrowClasses
)}
{...props}
></div>
{/snippet}
</TooltipPrimitive.Arrow>
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>

View file

@ -1,7 +0,0 @@
<script>
import { Tooltip as TooltipPrimitive } from 'bits-ui';
let { ref = $bindable(null), ...restProps } = $props();
</script>
<TooltipPrimitive.Trigger bind:ref data-slot="tooltip-trigger" {...restProps} />

View file

@ -1,239 +0,0 @@
import { SvelteURLSearchParams } from 'svelte/reactivity';
/**
* In-app notifications store.
*
* Single source of truth for the bell badge, the panel list, and
* svelte-sonner toasts. Talks to the SvelteKit proxies under
* `/api/notifications/...` (which forward to Django with the JWT cookie).
*
* Delivery is by polling `?since=<iso>`. This replaced an EventSource stream
* on 2026-08-03. The SSE endpoint was the only async code in the backend, so
* it forced the entire deployment onto ASGI, where each in-flight request
* takes its own database connection with no ceiling. It was also silently
* delivering nothing under a correctly configured database role, and the
* `error` handler here was an empty placeholder, so in practice the bell
* already only updated on page load. Polling is strictly better than that and
* costs about one concurrent request per 900 open tabs.
*/
import { toast } from 'svelte-sonner';
const PANEL_LIMIT = 20;
// Delivery latency ceiling. Notifications here are "you were assigned a lead",
// not chat, so tens of seconds is fine and the load is flat and predictable
// rather than proportional to how long tabs stay open.
const POLL_INTERVAL_MS = 45_000;
// Verbs that should fire an in-page toast in addition to bumping the badge.
// Keep this conservative: too noisy and users mute the channel.
// NOTE: `case.*` keys are backend wire-format strings. Do not rename.
const TOAST_VERBS = new Set([
'case.mentioned',
'case.assigned',
'case.sla_breached',
'support.replied'
]);
class NotificationsStore {
notifications = $state([]);
unread_count = $state(0);
total_count = $state(0);
loading = $state(false);
error = $state('');
/** @type {ReturnType<typeof setInterval> | null} */
#timer = null;
/** @type {boolean} */
#started = false;
/** @type {boolean} */
#polling = false;
/** High-water mark: `created_at` of the newest row we have ingested. */
/** @type {string | null} */
#since = null;
/** @type {(() => void) | null} */
#onVisibility = null;
async fetch() {
this.loading = true;
this.error = '';
try {
const res = await window.fetch(`/api/notifications/?limit=${PANEL_LIMIT}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
this.notifications = data.results || [];
this.unread_count = data.unread_count ?? 0;
this.total_count = data.count ?? this.notifications.length;
this.#advanceSince(this.notifications);
} catch (err) {
console.error('notifications fetch failed', err);
this.error = 'Could not load notifications.';
} finally {
this.loading = false;
}
}
/** @param {string} id */
async markRead(id) {
const idx = this.notifications.findIndex((n) => n.id === id);
if (idx === -1) return;
if (this.notifications[idx].read_at) return; // already read
// Optimistic
const before = this.notifications[idx].read_at;
this.notifications[idx] = { ...this.notifications[idx], read_at: new Date().toISOString() };
this.unread_count = Math.max(0, this.unread_count - 1);
try {
const res = await window.fetch(`/api/notifications/${id}/read/`, { method: 'POST' });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
} catch (err) {
console.error('notifications markRead failed', err);
// Roll back
this.notifications[idx] = { ...this.notifications[idx], read_at: before };
this.unread_count += 1;
}
}
async markAllRead() {
const now = new Date().toISOString();
const snapshot = this.notifications.map((n) => ({ ...n }));
const prevUnread = this.unread_count;
this.notifications = this.notifications.map((n) => (n.read_at ? n : { ...n, read_at: now }));
this.unread_count = 0;
try {
const res = await window.fetch('/api/notifications/read-all/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}'
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
} catch (err) {
console.error('notifications markAllRead failed', err);
this.notifications = snapshot;
this.unread_count = prevUnread;
}
}
/** @param {string} id */
async remove(id) {
const idx = this.notifications.findIndex((n) => n.id === id);
if (idx === -1) return;
const removed = this.notifications[idx];
this.notifications = this.notifications.filter((n) => n.id !== id);
if (!removed.read_at) this.unread_count = Math.max(0, this.unread_count - 1);
this.total_count = Math.max(0, this.total_count - 1);
try {
const res = await window.fetch(`/api/notifications/${id}/`, { method: 'DELETE' });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
} catch (err) {
console.error('notifications remove failed', err);
this.notifications = [...this.notifications, removed].sort(
(a, b) => new Date(b.created_at).valueOf() - new Date(a.created_at).valueOf()
);
if (!removed.read_at) this.unread_count += 1;
this.total_count += 1;
}
}
start() {
if (this.#started || typeof window === 'undefined') return;
this.#started = true;
this.fetch();
this.#timer = setInterval(() => this.#poll(), POLL_INTERVAL_MS);
// Don't poll a tab nobody is looking at, and catch up immediately when
// the user comes back rather than making them wait out the interval.
this.#onVisibility = () => {
if (document.visibilityState === 'visible') this.#poll();
};
document.addEventListener('visibilitychange', this.#onVisibility);
}
stop() {
if (this.#timer !== null) {
clearInterval(this.#timer);
this.#timer = null;
}
if (this.#onVisibility) {
document.removeEventListener('visibilitychange', this.#onVisibility);
this.#onVisibility = null;
}
this.#started = false;
}
async #poll() {
// Skip while hidden, and never let a slow request stack up behind itself.
if (this.#polling || document.visibilityState === 'hidden') return;
this.#polling = true;
try {
const qs = new SvelteURLSearchParams({ limit: String(PANEL_LIMIT) });
if (this.#since) qs.set('since', this.#since);
const res = await window.fetch(`/api/notifications/?${qs}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
// Oldest first, so the panel ends up newest-first after each unshift
// and the toasts fire in the order things actually happened.
const rows = (data.results || []).slice().reverse();
for (const row of rows) this.#ingest(row);
this.#advanceSince(rows);
// `unread_count` is computed over the whole feed server-side, unlike
// `count`, which is scoped to the `since` window. Trust it over the
// increments #ingest made, so a read on another device converges here.
if (typeof data.unread_count === 'number') this.unread_count = data.unread_count;
} catch (err) {
// A failed poll is not worth surfacing: the next tick retries, and
// showing an error banner for a transient blip is worse than silence.
console.error('notifications poll failed', err);
} finally {
this.#polling = false;
}
}
/**
* Move the high-water mark to the newest `created_at` we have seen.
*
* Compares parsed instants, never the raw strings. DRF omits the
* microseconds component when it is zero, so `...T10:00:00Z` and
* `...T10:00:00.123456Z` differ in length, and under a `Z` suffix the
* shorter one sorts *later* lexicographically ('Z' > '.') while being
* earlier in time. That would advance `since` past a notification and drop
* it. The bug is invisible on the default `TIME_ZONE = "Asia/Kolkata"`
* (a '+' offset happens to sort correctly) and appears only for deployments
* that set `TIME_ZONE = "UTC"`, which is exactly the sort of thing that
* survives every local test and fails in someone else's production.
*
* @param {any[]} rows
*/
#advanceSince(rows) {
let best = this.#since === null ? -Infinity : Date.parse(this.#since);
for (const row of rows) {
if (!row?.created_at) continue;
const t = Date.parse(row.created_at);
if (!Number.isNaN(t) && t > best) {
best = t;
this.#since = row.created_at;
}
}
}
/** @param {any} row */
#ingest(row) {
if (!row || !row.id) return;
if (this.notifications.some((n) => n.id === row.id)) return;
this.notifications = [row, ...this.notifications];
this.total_count += 1;
if (!row.read_at) this.unread_count += 1;
if (TOAST_VERBS.has(row.verb)) {
const message = row.entity_name ? `${row.verb}: ${row.entity_name}` : row.verb;
toast(message, {
description: row.data?.comment_excerpt || undefined,
action: row.link
? { label: 'Open', onClick: () => (window.location.href = row.link) }
: undefined
});
}
}
}
export const notifications = new NotificationsStore();

View file

@ -0,0 +1,30 @@
<script>
import { mentionTokens } from '#lib/v2/mentions.js';
/**
* A comment body with each `@handle` that names a member drawn as a chip.
* An unknown handle stays plain text: the backend notified nobody for it.
*
* @type {{ body: string, people?: import('#lib/v2/mentions.js').MentionPerson[] }}
*/
let { body = '', people = [] } = $props();
const tokens = $derived(mentionTokens(body, people));
</script>
<!-- Hugged on purpose: the parent is white-space: pre-wrap, so any gap is shown. -->
{#each tokens as token, i (i)}{#if token.type === 'mention'}<span
class="mention-chip"
title={token.person.name}>{token.value}</span
>{:else}{token.value}{/if}{/each}
<style>
.mention-chip {
padding: 0 4px;
border-radius: 4px;
/* Not --v2-line-soft: that is the background of our own replies. */
background: var(--v2-line);
color: var(--v2-ink);
font-weight: 600;
}
</style>

View file

@ -0,0 +1,150 @@
<script>
import { mentionAt, insertMention, matchPeople } from '#lib/v2/mentions.js';
/**
* A textarea that offers people from `people` while an `@` handle is being
* typed. Arrow keys move, Enter or Tab inserts, Escape closes. Every other
* attribute goes to the textarea, and `ref` is the textarea element.
*
* @type {{
* value: string,
* people?: import('#lib/v2/mentions.js').MentionPerson[],
* ref?: HTMLTextAreaElement,
* [key: string]: any
* }}
*/
// No fallback on `ref`: Svelte refuses `bind:ref={undefined}` against one.
let { value = $bindable(''), people = [], ref = $bindable(), ...rest } = $props();
const listId = $props.id();
/** @type {{ start: number, query: string } | null} */
let typing = $state(null);
let active = $state(0);
const matches = $derived(typing ? matchPeople(people, typing.query) : []);
const open = $derived(matches.length > 0);
function detect() {
typing = ref ? mentionAt(value, ref.selectionStart) : null;
active = 0;
}
/** @param {import('#lib/v2/mentions.js').MentionPerson} person */
function pick(person) {
if (!typing || !ref) return;
const next = insertMention(value, typing.start, ref.selectionStart, person.username);
value = next.text;
typing = null;
requestAnimationFrame(() => {
ref?.focus();
ref?.setSelectionRange(next.caret, next.caret);
});
}
/** @param {KeyboardEvent} event */
function onkeydown(event) {
if (!open) return;
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault();
const step = event.key === 'ArrowDown' ? 1 : -1;
active = (active + step + matches.length) % matches.length;
} else if (event.key === 'Enter' || event.key === 'Tab') {
event.preventDefault();
pick(matches[active]);
} else if (event.key === 'Escape') {
event.preventDefault();
typing = null;
}
}
/** @param {KeyboardEvent} event */
function onkeyup(event) {
if (['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) detect();
}
</script>
<div class="mention">
<textarea
bind:this={ref}
bind:value
{...rest}
aria-autocomplete="list"
aria-controls={open ? listId : undefined}
aria-activedescendant={open ? `${listId}-${active}` : undefined}
oninput={detect}
onclick={detect}
onblur={() => (typing = null)}
{onkeydown}
{onkeyup}></textarea>
{#if open}
<ul id={listId} class="mention-list" role="listbox" aria-label="People to mention">
{#each matches as person, i (person.username)}
<li
id={`${listId}-${i}`}
role="option"
tabindex="-1"
aria-selected={i === active}
class:active={i === active}
onpointerdown={(event) => {
// Before the textarea blurs, so the pick still knows where the handle is.
event.preventDefault();
pick(person);
}}
>
<span class="mention-name">{person.name}</span>
<span class="mention-handle">@{person.username}</span>
</li>
{/each}
</ul>
{/if}
</div>
<style>
.mention {
position: relative;
}
.mention-list {
position: absolute;
top: 100%;
left: 0;
right: 0;
/* Above .v2-fab (30), below the scrims and drawers (60). */
z-index: 40;
margin: 4px 0 0;
padding: 4px;
list-style: none;
max-height: 240px;
overflow-y: auto;
background: var(--v2-card);
border: 1px solid var(--v2-line);
border-radius: 8px;
}
.mention-list li {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
min-height: 44px;
padding: 0 10px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
}
.mention-list li.active,
.mention-list li:hover {
background: var(--v2-line-soft);
}
.mention-name,
.mention-handle {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mention-name {
color: var(--v2-ink);
font-weight: 550;
}
.mention-handle {
color: var(--v2-slate);
font-size: 12px;
}
</style>

View file

@ -0,0 +1,99 @@
/**
* @mentions in ticket comments, held to the backend's rules: `cases/notifications.py`
* reads `@<handle>` with MENTION_RE and resolves it against the email local part
* of an active member. Mirroring both means the composer only offers handles that
* resolve, and a chip appears only where a notification went out.
*/
// MENTION_RE in cases/notifications.py.
const MENTION = /(?<![A-Za-z0-9])@([A-Za-z0-9._-]+)/g;
const TYPING = /(?<![A-Za-z0-9])@([A-Za-z0-9._-]*)$/;
const LIMIT = 6;
/** @typedef {{ username: string, name: string }} MentionPerson */
/**
* @param {{ id: string, name: string, email: string }[]} people from `getOrgPeopleAndTeams`
* @returns {MentionPerson[]}
*/
export function mentionPeople(people) {
const seen = new Set();
/** @type {MentionPerson[]} */
const out = [];
for (const person of people) {
const username = (person.email ?? '').split('@')[0].toLowerCase();
if (!username || seen.has(username)) continue;
seen.add(username);
out.push({ username, name: person.name });
}
return out;
}
/**
* The handle being typed just before the caret, or null when there is none.
*
* @param {string} text
* @param {number | null} caret
* @returns {{ start: number, query: string } | null}
*/
export function mentionAt(text, caret) {
const end = caret ?? text.length;
const match = text.slice(0, end).match(TYPING);
return match ? { start: end - match[0].length, query: match[1] } : null;
}
/**
* @param {string} text
* @param {number} start where the `@` sits
* @param {number} caret where the typed handle ends
* @param {string} username
* @returns {{ text: string, caret: number }}
*/
export function insertMention(text, start, caret, username) {
const after = text.slice(caret);
const insert = `@${username}${/^\s/.test(after) ? '' : ' '}`;
return { text: text.slice(0, start) + insert + after, caret: start + insert.length };
}
/**
* @param {MentionPerson[]} people
* @param {string} query
* @returns {MentionPerson[]}
*/
export function matchPeople(people, query) {
const q = query.toLowerCase();
const hits = q
? people.filter(
(p) =>
p.username.startsWith(q) ||
p.name
.toLowerCase()
.split(/\s+/)
.some((word) => word.startsWith(q))
)
: people;
return hits.slice(0, LIMIT);
}
/**
* @param {string} body
* @param {MentionPerson[]} people
* @returns {({ type: 'text', value: string } | { type: 'mention', value: string, person: MentionPerson })[]}
*/
export function mentionTokens(body, people) {
const byName = new Map(people.map((p) => [p.username, p]));
/** @type {any[]} */
const out = [];
let last = 0;
for (const match of body.matchAll(MENTION)) {
const at = match.index ?? 0;
if (at > last) out.push({ type: 'text', value: body.slice(last, at) });
const person = byName.get(match[1].toLowerCase());
out.push(
person ? { type: 'mention', value: match[0], person } : { type: 'text', value: match[0] }
);
last = at + match[0].length;
}
if (last < body.length) out.push({ type: 'text', value: body.slice(last) });
return out;
}

View file

@ -0,0 +1,112 @@
import { describe, it, expect } from 'vitest';
import { mentionPeople, mentionAt, insertMention, matchPeople, mentionTokens } from './mentions.js';
const liz = { username: 'liz.rao', name: 'Liz Rao' };
const sam = { username: 'sam', name: 'Sam Okafor' };
describe('mentionPeople', () => {
it('names each person by their email local part, lower-cased, as the backend resolves them', () => {
expect(mentionPeople([{ id: '1', name: 'Liz Rao', email: 'Liz.Rao@acme.com' }])).toEqual([liz]);
});
it('keeps one entry when two people share a local part', () => {
const people = [
{ id: '1', name: 'Sam Okafor', email: 'sam@acme.com' },
{ id: '2', name: 'Sam Lee', email: 'sam@other.com' }
];
expect(mentionPeople(people)).toEqual([sam]);
});
it('skips a person with no email', () => {
expect(mentionPeople([{ id: '1', name: 'Nobody', email: '' }])).toEqual([]);
});
});
describe('mentionAt', () => {
it('finds the handle being typed after @', () => {
expect(mentionAt('Hi @li', 6)).toEqual({ start: 3, query: 'li' });
});
it('opens on a bare @', () => {
expect(mentionAt('@', 1)).toEqual({ start: 0, query: '' });
});
it('ignores an email address', () => {
expect(mentionAt('mail liz@acme', 13)).toBeNull();
});
it('reads only up to the caret', () => {
expect(mentionAt('Hi @liz there', 5)).toEqual({ start: 3, query: 'l' });
});
it('closes once a space follows the handle', () => {
expect(mentionAt('Hi @liz ', 8)).toBeNull();
});
it('treats a missing caret as the end of the text', () => {
expect(mentionAt('@li', null)).toEqual({ start: 0, query: 'li' });
});
});
describe('insertMention', () => {
it('replaces the typed handle and adds a space', () => {
expect(insertMention('Hi @li', 3, 6, 'liz.rao')).toEqual({ text: 'Hi @liz.rao ', caret: 12 });
});
it('keeps the text after the caret', () => {
expect(insertMention('Hi @li there', 3, 6, 'liz.rao')).toEqual({
text: 'Hi @liz.rao there',
caret: 11
});
});
});
describe('matchPeople', () => {
it('matches the start of the handle or of a name, ignoring case', () => {
expect(matchPeople([liz, sam], 'LI')).toEqual([liz]);
expect(matchPeople([liz, sam], 'oka')).toEqual([sam]);
});
it('offers everyone, up to the cap, before anything is typed', () => {
const many = Array.from({ length: 9 }, (_, i) => ({ username: `u${i}`, name: `U ${i}` }));
expect(matchPeople(many, '')).toHaveLength(6);
});
});
describe('mentionTokens', () => {
it('marks a handle that names a member', () => {
expect(mentionTokens('Thanks @liz.rao, done', [liz, sam])).toEqual([
{ type: 'text', value: 'Thanks ' },
{ type: 'mention', value: '@liz.rao', person: liz },
{ type: 'text', value: ', done' }
]);
});
it('matches a handle whatever its case', () => {
expect(mentionTokens('@LIZ.RAO', [liz])).toEqual([
{ type: 'mention', value: '@LIZ.RAO', person: liz }
]);
});
it('leaves an unknown handle as text', () => {
expect(mentionTokens('cc @bob', [liz])).toEqual([
{ type: 'text', value: 'cc ' },
{ type: 'text', value: '@bob' }
]);
});
it('leaves an email address alone', () => {
expect(mentionTokens('mail liz.rao@acme.com', [liz])).toEqual([
{ type: 'text', value: 'mail liz.rao@acme.com' }
]);
});
it('reads a trailing full stop as part of the handle, as the backend does', () => {
// `cases/notifications.py` captures "liz.rao." and resolves nobody, so no
// notification went out and no chip may claim one did.
expect(mentionTokens('Thanks @liz.rao.', [liz])).toEqual([
{ type: 'text', value: 'Thanks ' },
{ type: 'text', value: '@liz.rao.' }
]);
});
});

View file

@ -23,6 +23,8 @@ import {
applySummary
} from '#lib/server/v2/macros.js';
import { getOrgSettings } from '#lib/server/v2/organization.js';
import { getOrgPeopleAndTeams } from '#lib/server/v2/org-people.js';
import { mentionPeople } from '#lib/v2/mentions.js';
import {
listTicketTime,
startTicketTimer,
@ -86,11 +88,12 @@ import { treeRows, subtreeIds, parentCandidates, linkRefusal } from './tree.js';
* @type {import('./$types').PageServerLoad}
*/
export async function load({ cookies, params, locals, url }) {
const [data, timeEntries, approvals, watchers] = await Promise.all([
const [data, timeEntries, approvals, watchers, orgPeople] = await Promise.all([
getTicket({ cookies }, params.id),
listTicketTime({ cookies }, params.id).catch(() => null),
listTicketApprovals({ cookies }, params.id).catch(() => null),
getTicketWatchers({ cookies }, params.id).catch(() => null)
getTicketWatchers({ cookies }, params.id).catch(() => null),
getOrgPeopleAndTeams(cookies)
]);
const merge = { open: false, q: '', targets: /** @type {any[]} */ ([]), error: '' };
@ -162,7 +165,15 @@ export async function load({ cookies, params, locals, url }) {
? { rows: treeRows(tree.root, params.id), rootId: tree.root.id }
: null;
const extras = { time, approvals, watchers, macros, articlePicker };
// Who an @handle can name, for the composer and the chips in the thread.
const extras = {
time,
approvals,
watchers,
macros,
articlePicker,
mentionPeople: mentionPeople(orgPeople.people)
};
if (!hasChildren) return { ...data, ...extras, merge, link, tree: treeView };
return {

View file

@ -6,6 +6,8 @@
import NextAction from '#lib/v2/components/NextAction.svelte';
import Pill from '#lib/v2/components/Pill.svelte';
import Avatar from '#lib/v2/components/Avatar.svelte';
import MentionTextarea from '#lib/v2/components/MentionTextarea.svelte';
import MentionBody from '#lib/v2/components/MentionBody.svelte';
import {
relativeDays,
shortAge,
@ -1357,7 +1359,9 @@
<b style="color:var(--v2-ink);font-weight:600">{m.author}</b>
· internal note · {shortAge(m.at)} ago
</div>
<div style="font-size:13.5px;line-height:1.55;white-space:pre-wrap">{m.body}</div>
<div style="font-size:13.5px;line-height:1.55;white-space:pre-wrap">
<MentionBody body={m.body} people={data.mentionPeople} />
</div>
</div>
{:else}
<div
@ -1380,7 +1384,13 @@
{#if m.subject}
<div style="font-size:12.5px;font-weight:600;margin-bottom:4px">{m.subject}</div>
{/if}
<div style="font-size:13.5px;line-height:1.55;white-space:pre-wrap">{m.body}</div>
<!-- The backend reads mentions in comments and notes, never in emails. -->
<div style="font-size:13.5px;line-height:1.55;white-space:pre-wrap">
{#if m.kind === 'email'}{m.body}{:else}<MentionBody
body={m.body}
people={data.mentionPeople}
/>{/if}
</div>
</div>
</div>
{/if}
@ -1455,14 +1465,15 @@
{/each}
</div>
{/if}
<textarea
bind:this={replyBox}
name="body"
<MentionTextarea
bind:ref={replyBox}
bind:value={body}
people={data.mentionPeople}
name="body"
rows="3"
placeholder={internal ? 'Note for the team…' : 'Write a reply…'}
style="width:100%;border:none;background:transparent;resize:vertical;font:inherit;font-size:13.5px;line-height:1.55;color:var(--v2-ink);outline:none"
></textarea>
/>
<div
style="display:flex;gap:9px;align-items:center;border-top:1px solid var(--v2-line);padding-top:12px;flex-wrap:wrap"
>

View file

@ -17,7 +17,7 @@ Flutter client for the BottleCRM API: Riverpod 3 (hand-written `AsyncNotifier` a
- "Today" is the org's, not the device's: `lib/services/org_date.dart` (`orgToday`, `todayIn`, `orgDaysUntil`, `orgTimeZoneOf`).
- Currency: `Currency.symbolFor(code)` (`lib/data/models/deal.dart`), which never falls back to `$`. Invoice amounts: `money()` and `compactMoney()` in `lib/screens/invoices/invoice_format.dart`.
- Attachments: `lib/services/attachment_upload.dart` (`AttachmentTarget`, `downloadAttachment`) and nowhere else. Each record type posts its own multipart field (`task_attachment`, `lead_attachment`, `opportunity_attachment`, `case_attachment`). A wrong name is answered 200 with nothing stored, so `test/unit/attachment_upload_test.dart` pins all four.
- Forms: `lib/widgets/forms/custom_fields_form.dart`, `unsaved_changes.dart` (`UnsavedChangesGuard`), `multi_select_sheet.dart`. Shared widgets: the `lib/widgets/common/common.dart` barrel and `badge.dart` (`StatusBadge`, `PriorityBadge`, `LabelPill`).
- Forms: `lib/widgets/forms/custom_fields_form.dart`, `unsaved_changes.dart` (`UnsavedChangesGuard`), `multi_select_sheet.dart`. Shared widgets: the `lib/widgets/common/common.dart` barrel and `badge.dart` (`StatusBadge`, `PriorityBadge`, `LabelPill`). @mention chips: `MentionText` in `lib/widgets/common/mention_text.dart`.
## Commands

View file

@ -45,3 +45,4 @@ These are not gaps. Do not build them on mobile without a decision.
| B9 | Web Delete for leads, contacts, accounts, deals and solutions | 2026-09-26 | Found in the 1.12.0 pass (never had a row). Both clients now gate Delete on the server's `can_delete`; mobile's client-side admin-or-creator check (`core/permissions.dart`) is deleted. |
| B7 | Deal board drag gated on the card's `can_move` | 2026-09-27 | The mobile deal board reads `GET /api/opportunities/kanban/` (one call, with the list's pipeline and filters) instead of loading every page of the deals list, and a card drags or offers "Change stage" only when `can_move` is `true` (missing counts as false), like the ticket and lead boards. A column over the 100-card cap shows its real `item_count`. The board endpoint now accepts every deal list filter and its cards carry `tags`, `line_items`, `stage_changed_at` and `updated_at`. Tests: `test/unit/deal_board_provider_test.dart`, `test/screens/deals/deals_list_screen_test.dart`, `opportunity/tests/test_kanban_card_mobile_fields.py`. |
| B11 | Done step after an import | 2026-10-20 | After a successful contacts or leads commit the phone sheet stays open on a done step, as the web's `ImportFlow` does: "Imported N contacts." with a check icon, one line per field the commit left blank (`csvDroppedLines`, by label), and "N rows skipped." with Save skipped rows, which saves the commit's own skipped rows (`csvSkippedRowsCsv`, formula-guarded, `<plural>-skipped-rows.csv`). `CsvImportState.result` keeps the commit's `created`, `skipped` and `dropped`. Done, Close, back and a tap outside all close it and run `onImported`, so the list refreshes and the welcome screen still lands on the imported list; the snackbar is gone for contacts and leads, and tickets keep theirs. Tests: `test/unit/csv_import_provider_test.dart`, `test/widgets/csv_import_sheet_test.dart`, `test/widgets/import_welcome_screen_test.dart`. |
| B12 | @mention someone in a ticket comment, and show mentions as chips | 2026-10-21 | Web `/tickets/[id]`: after `@` the composer offers active org members (`MentionTextarea.svelte`: keyboard or tap, a listbox announced through `aria-activedescendant`) and inserts `@<email local part>`, as the phone's picker does. Comments and notes draw a handle that names a member as a chip on both clients (`MentionBody.svelte`, `mobile/lib/widgets/common/mention_text.dart`); emails stay plain. Both mirror `MENTION_RE`, so a chip means the backend notified someone. Tests: `frontend/src/lib/v2/mentions.test.js`, `mobile/test/widgets/mention_text_test.dart`. |

View file

@ -22,6 +22,7 @@ import '../../services/api_service.dart' show ApiResponse;
import '../../services/attachment_upload.dart';
import '../../data/models/lookup_models.dart';
import '../../widgets/common/common.dart';
import '../../widgets/common/mention_text.dart';
import '../../widgets/forms/multi_select_sheet.dart';
import '../../widgets/tickets/ticket_approval_panel.dart';
import '../../widgets/tickets/ticket_properties_card.dart';
@ -676,6 +677,7 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen>
: 'Add the first comment to get started.',
);
}
final handles = mentionHandles(ref.watch(usersProvider));
return ListView.builder(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 16),
itemCount: comments.length,
@ -684,6 +686,7 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen>
return _CommentTile(
comment: comment,
isInternal: internalIds.contains(comment.id),
handles: handles,
);
},
);
@ -2278,8 +2281,13 @@ class _InfoRow extends StatelessWidget {
class _CommentTile extends StatelessWidget {
final Comment comment;
final bool isInternal;
final Set<String> handles;
const _CommentTile({required this.comment, required this.isInternal});
const _CommentTile({
required this.comment,
required this.isInternal,
required this.handles,
});
@override
Widget build(BuildContext context) {
@ -2321,8 +2329,9 @@ class _CommentTile extends StatelessWidget {
),
const SizedBox(height: 6),
],
Text(
MentionText(
comment.comment,
handles: handles,
style: AppTypography.body.copyWith(
color: AppColors.textPrimary,
height: 1.5,

View file

@ -0,0 +1,68 @@
import 'package:flutter/material.dart';
import '../../core/theme/theme.dart';
import '../../data/models/lookup_models.dart';
/// MENTION_RE in cases/notifications.py. The backend resolves the handle
/// against the email local part of an active member, so a chip appears only
/// where a notification went out. The web mirrors this in `lib/v2/mentions.js`.
final _mention = RegExp(r'(?<![A-Za-z0-9])@([A-Za-z0-9._-]+)');
/// The handles a comment can name: each active member's email local part.
Set<String> mentionHandles(Iterable<UserLookup> users) => {
for (final u in users)
if (u.isActive && u.email.contains('@'))
u.email.split('@').first.toLowerCase(),
};
List<({String text, bool isMention})> mentionTokens(
String body,
Set<String> handles,
) {
final out = <({String text, bool isMention})>[];
var last = 0;
for (final match in _mention.allMatches(body)) {
if (match.start > last) {
out.add((text: body.substring(last, match.start), isMention: false));
}
out.add((
text: match.group(0)!,
isMention: handles.contains(match.group(1)!.toLowerCase()),
));
last = match.end;
}
if (last < body.length) {
out.add((text: body.substring(last), isMention: false));
}
return out;
}
/// A comment body with each `@handle` that names a member drawn as a chip.
class MentionText extends StatelessWidget {
final String text;
final Set<String> handles;
final TextStyle? style;
const MentionText(this.text, {super.key, required this.handles, this.style});
@override
Widget build(BuildContext context) {
return Text.rich(
TextSpan(
style: style,
children: [
for (final token in mentionTokens(text, handles))
TextSpan(
text: token.text,
style: token.isMention
? const TextStyle(
fontWeight: FontWeight.w600,
backgroundColor: AppColors.gray200,
)
: null,
),
],
),
);
}
}

View file

@ -0,0 +1,84 @@
import 'package:bottle_crm/data/models/lookup_models.dart';
import 'package:bottle_crm/widgets/common/mention_text.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('mentionTokens', () {
const handles = {'liz.rao', 'sam'};
List<(String, bool)> read(String body) => [
for (final t in mentionTokens(body, handles)) (t.text, t.isMention),
];
test('marks a handle that names a member', () {
expect(read('Thanks @liz.rao, done'), [
('Thanks ', false),
('@liz.rao', true),
(', done', false),
]);
});
test('matches a handle whatever its case', () {
expect(read('@LIZ.RAO'), [('@LIZ.RAO', true)]);
});
test('leaves an unknown handle as text', () {
expect(read('cc @bob'), [('cc ', false), ('@bob', false)]);
});
test('leaves an email address alone', () {
expect(read('mail liz.rao@acme.com'), [('mail liz.rao@acme.com', false)]);
});
test(
'reads a trailing full stop as part of the handle, as the backend does',
() {
// cases/notifications.py captures "liz.rao." and resolves nobody.
expect(read('Thanks @liz.rao.'), [
('Thanks ', false),
('@liz.rao.', false),
]);
},
);
});
test(
"mentionHandles uses each active member's email local part, lower-cased",
() {
const users = [
UserLookup(
id: '1',
email: 'Liz.Rao@acme.com',
role: 'USER',
isActive: true,
),
UserLookup(
id: '2',
email: 'gone@acme.com',
role: 'USER',
isActive: false,
),
];
expect(mentionHandles(users), {'liz.rao'});
},
);
testWidgets('draws a known handle as a chip and leaves the rest plain', (
tester,
) async {
await tester.pumpWidget(
const MaterialApp(
home: Scaffold(body: MentionText('Ask @sam or @bob', handles: {'sam'})),
),
);
final root = tester.widget<Text>(find.byType(Text)).textSpan! as TextSpan;
final spans = root.children!.cast<TextSpan>();
final sam = spans.firstWhere((s) => s.text == '@sam');
final bob = spans.firstWhere((s) => s.text == '@bob');
expect(sam.style?.fontWeight, FontWeight.w600);
expect(sam.style?.backgroundColor, isNotNull);
expect(bob.style, isNull);
expect(find.text('Ask @sam or @bob', findRichText: true), findsOneWidget);
});
}