clean up
This commit is contained in:
parent
f3a7aa786b
commit
8e45236d5a
56 changed files with 590 additions and 4297 deletions
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
@ -1 +0,0 @@
|
|||
export { default as CustomFieldsPanel } from './CustomFieldsPanel.svelte';
|
||||
|
|
@ -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
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
export { default as Bell } from './Bell.svelte';
|
||||
export { default as NotificationPanel } from './NotificationPanel.svelte';
|
||||
|
|
@ -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} />
|
||||
|
|
@ -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} />
|
||||
|
|
@ -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} />
|
||||
|
|
@ -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
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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';
|
||||
|
|
@ -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';
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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}
|
||||
|
|
@ -1 +0,0 @@
|
|||
export { default as Toaster } from './sonner.svelte';
|
||||
|
|
@ -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}
|
||||
/>
|
||||
|
|
@ -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
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
@ -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} />
|
||||
|
|
@ -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();
|
||||
30
frontend/src/lib/v2/components/MentionBody.svelte
Normal file
30
frontend/src/lib/v2/components/MentionBody.svelte
Normal 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>
|
||||
150
frontend/src/lib/v2/components/MentionTextarea.svelte
Normal file
150
frontend/src/lib/v2/components/MentionTextarea.svelte
Normal 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>
|
||||
99
frontend/src/lib/v2/mentions.js
Normal file
99
frontend/src/lib/v2/mentions.js
Normal 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;
|
||||
}
|
||||
112
frontend/src/lib/v2/mentions.test.js
Normal file
112
frontend/src/lib/v2/mentions.test.js
Normal 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.' }
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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`. |
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
68
mobile/lib/widgets/common/mention_text.dart
Normal file
68
mobile/lib/widgets/common/mention_text.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
84
mobile/test/widgets/mention_text_test.dart
Normal file
84
mobile/test/widgets/mention_text_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue