firstly 0.4.1 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # firstly
2
2
 
3
+ ## 0.4.3
4
+
5
+ ### Patch Changes
6
+
7
+ - [#265](https://github.com/jycouet/firstly/pull/265) [`9105a8b`](https://github.com/jycouet/firstly/commit/9105a8b4ee47121fdfd8c33419c43bfa75f75c5c) Thanks [@jycouet](https://github.com/jycouet)! - mail: store `attachments` count in `Mail.metadata` so the admin UI can show how many files were sent.
8
+
9
+ ## 0.4.2
10
+
11
+ ### Patch Changes
12
+
13
+ - [#258](https://github.com/jycouet/firstly/pull/258) [`43f3f77`](https://github.com/jycouet/firstly/commit/43f3f77aa34f064720c90cdd240ac050a360ecf6) Thanks [@jycouet](https://github.com/jycouet)! - `firstly/changeLog`: export the `ChangeLog` entity class so apps can declare `@Relations` to it (e.g. linking changelog rows back to a `User`).
14
+
15
+ - [#253](https://github.com/jycouet/firstly/pull/253) [`6e203da`](https://github.com/jycouet/firstly/commit/6e203dab7bcbfe043c087703e8c199d42916015f) Thanks [@jycouet](https://github.com/jycouet)! - `firstly/mail`: export `MailSection`, persist nodemailer response into `Mail.metadata.transport` (so provider IDs like Resend's `re_...` are recoverable from the DB), ship drop-in `<WriteMail />` + `<LastMails />` admin components and an opt-in `MailController.sendTest` BackendMethod (`mail({ enableTest: true })`), plus a Resend docs section. Root: new `errorMessage(err)` helper that handles native `Error`, remult `ErrorInfo` rejections, and falls back to JSON instead of `[object Object]`.
16
+
17
+ - [#252](https://github.com/jycouet/firstly/pull/252) [`a4ee628`](https://github.com/jycouet/firstly/commit/a4ee628262ac290901039eba77cdf508dde48a2e) Thanks [@jycouet](https://github.com/jycouet)! - `firstly/sqlAdmin`: drop daisyUI dep, style with raw Tailwind, and add docs page.
18
+
3
19
  ## 0.4.1
4
20
 
5
21
  ### Patch Changes
@@ -1,6 +1,6 @@
1
1
  import { type EntityOptions, type FieldRef, type FieldsRef, type LifecycleEvent } from 'remult';
2
- import { Roles_ChangeLog, type change } from './changeLogEntities';
3
- export { Roles_ChangeLog };
2
+ import { ChangeLog, Roles_ChangeLog, type change } from './changeLogEntities';
3
+ export { ChangeLog, Roles_ChangeLog };
4
4
  export type { change };
5
5
  export interface changeEvent {
6
6
  date: Date;
@@ -1,6 +1,6 @@
1
1
  import { getEntityRef, IdEntity, isBackend, remult, repo, } from 'remult';
2
2
  import { ChangeLog, Roles_ChangeLog } from './changeLogEntities';
3
- export { Roles_ChangeLog };
3
+ export { ChangeLog, Roles_ChangeLog };
4
4
  export async function recordSaved(entity, e, options) {
5
5
  if (isBackend()) {
6
6
  const changes = [];
@@ -0,0 +1,63 @@
1
+ /**
2
+ * A localized message. Either a literal string (single-locale projects) or a
3
+ * function called at validation time (multi-locale projects - typically a
4
+ * paraglide / i18next / lingui message function that resolves against the
5
+ * current request's locale).
6
+ */
7
+ export type LocalizedMessage = string | (() => string);
8
+ export type EmailMessages = {
9
+ /** Returned when the value doesn't match the basic email shape regex. */
10
+ invalid?: LocalizedMessage;
11
+ /** Returned when the domain is malformed (`..`, leading/trailing dot, missing TLD). */
12
+ invalidDomain?: LocalizedMessage;
13
+ /** Returned when the domain is on the blocked list (`example.com`, `test.com`, ...). */
14
+ blockedDomain?: LocalizedMessage;
15
+ /** Returned when the TLD is on the blocked list (`.test`, `.example`, `.invalid`, ...). */
16
+ blockedTld?: LocalizedMessage;
17
+ };
18
+ export type ValidatorMessages = {
19
+ email?: EmailMessages;
20
+ };
21
+ /**
22
+ * Build a project-localized set of validators. Override any subset of
23
+ * messages; defaults are English.
24
+ *
25
+ * Each message can be a literal string OR a function returning a string.
26
+ * Function form is called at validation time (NOT at factory call time), so
27
+ * pass paraglide / i18next / lingui message functions to get per-request
28
+ * locale resolution:
29
+ *
30
+ * ```ts
31
+ * import { createValidators } from '..'
32
+ * import * as m from '../paraglide/messages'
33
+ *
34
+ * export const App_Validators = createValidators({
35
+ * email: {
36
+ * invalid: () => m.email_invalid(),
37
+ * invalidDomain: () => m.email_invalid_domain(),
38
+ * blockedDomain: () => m.email_blocked_domain(),
39
+ * blockedTld: () => m.email_blocked_tld(),
40
+ * },
41
+ * })
42
+ * ```
43
+ *
44
+ * Each validator group exposes two members:
45
+ * - `checkXxx(value)` - pure function, returns `true` on valid or a
46
+ * localized error message string. Use for live UI feedback.
47
+ * - `xxx` - a Remult `Validator` wrapping the same `checkXxx`. Use as
48
+ * `@Fields.string({ validate: app.email })`.
49
+ */
50
+ export declare function createValidators(messages?: ValidatorMessages): {
51
+ checkEmail: (value: string) => true | string;
52
+ email: import("remult").Validator<string>;
53
+ };
54
+ /**
55
+ * Default English validators. For localized messages, build your own with
56
+ * `createValidators(messages)` and import that one in your project instead.
57
+ */
58
+ export declare const FF_Validators: {
59
+ checkEmail: (value: string) => true | string;
60
+ email: import("remult").Validator<string>;
61
+ };
62
+ /** Convenience direct export of `FF_Validators.checkEmail` for ad-hoc use. */
63
+ export declare const checkEmail: (value: string) => true | string;
@@ -0,0 +1,97 @@
1
+ import { createValueValidator } from 'remult';
2
+ // RFC 2606 reserved test/example domains and obvious placeholders.
3
+ const BLOCKED_EMAIL_DOMAINS = new Set([
4
+ 'example.com',
5
+ 'example.net',
6
+ 'example.org',
7
+ 'test.com',
8
+ 'test.net',
9
+ 'test.org',
10
+ 'foo.com',
11
+ 'foo.bar',
12
+ 'bar.com',
13
+ 'baz.com',
14
+ 'domain.com',
15
+ 'mail.com',
16
+ 'localhost',
17
+ 'invalid',
18
+ ]);
19
+ const BLOCKED_EMAIL_TLDS = new Set(['test', 'example', 'invalid', 'localhost', 'local']);
20
+ const EMAIL_SHAPE_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
21
+ const DEFAULT_EMAIL_MESSAGES = {
22
+ invalid: 'Invalid email',
23
+ invalidDomain: 'Invalid domain',
24
+ blockedDomain: 'Test/example email not accepted',
25
+ blockedTld: 'Test/example TLD not accepted',
26
+ };
27
+ /** Resolve a `LocalizedMessage` to its current string value. */
28
+ function resolve(m) {
29
+ return typeof m === 'function' ? m() : m;
30
+ }
31
+ /**
32
+ * Build a project-localized set of validators. Override any subset of
33
+ * messages; defaults are English.
34
+ *
35
+ * Each message can be a literal string OR a function returning a string.
36
+ * Function form is called at validation time (NOT at factory call time), so
37
+ * pass paraglide / i18next / lingui message functions to get per-request
38
+ * locale resolution:
39
+ *
40
+ * ```ts
41
+ * import { createValidators } from '..'
42
+ * import * as m from '../paraglide/messages'
43
+ *
44
+ * export const App_Validators = createValidators({
45
+ * email: {
46
+ * invalid: () => m.email_invalid(),
47
+ * invalidDomain: () => m.email_invalid_domain(),
48
+ * blockedDomain: () => m.email_blocked_domain(),
49
+ * blockedTld: () => m.email_blocked_tld(),
50
+ * },
51
+ * })
52
+ * ```
53
+ *
54
+ * Each validator group exposes two members:
55
+ * - `checkXxx(value)` - pure function, returns `true` on valid or a
56
+ * localized error message string. Use for live UI feedback.
57
+ * - `xxx` - a Remult `Validator` wrapping the same `checkXxx`. Use as
58
+ * `@Fields.string({ validate: app.email })`.
59
+ */
60
+ export function createValidators(messages = {}) {
61
+ const m = {
62
+ ...DEFAULT_EMAIL_MESSAGES,
63
+ ...messages.email,
64
+ };
65
+ function checkEmail(value) {
66
+ if (value === '')
67
+ return true;
68
+ if (!EMAIL_SHAPE_RE.test(value))
69
+ return resolve(m.invalid);
70
+ const at = value.lastIndexOf('@');
71
+ const domain = value.slice(at + 1).toLowerCase();
72
+ if (!domain || domain.includes('..') || domain.startsWith('.') || domain.endsWith('.')) {
73
+ return resolve(m.invalidDomain);
74
+ }
75
+ if (BLOCKED_EMAIL_DOMAINS.has(domain))
76
+ return resolve(m.blockedDomain);
77
+ const tld = domain.split('.').pop() ?? '';
78
+ if (BLOCKED_EMAIL_TLDS.has(tld))
79
+ return resolve(m.blockedTld);
80
+ if (!domain.includes('.') || tld.length < 2)
81
+ return resolve(m.invalidDomain);
82
+ return true;
83
+ }
84
+ return {
85
+ checkEmail,
86
+ // Pass a function so the per-request locale is resolved when the
87
+ // validator actually fires, not when the validator is built.
88
+ email: createValueValidator(checkEmail, () => resolve(m.invalid)),
89
+ };
90
+ }
91
+ /**
92
+ * Default English validators. For localized messages, build your own with
93
+ * `createValidators(messages)` and import that one in your project instead.
94
+ */
95
+ export const FF_Validators = createValidators();
96
+ /** Convenience direct export of `FF_Validators.checkEmail` for ad-hoc use. */
97
+ export const checkEmail = FF_Validators.checkEmail;
@@ -1,2 +1,19 @@
1
1
  import type { ErrorInfo } from 'remult';
2
2
  export declare function isError<T>(object: any): object is ErrorInfo<T>;
3
+ /**
4
+ * Extract a user-readable message from any thrown / rejected value.
5
+ *
6
+ * Why a helper instead of `String(err)`: when a remult `BackendMethod`
7
+ * rejects, the client receives a plain object matching `ErrorInfo`
8
+ * (`{ message?, modelState? }`) - **not** an `Error` instance. The naive
9
+ * `err instanceof Error ? err.message : String(err)` fallback prints
10
+ * `[object Object]` for those rejections and surfaces nothing useful.
11
+ *
12
+ * Order of resolution:
13
+ * 1. plain string -> as-is
14
+ * 2. native `Error` (incl. `EntityError`, `ForbiddenError`) -> `.message`
15
+ * 3. `ErrorInfo`-shaped object with `.message` -> that message
16
+ * 4. `ErrorInfo`-shaped object with `.modelState` -> first non-empty value
17
+ * 5. fallback to `JSON.stringify(err)` (so the shape is at least readable)
18
+ */
19
+ export declare function errorMessage(err: unknown, fallback?: string): string;
@@ -1,3 +1,43 @@
1
1
  export function isError(object) {
2
2
  return object;
3
3
  }
4
+ /**
5
+ * Extract a user-readable message from any thrown / rejected value.
6
+ *
7
+ * Why a helper instead of `String(err)`: when a remult `BackendMethod`
8
+ * rejects, the client receives a plain object matching `ErrorInfo`
9
+ * (`{ message?, modelState? }`) - **not** an `Error` instance. The naive
10
+ * `err instanceof Error ? err.message : String(err)` fallback prints
11
+ * `[object Object]` for those rejections and surfaces nothing useful.
12
+ *
13
+ * Order of resolution:
14
+ * 1. plain string -> as-is
15
+ * 2. native `Error` (incl. `EntityError`, `ForbiddenError`) -> `.message`
16
+ * 3. `ErrorInfo`-shaped object with `.message` -> that message
17
+ * 4. `ErrorInfo`-shaped object with `.modelState` -> first non-empty value
18
+ * 5. fallback to `JSON.stringify(err)` (so the shape is at least readable)
19
+ */
20
+ export function errorMessage(err, fallback = 'Unknown error') {
21
+ if (err == null)
22
+ return fallback;
23
+ if (typeof err === 'string')
24
+ return err || fallback;
25
+ if (err instanceof Error && err.message)
26
+ return err.message;
27
+ if (typeof err === 'object') {
28
+ const e = err;
29
+ if (typeof e.message === 'string' && e.message)
30
+ return e.message;
31
+ if (e.modelState && typeof e.modelState === 'object') {
32
+ const first = Object.values(e.modelState).find((v) => typeof v === 'string' && v);
33
+ if (typeof first === 'string')
34
+ return first;
35
+ }
36
+ }
37
+ try {
38
+ return JSON.stringify(err);
39
+ }
40
+ catch {
41
+ return String(err);
42
+ }
43
+ }
package/esm/index.d.ts CHANGED
@@ -9,7 +9,9 @@ export { FF_Entity } from './core/FF_Entity.js';
9
9
  export { FF_Role } from './core/common.js';
10
10
  export { FF_Allow } from './core/FF_Allow.js';
11
11
  export { FF_Filter } from './core/FF_Filter.js';
12
- export { isError } from './core/helper.js';
12
+ export { FF_Validators, createValidators } from './core/FF_Validators.js';
13
+ export type { EmailMessages, ValidatorMessages } from './core/FF_Validators.js';
14
+ export { errorMessage, isError } from './core/helper.js';
13
15
  export { tryCatch, tryCatchSync } from './core/tryCatch.js';
14
16
  export type { ResolvedType, UnArray, RecursivePartial } from './core/types.js';
15
17
  export { tw } from './core/tailwind.js';
package/esm/index.js CHANGED
@@ -11,7 +11,8 @@ export { FF_Entity } from './core/FF_Entity.js';
11
11
  export { FF_Role } from './core/common.js';
12
12
  export { FF_Allow } from './core/FF_Allow.js';
13
13
  export { FF_Filter } from './core/FF_Filter.js';
14
- export { isError } from './core/helper.js';
14
+ export { FF_Validators, createValidators } from './core/FF_Validators.js';
15
+ export { errorMessage, isError } from './core/helper.js';
15
16
  export { tryCatch, tryCatchSync } from './core/tryCatch.js';
16
17
  export { tw } from './core/tailwind.js';
17
18
  // Misc primitives still exposed from the root.
@@ -0,0 +1,22 @@
1
+ export declare class MailController {
2
+ /**
3
+ * Drives the bundled `<WriteMail />` component. Sends a single-section
4
+ * mail through `remult.context.sendMail` (set by the `mail()` module) and
5
+ * returns the provider's `messageId` when available.
6
+ *
7
+ * Each of `to`, `cc`, `bcc` is a comma-separated string. Splitting,
8
+ * trimming, lowercasing, and per-entry validation happen here on the
9
+ * server - the client just hands over what the user typed.
10
+ */
11
+ static sendTest(input: {
12
+ to: string;
13
+ cc?: string;
14
+ bcc?: string;
15
+ subject: string;
16
+ body: string;
17
+ }): Promise<{
18
+ ok: boolean;
19
+ messageId: string | null;
20
+ error: string | null;
21
+ }>;
22
+ }
@@ -0,0 +1,68 @@
1
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
6
+ };
7
+ import { BackendMethod, remult } from 'remult';
8
+ import { checkEmail } from '../core/FF_Validators';
9
+ import { Roles_Mail } from './Roles_Mail';
10
+ /** Split on commas, trim, lowercase, drop empties. */
11
+ function parseRecipients(raw) {
12
+ if (!raw)
13
+ return [];
14
+ return raw
15
+ .split(',')
16
+ .map((s) => s.trim().toLowerCase())
17
+ .filter(Boolean);
18
+ }
19
+ export class MailController {
20
+ /**
21
+ * Drives the bundled `<WriteMail />` component. Sends a single-section
22
+ * mail through `remult.context.sendMail` (set by the `mail()` module) and
23
+ * returns the provider's `messageId` when available.
24
+ *
25
+ * Each of `to`, `cc`, `bcc` is a comma-separated string. Splitting,
26
+ * trimming, lowercasing, and per-entry validation happen here on the
27
+ * server - the client just hands over what the user typed.
28
+ */
29
+ static async sendTest(input) {
30
+ if (import.meta.env.SSR) {
31
+ if (!remult.context.sendMail) {
32
+ throw new Error('mail module not registered (call mail() in remultApi.modules)');
33
+ }
34
+ const tos = parseRecipients(input.to);
35
+ const ccs = parseRecipients(input.cc);
36
+ const bccs = parseRecipients(input.bcc);
37
+ if (tos.length === 0 && ccs.length === 0 && bccs.length === 0) {
38
+ throw new Error('At least one recipient is required (to, cc, or bcc).');
39
+ }
40
+ for (const entry of [...tos, ...ccs, ...bccs]) {
41
+ const verdict = checkEmail(entry);
42
+ if (verdict !== true)
43
+ throw new Error(`${verdict}: "${entry}"`);
44
+ }
45
+ const safe = (s) => s.replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' })[c]);
46
+ const r = await remult.context.sendMail('test', {
47
+ to: tos.length === 1 ? tos[0] : tos,
48
+ cc: ccs.length ? (ccs.length === 1 ? ccs[0] : ccs) : undefined,
49
+ bcc: bccs.length ? (bccs.length === 1 ? bccs[0] : bccs) : undefined,
50
+ subject: input.subject,
51
+ sections: [
52
+ {
53
+ html: `<p style="margin:0;white-space:pre-wrap;">${safe(input.body) || '<em>(no body)</em>'}</p>`,
54
+ },
55
+ ],
56
+ });
57
+ return {
58
+ ok: !!r.data,
59
+ messageId: r.data?.messageId ?? null,
60
+ error: r.error ? String(r.error.message ?? r.error) : null,
61
+ };
62
+ }
63
+ throw new Error('sendTest: server-only');
64
+ }
65
+ }
66
+ __decorate([
67
+ BackendMethod({ allowed: Roles_Mail.Mail_Admin })
68
+ ], MailController, "sendTest", null);
@@ -1,6 +1,11 @@
1
1
  import { Log } from '@kitql/helpers';
2
2
  import { Mail } from './Mail';
3
+ export { Mail } from './Mail';
4
+ export { MailController } from './MailController';
3
5
  export { Roles_Mail } from './Roles_Mail';
6
+ export type { MailSection } from './types';
7
+ export { default as WriteMail } from './ui/WriteMail.svelte';
8
+ export { default as LastMails } from './ui/LastMails.svelte';
4
9
  export declare const key = "mail";
5
10
  export declare const log: Log;
6
11
  export declare const mailEntities: {
package/esm/mail/index.js CHANGED
@@ -1,6 +1,10 @@
1
1
  import { Log } from '@kitql/helpers';
2
2
  import { Mail } from './Mail';
3
+ export { Mail } from './Mail';
4
+ export { MailController } from './MailController';
3
5
  export { Roles_Mail } from './Roles_Mail';
6
+ export { default as WriteMail } from './ui/WriteMail.svelte';
7
+ export { default as LastMails } from './ui/LastMails.svelte';
4
8
  export const key = 'mail';
5
9
  export const log = new Log(key);
6
10
  export const mailEntities = {
@@ -1,16 +1,11 @@
1
+ import type { MailSection } from '../types';
1
2
  export type MailStyle = {
2
3
  service: string;
3
4
  primaryColor: string;
4
5
  secondaryColor: string;
5
6
  subject: string;
6
7
  title: string;
7
- sections: {
8
- html: string;
9
- cta?: {
10
- html: string;
11
- link: string;
12
- } | undefined;
13
- }[];
8
+ sections: MailSection[];
14
9
  footer: string;
15
10
  };
16
11
  export declare const toHtml: (args: MailStyle) => string;
@@ -6,6 +6,7 @@ import type SMTPPool from 'nodemailer/lib/smtp-pool';
6
6
  import type SMTPTransport from 'nodemailer/lib/smtp-transport';
7
7
  import type StreamTransport from 'nodemailer/lib/stream-transport';
8
8
  import { Module } from 'remult/server';
9
+ import type { MailSection } from '../types';
9
10
  import { type MailStyle } from './formatMailHelper';
10
11
  declare module 'remult' {
11
12
  interface RemultContext {
@@ -28,6 +29,14 @@ export type MailOptions = GlobalEasyOptions & {
28
29
  transport?: TransportTypes;
29
30
  defaults?: DefaultOptions;
30
31
  };
32
+ /**
33
+ * Register `MailController.sendTest` (the BackendMethod that drives the
34
+ * bundled `<WriteMail />` component). Off by default - exposing a
35
+ * "send any mail to anyone" endpoint should be an explicit opt-in.
36
+ *
37
+ * Gated by `Roles_Mail.Mail_Admin` regardless.
38
+ */
39
+ enableTest?: boolean;
31
40
  };
32
41
  export type SendMail = typeof sendMail;
33
42
  export type SendMailResult = {
@@ -41,15 +50,11 @@ export declare const sendMail: (
41
50
  /** usefull for logs, it has NO impact on the mail itself */
42
51
  topic: string, easyOptions: GlobalEasyOptions & {
43
52
  to: Required<DefaultOptions>['to'];
53
+ cc?: DefaultOptions['cc'];
54
+ bcc?: DefaultOptions['bcc'];
44
55
  subject: Required<DefaultOptions>['subject'];
45
56
  title?: string;
46
- sections: {
47
- html: string;
48
- cta?: {
49
- html: string;
50
- link: string;
51
- } | undefined;
52
- }[];
57
+ sections: MailSection[];
53
58
  }, options?: {
54
59
  nodemailer?: MailOptions['nodemailer'];
55
60
  }) => Promise<SendMailResult>;
@@ -3,6 +3,7 @@ import { remult, repo } from 'remult';
3
3
  import { Module } from 'remult/server';
4
4
  import { cyan, green, magenta, red, sleep, white } from '@kitql/helpers';
5
5
  import { log, mailEntities } from '../index';
6
+ import { MailController } from '../MailController';
6
7
  import { toHtml } from './formatMailHelper';
7
8
  let transporter;
8
9
  let globalOptions;
@@ -64,12 +65,14 @@ export const sendMail = async (topic, easyOptions, options) => {
64
65
  from: easyOptions.from ?? globalOptions?.from,
65
66
  };
66
67
  let { primaryColor, secondaryColor, title, footer, service } = easyOptionsToUse;
67
- const { subject, sections, to } = easyOptionsToUse;
68
+ const { subject, sections, to, cc, bcc } = easyOptionsToUse;
68
69
  service = service ?? 'service';
69
70
  primaryColor = primaryColor ?? '#0d0f70';
70
71
  secondaryColor = secondaryColor ?? '#653eae';
71
72
  title = title ?? subject ?? 'subject';
72
73
  footer = footer ?? 'The team wishes you a great day 🚀';
74
+ const mergedAttachments = options?.nodemailer?.defaults?.attachments ?? globalOptions?.nodemailer?.defaults?.attachments;
75
+ const attachments = Array.isArray(mergedAttachments) ? mergedAttachments.length : 0;
73
76
  const metadata = {
74
77
  service,
75
78
  primaryColor,
@@ -78,12 +81,17 @@ export const sendMail = async (topic, easyOptions, options) => {
78
81
  title,
79
82
  footer,
80
83
  sections,
84
+ cc,
85
+ bcc,
86
+ attachments,
81
87
  };
82
88
  const html = easyOptionsToUse.toHtml ? easyOptionsToUse.toHtml(metadata) : toHtml(metadata);
83
89
  nodemailerOptions = {
84
90
  defaults: {
85
91
  ...globalOptions?.nodemailer?.defaults,
86
92
  to,
93
+ cc,
94
+ bcc,
87
95
  subject,
88
96
  html,
89
97
  ...nodemailerOptions?.defaults,
@@ -100,18 +108,19 @@ export const sendMail = async (topic, easyOptions, options) => {
100
108
  try {
101
109
  if (!globalOptions?.nodemailer?.transport) {
102
110
  const data = await transporter.sendMail({ ...nodemailerOptions.defaults });
103
- log.error(`${magenta(`[${topic}]`)} - ⚠️ ${red(`mail not configured`)} ⚠️
104
- We are still nice and generated you an email preview link (the mail we not really sent):
105
- 👉 ${cyan(String(nodemailer.getTestMessageUrl(data)))}
106
-
107
- To really send mails, check out the doc ${white(`https://firstly.fun/modules/mail`)}.
111
+ const previewUrl = nodemailer.getTestMessageUrl(data) || undefined;
112
+ log.error(`${magenta(`[${topic}]`)} - ⚠️ ${red(`mail not configured`)} ⚠️
113
+ We are still nice and generated you an email preview link (the mail was NOT really sent):
114
+ 👉 ${cyan(String(previewUrl))}
115
+
116
+ To really send mails (likely a missing provider API key), see ${white(`https://firstly.fun/docs/modules/mail`)}.
108
117
  `);
109
118
  await repo(mailEntities.Mail).insert({
110
119
  status: 'transport_not_configured',
111
120
  to: JSON.stringify(to),
112
121
  html: easyOptionsToUse.saveHtml ? html : '',
113
122
  topic,
114
- metadata,
123
+ metadata: { ...metadata, transport: extractTransportInfo(data, previewUrl) },
115
124
  });
116
125
  return { data };
117
126
  }
@@ -123,16 +132,18 @@ export const sendMail = async (topic, easyOptions, options) => {
123
132
  to: JSON.stringify(to),
124
133
  html: easyOptionsToUse.saveHtml ? html : '',
125
134
  topic,
126
- metadata,
135
+ metadata: { ...metadata, transport: extractTransportInfo(data) },
127
136
  });
128
137
  return { data };
129
138
  }
130
139
  }
131
140
  catch (error) {
132
141
  if (error instanceof Error && error.message.includes('Missing credentials for "PLAIN"')) {
133
- log.error(`${magenta(`[${topic}]`)} - ⚠️ ${red(`mail not well configured`)} ⚠️
142
+ log.error(`${magenta(`[${topic}]`)} - ⚠️ ${red(`mail not well configured`)} ⚠️
134
143
  👉 transport used:
135
144
  ${cyan(JSON.stringify(globalOptions?.nodemailer?.transport, null, 2))}
145
+
146
+ Auth was refused - check your provider's API key. Docs: ${white(`https://firstly.fun/docs/modules/mail`)}.
136
147
  `);
137
148
  }
138
149
  else {
@@ -177,20 +188,34 @@ ${cyan(JSON.stringify(globalOptions?.nodemailer?.transport, null, 2))}
177
188
  return { error };
178
189
  }
179
190
  };
180
- const mailModule = new Module({
191
+ /**
192
+ * Captured nodemailer-side metadata persisted on every send. This makes
193
+ * provider-side IDs (e.g. Resend's `re_...` returned via SMTP `messageId`)
194
+ * recoverable from the DB without an extra round-trip to the provider.
195
+ */
196
+ function extractTransportInfo(data, preview) {
197
+ return {
198
+ messageId: data.messageId,
199
+ response: data.response,
200
+ accepted: data.accepted,
201
+ rejected: data.rejected,
202
+ envelope: data.envelope,
203
+ preview,
204
+ };
205
+ }
206
+ export const mail = (o) => new Module({
181
207
  key: 'mail',
182
208
  priority: -888,
183
209
  entities: Object.values(mailEntities),
184
- });
185
- export const mail = (o) => {
186
- mailModule.initApi = () => {
210
+ // Opt-in: only register the test endpoint when the consumer asks for it.
211
+ controllers: o?.enableTest ? [MailController] : [],
212
+ initApi: () => {
187
213
  initMail(o);
188
214
  // Need to init in the 2 places!
189
215
  remult.context.sendMail = sendMail;
190
- };
191
- mailModule.initRequest = async () => {
216
+ },
217
+ initRequest: async () => {
192
218
  // Need to init in the 2 places!
193
219
  remult.context.sendMail = sendMail;
194
- };
195
- return mailModule;
196
- };
220
+ },
221
+ });
@@ -0,0 +1,11 @@
1
+ /**
2
+ * One section in a mail body. Pure structural type, safe to import on the
3
+ * client when building sections from a UI.
4
+ */
5
+ export type MailSection = {
6
+ html: string;
7
+ cta?: {
8
+ html: string;
9
+ link: string;
10
+ };
11
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,184 @@
1
+ <script lang="ts">
2
+ import { onDestroy, onMount } from 'svelte'
3
+
4
+ import { remult, repo } from 'remult'
5
+
6
+ import { Mail } from '../Mail'
7
+ import { Roles_Mail } from '../Roles_Mail'
8
+
9
+ const hasAccess = $derived(remult.user?.roles?.includes(Roles_Mail.Mail_Admin) ?? false)
10
+
11
+ type Props = {
12
+ limit?: number
13
+ /** Subscribe to a remult `liveQuery` so the list updates over SSE.
14
+ * Default `true`. Set to `false` to fall back to a one-shot fetch on
15
+ * mount (the Refresh button still works either way). */
16
+ live?: boolean
17
+ }
18
+ let { limit = 30, live = true }: Props = $props()
19
+
20
+ let mails: Mail[] = $state([])
21
+ let isLoading = $state(false)
22
+ let error = $state('')
23
+ let unsubscribe: (() => void) | null = null
24
+
25
+ export async function refresh() {
26
+ isLoading = true
27
+ error = ''
28
+ try {
29
+ mails = await repo(Mail).find({ limit })
30
+ } catch (e) {
31
+ error = e instanceof Error ? e.message : String(e)
32
+ } finally {
33
+ isLoading = false
34
+ }
35
+ }
36
+
37
+ onMount(() => {
38
+ if (live) {
39
+ // Pass `orderBy` explicitly: remult keeps the live state sorted on
40
+ // incremental adds/replaces only when `query.options.orderBy` is
41
+ // set (the entity's `defaultOrderBy` only applies to the initial
42
+ // fetch). Without this, new SSE rows would land at the bottom.
43
+ unsubscribe = repo(Mail)
44
+ .liveQuery({ limit, orderBy: { createdAt: 'desc' } })
45
+ .subscribe((res) => {
46
+ mails = res.items
47
+ error = ''
48
+ })
49
+ } else {
50
+ refresh()
51
+ }
52
+ })
53
+
54
+ onDestroy(() => unsubscribe?.())
55
+
56
+ function formatList(v: unknown): string {
57
+ if (v == null) return ''
58
+ if (Array.isArray(v)) return v.join(', ')
59
+ return String(v)
60
+ }
61
+
62
+ function parseTo(raw: string): string {
63
+ try {
64
+ return formatList(JSON.parse(raw))
65
+ } catch {
66
+ return raw
67
+ }
68
+ }
69
+
70
+ function formatDate(d: unknown): string {
71
+ if (!d) return ''
72
+ const date = d instanceof Date ? d : new Date(d as string)
73
+ if (isNaN(date.getTime())) return String(d)
74
+ return date.toLocaleString()
75
+ }
76
+
77
+ function badgeClass(status: Mail['status']): string {
78
+ if (status === 'sent') return 'bg-emerald-500/10 border-emerald-500/40 text-emerald-300'
79
+ if (status === 'transport_not_configured')
80
+ return 'bg-amber-500/10 border-amber-500/40 text-amber-300'
81
+ return 'bg-red-500/10 border-red-500/40 text-red-300'
82
+ }
83
+ </script>
84
+
85
+ <div class="border border-slate-700 bg-slate-800 text-slate-200">
86
+ <header class="flex flex-wrap items-center gap-3 border-b border-slate-700 px-5 py-4">
87
+ <div class="flex flex-col">
88
+ <h2 class="text-lg font-semibold text-slate-100">Last mails</h2>
89
+ <p class="text-sm text-slate-400">Recent mails sent through this app.</p>
90
+ </div>
91
+ {#if hasAccess}
92
+ <button
93
+ type="button"
94
+ onclick={refresh}
95
+ disabled={isLoading}
96
+ class="ml-auto inline-flex items-center gap-2 border border-slate-600 bg-slate-700 px-3 py-1.5 text-sm font-medium text-slate-100 hover:bg-slate-600 disabled:opacity-50"
97
+ >
98
+ {#if isLoading}
99
+ <svg
100
+ class="h-4 w-4 animate-spin"
101
+ viewBox="0 0 24 24"
102
+ fill="none"
103
+ xmlns="http://www.w3.org/2000/svg"
104
+ aria-hidden="true"
105
+ >
106
+ <circle cx="12" cy="12" r="10" stroke="currentColor" stroke-opacity="0.25" stroke-width="4"
107
+ ></circle>
108
+ <path d="M4 12a8 8 0 0 1 8-8" stroke="currentColor" stroke-width="4" stroke-linecap="round"
109
+ ></path>
110
+ </svg>
111
+ {/if}
112
+ Refresh
113
+ </button>
114
+ <span class="text-xs text-slate-500">
115
+ {mails.length} mail{mails.length === 1 ? '' : 's'}
116
+ {#if live}<span class="text-indigo-400">· live</span>{/if}
117
+ </span>
118
+ {/if}
119
+ </header>
120
+
121
+ <div class="p-5">
122
+ {#if !hasAccess}
123
+ <div class="border border-amber-500/40 bg-amber-500/10 p-3 text-sm text-amber-200">
124
+ You need the
125
+ <code class="bg-amber-500/20 px-1 py-0.5 text-xs text-amber-100">Mail.Admin</code>
126
+ role to use this.
127
+ </div>
128
+ {:else if error}
129
+ <div class="border border-red-500/40 bg-red-500/10 p-3 text-sm text-red-200">{error}</div>
130
+ {:else if mails.length === 0}
131
+ <div class="border border-slate-700 bg-slate-900 p-3 text-sm text-slate-400">No mails yet.</div>
132
+ {:else}
133
+ <div class="flex flex-col gap-3">
134
+ {#each mails as m (m.id)}
135
+ {@const subject = m.metadata?.subject as string | undefined}
136
+ {@const cc = formatList(m.metadata?.cc)}
137
+ {@const bcc = formatList(m.metadata?.bcc)}
138
+ {@const messageId = m.metadata?.transport?.messageId as string | undefined}
139
+ {@const preview = m.metadata?.transport?.preview as string | undefined}
140
+ <article class="flex flex-col gap-2 border border-slate-700 bg-slate-900 p-4">
141
+ <div class="flex flex-wrap items-center gap-2">
142
+ <span class="border px-2 py-0.5 text-xs font-medium {badgeClass(m.status)}">{m.status}</span>
143
+ <span
144
+ class="border border-slate-600 bg-slate-700/50 px-2 py-0.5 text-xs font-medium text-slate-200"
145
+ >{m.topic}</span
146
+ >
147
+ <span class="text-xs text-slate-400">{parseTo(m.to)}</span>
148
+ <span class="ml-auto text-xs text-slate-500">{formatDate(m.createdAt)}</span>
149
+ </div>
150
+
151
+ <div class="text-base font-medium text-slate-100">{subject || '(no subject)'}</div>
152
+
153
+ {#if cc}
154
+ <div class="text-xs text-slate-500">cc: {cc}</div>
155
+ {/if}
156
+ {#if bcc}
157
+ <div class="text-xs text-slate-500">bcc: {bcc}</div>
158
+ {/if}
159
+
160
+ {#if preview}
161
+ <div class="text-xs">
162
+ <a
163
+ href={preview}
164
+ target="_blank"
165
+ rel="noopener noreferrer"
166
+ class="text-indigo-400 underline hover:text-indigo-300">preview (mail not really sent)</a
167
+ >
168
+ </div>
169
+ {/if}
170
+
171
+ {#if messageId}
172
+ <div class="text-xs break-all text-slate-500">id: <code>{messageId}</code></div>
173
+ {/if}
174
+
175
+ {#if m.status === 'error' && m.errorInfo}
176
+ <pre
177
+ class="border border-red-500/40 bg-red-500/10 p-2 text-xs whitespace-pre-wrap text-red-200">{m.errorInfo}</pre>
178
+ {/if}
179
+ </article>
180
+ {/each}
181
+ </div>
182
+ {/if}
183
+ </div>
184
+ </div>
@@ -0,0 +1,12 @@
1
+ type Props = {
2
+ limit?: number;
3
+ /** Subscribe to a remult `liveQuery` so the list updates over SSE.
4
+ * Default `true`. Set to `false` to fall back to a one-shot fetch on
5
+ * mount (the Refresh button still works either way). */
6
+ live?: boolean;
7
+ };
8
+ declare const LastMails: import("svelte").Component<Props, {
9
+ refresh: () => Promise<void>;
10
+ }, "">;
11
+ type LastMails = ReturnType<typeof LastMails>;
12
+ export default LastMails;
@@ -0,0 +1,183 @@
1
+ <script lang="ts">
2
+ import { remult } from 'remult'
3
+ import { errorMessage } from '../..'
4
+
5
+ import { MailController } from '../MailController'
6
+ import { Roles_Mail } from '../Roles_Mail'
7
+
8
+ const hasAccess = $derived(remult.user?.roles?.includes(Roles_Mail.Mail_Admin) ?? false)
9
+
10
+ let to = $state('')
11
+ let cc = $state('')
12
+ let bcc = $state('')
13
+ let subject = $state('')
14
+ let body = $state('')
15
+ let isLoading = $state(false)
16
+
17
+ let result: { ok: boolean; messageId: string | null } | null = $state(null)
18
+ let error = $state('')
19
+
20
+ // Bare-minimum client gate: subject + at least one recipient slot has
21
+ // content. Server splits, trims, lowercases, and validates each entry.
22
+ const canSend = $derived(
23
+ subject.trim().length > 0 && (to.trim() || cc.trim() || bcc.trim()).length > 0,
24
+ )
25
+
26
+ async function handleSubmit(e: Event) {
27
+ e.preventDefault()
28
+ if (!canSend) return
29
+ result = null
30
+ error = ''
31
+ isLoading = true
32
+ // We don't gate the request on `hasAccess` (it's a client-only signal):
33
+ // the server cookie-auths via the BackendMethod's `allowed`. The amber
34
+ // notice in the template is for UX only.
35
+ try {
36
+ const r = await MailController.sendTest({ to, cc, bcc, subject, body })
37
+ if (r.ok) {
38
+ result = { ok: true, messageId: r.messageId }
39
+ } else {
40
+ error = r.error ?? 'Unknown error'
41
+ }
42
+ } catch (e) {
43
+ error = errorMessage(e)
44
+ } finally {
45
+ isLoading = false
46
+ }
47
+ }
48
+ </script>
49
+
50
+ <div class="border border-slate-700 bg-slate-800 text-slate-200">
51
+ <header class="border-b border-slate-700 px-5 py-4">
52
+ <h2 class="text-lg font-semibold text-slate-100">Write mail</h2>
53
+ <p class="mt-1 text-sm text-slate-400">Send a test mail through the configured transport.</p>
54
+ </header>
55
+
56
+ <div class="p-5">
57
+ {#if !hasAccess}
58
+ <div class="border border-amber-500/40 bg-amber-500/10 p-3 text-sm text-amber-200">
59
+ You need the
60
+ <code class="bg-amber-500/20 px-1 py-0.5 text-xs text-amber-100">Mail.Admin</code>
61
+ role to use this.
62
+ </div>
63
+ {:else}
64
+ <form onsubmit={handleSubmit} class="flex flex-col gap-4">
65
+ <div class="flex flex-col gap-1">
66
+ <label for="write-mail-to" class="text-xs font-medium tracking-wide text-slate-400 uppercase"
67
+ >To</label
68
+ >
69
+ <input
70
+ id="write-mail-to"
71
+ type="text"
72
+ bind:value={to}
73
+ disabled={isLoading}
74
+ placeholder="someone@example.com, other@example.com"
75
+ class="border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100 placeholder-slate-500 focus:border-indigo-400 focus:outline-none disabled:opacity-50"
76
+ />
77
+ </div>
78
+
79
+ <div class="grid grid-cols-2 gap-4 max-md:grid-cols-1">
80
+ <div class="flex flex-col gap-1">
81
+ <label for="write-mail-cc" class="text-xs font-medium tracking-wide text-slate-400 uppercase"
82
+ >Cc</label
83
+ >
84
+ <input
85
+ id="write-mail-cc"
86
+ type="text"
87
+ bind:value={cc}
88
+ disabled={isLoading}
89
+ placeholder="optional, comma-separated"
90
+ class="border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100 placeholder-slate-500 focus:border-indigo-400 focus:outline-none disabled:opacity-50"
91
+ />
92
+ </div>
93
+ <div class="flex flex-col gap-1">
94
+ <label for="write-mail-bcc" class="text-xs font-medium tracking-wide text-slate-400 uppercase"
95
+ >Bcc</label
96
+ >
97
+ <input
98
+ id="write-mail-bcc"
99
+ type="text"
100
+ bind:value={bcc}
101
+ disabled={isLoading}
102
+ placeholder="optional, comma-separated"
103
+ class="border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100 placeholder-slate-500 focus:border-indigo-400 focus:outline-none disabled:opacity-50"
104
+ />
105
+ </div>
106
+ </div>
107
+
108
+ <div class="flex flex-col gap-1">
109
+ <label
110
+ for="write-mail-subject"
111
+ class="text-xs font-medium tracking-wide text-slate-400 uppercase">Subject</label
112
+ >
113
+ <input
114
+ id="write-mail-subject"
115
+ type="text"
116
+ bind:value={subject}
117
+ disabled={isLoading}
118
+ required
119
+ placeholder="Subject"
120
+ class="border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100 placeholder-slate-500 focus:border-indigo-400 focus:outline-none disabled:opacity-50"
121
+ />
122
+ </div>
123
+
124
+ <div class="flex flex-col gap-1">
125
+ <label for="write-mail-body" class="text-xs font-medium tracking-wide text-slate-400 uppercase"
126
+ >Body</label
127
+ >
128
+ <textarea
129
+ id="write-mail-body"
130
+ bind:value={body}
131
+ disabled={isLoading}
132
+ placeholder="Write your message..."
133
+ class="h-40 w-full border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100 placeholder-slate-500 focus:border-indigo-400 focus:outline-none disabled:opacity-50"
134
+ ></textarea>
135
+ </div>
136
+
137
+ <div class="flex items-center gap-4 border-t border-slate-700 pt-4">
138
+ <button
139
+ type="submit"
140
+ disabled={isLoading || !canSend}
141
+ class="inline-flex items-center gap-2 bg-indigo-500 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-400 disabled:opacity-50"
142
+ >
143
+ {#if isLoading}
144
+ <svg
145
+ class="h-4 w-4 animate-spin"
146
+ viewBox="0 0 24 24"
147
+ fill="none"
148
+ xmlns="http://www.w3.org/2000/svg"
149
+ aria-hidden="true"
150
+ >
151
+ <circle cx="12" cy="12" r="10" stroke="currentColor" stroke-opacity="0.25" stroke-width="4"
152
+ ></circle>
153
+ <path d="M4 12a8 8 0 0 1 8-8" stroke="currentColor" stroke-width="4" stroke-linecap="round"
154
+ ></path>
155
+ </svg>
156
+ {/if}
157
+ Send
158
+ </button>
159
+
160
+ {#if !canSend && !result && !error}
161
+ <span class="text-xs text-slate-500"> Add a subject and at least one recipient. </span>
162
+ {/if}
163
+
164
+ {#if result}
165
+ <div
166
+ class="flex flex-1 items-center gap-2 border border-emerald-500/40 bg-emerald-500/10 px-3 py-1.5 text-sm text-emerald-200"
167
+ >
168
+ <span class="font-medium">Sent</span>
169
+ {#if result.messageId}
170
+ <code class="ml-auto text-xs break-all">{result.messageId}</code>
171
+ {/if}
172
+ </div>
173
+ {/if}
174
+
175
+ {#if error}
176
+ <pre
177
+ class="flex-1 overflow-auto border border-red-500/40 bg-red-500/10 px-3 py-1.5 text-xs whitespace-pre-wrap text-red-200">{error}</pre>
178
+ {/if}
179
+ </div>
180
+ </form>
181
+ {/if}
182
+ </div>
183
+ </div>
@@ -0,0 +1,3 @@
1
+ declare const WriteMail: import("svelte").Component<Record<string, never>, {}, "">;
2
+ type WriteMail = ReturnType<typeof WriteMail>;
3
+ export default WriteMail;
@@ -2,10 +2,15 @@
2
2
  /**
3
3
  * SQL Admin UI.
4
4
  *
5
+ * Dark theme (zinc + indigo accent), styled with raw Tailwind utilities only -
6
+ * no plugin (daisyUI, shadcn, etc.) required. Drop into any Tailwind-powered
7
+ * project and it just works.
8
+ *
5
9
  * Results are logged to the browser console as `for AI: <json rows>` after
6
10
  * each successful query - chrome-devtools / AI agents inspecting the page
7
11
  * can read them with `list_console_messages`.
8
12
  */
13
+ import { log } from '../index'
9
14
  import { SqlAdminController } from '../SqlAdminController'
10
15
 
11
16
  const defaultQuery = `SELECT *
@@ -46,9 +51,7 @@ ORDER BY tablename, indexname`,
46
51
  },
47
52
  } as const
48
53
 
49
- console.info(
50
- '[firstly/SqlAdmin] AI Hint: results are also logged as "for AI:" JSON after each query.',
51
- )
54
+ log.info('AI Hint: results are also logged as "for AI:" JSON after each query.')
52
55
 
53
56
  function setPresetQuery(queryId: keyof typeof queries) {
54
57
  sqlInput = queries[queryId].sql
@@ -60,8 +63,8 @@ ORDER BY tablename, indexname`,
60
63
  error = ''
61
64
  isLoading = true
62
65
  result = { ...(await SqlAdminController.exec(sqlInput)) }
63
- console.info('for AI:', JSON.stringify(result.r.rows))
64
- console.info('for humans:', result)
66
+ log.info('for AI:', JSON.stringify(result.r.rows))
67
+ log.info('for humans:', result)
65
68
  } catch (e) {
66
69
  error = JSON.stringify(e, null, 2)
67
70
  } finally {
@@ -75,72 +78,120 @@ ORDER BY tablename, indexname`,
75
78
  }
76
79
  </script>
77
80
 
78
- <div class="flex flex-col gap-4">
79
- <div class="flex flex-col gap-2">
80
- <h2 class="text-2xl font-bold">SQL Admin</h2>
81
- <p class="text-sm text-base-content/70">
81
+ <div class="border border-slate-700 bg-slate-800 text-slate-200">
82
+ <header class="border-b border-slate-700 px-5 py-4">
83
+ <h2 class="text-lg font-semibold text-slate-100">SQL Admin</h2>
84
+ <p class="mt-1 text-sm text-slate-400">
82
85
  Execute SQL queries directly on the database. Results are displayed below and also logged to the
83
86
  browser console.
84
87
  </p>
85
- </div>
86
- <div class="flex flex-wrap gap-2">
87
- {#each Object.entries(queries) as [id, query] (id)}
88
- <button class="btn btn-outline btn-sm" onclick={() => setPresetQuery(id as keyof typeof queries)}
89
- >{query.label}</button
90
- >
91
- {/each}
92
- </div>
93
- <form onsubmit={handleSubmit} class="flex flex-col gap-4">
94
- <fieldset class="fieldset">
88
+ </header>
89
+
90
+ <div class="flex flex-col gap-4 p-5">
91
+ <div class="flex flex-wrap gap-2">
92
+ {#each Object.entries(queries) as [id, query] (id)}
93
+ <button
94
+ type="button"
95
+ class="border border-slate-600 bg-slate-700 px-3 py-1.5 text-sm font-medium text-slate-100 hover:bg-slate-600 disabled:opacity-50"
96
+ onclick={() => setPresetQuery(id as keyof typeof queries)}>{query.label}</button
97
+ >
98
+ {/each}
99
+ </div>
100
+ <form onsubmit={handleSubmit} class="flex flex-col gap-4">
95
101
  <textarea
96
102
  bind:value={sqlInput}
97
- class="textarea h-52 w-full font-mono"
103
+ class="h-52 w-full border border-slate-700 bg-slate-900 p-3 font-mono text-sm text-slate-100 placeholder-slate-500 focus:border-indigo-400 focus:outline-none disabled:opacity-50"
98
104
  placeholder="Enter SQL command..."
99
105
  disabled={isLoading}
100
106
  ></textarea>
101
- </fieldset>
102
- <div class="flex items-center gap-4">
103
- <button type="submit" class="btn btn-primary" disabled={isLoading}>
104
- {#if isLoading}<span class="loading loading-spinner"></span>{/if}
105
- Execute SQL
106
- </button>
107
- {#if error}<pre class="alert flex-1 text-sm alert-error">{error.replaceAll(
108
- '\\n',
109
- '\n',
110
- )}</pre>{/if}
111
- {#if result}
112
- <div class="alert flex flex-1 justify-between alert-success">
113
- <span class="text-success-content">{result.took.toFixed(0)} ms</span>
114
- <span class="text-success-content">{result.r.rowCount} rows</span>
115
- </div>
116
- {/if}
117
- </div>
118
- </form>
119
- {#if result}
120
- <div class="max-h-[600px] overflow-auto rounded-lg border border-base-300">
121
- {#if result.r.rows && result.r.rows.length > 0}
122
- <table class="table w-full table-zebra bg-base-200">
123
- <thead class="sticky top-0 z-10 bg-base-300">
124
- <tr
125
- >{#each getHeaders(result.r.rows) as header, i (i)}<th>{header}</th>{/each}</tr
107
+ <div class="flex flex-wrap items-center gap-4">
108
+ <button
109
+ type="submit"
110
+ class="inline-flex items-center gap-2 bg-indigo-500 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-400 disabled:opacity-50"
111
+ disabled={isLoading}
112
+ >
113
+ {#if isLoading}
114
+ <svg
115
+ class="h-4 w-4 animate-spin"
116
+ viewBox="0 0 24 24"
117
+ fill="none"
118
+ xmlns="http://www.w3.org/2000/svg"
119
+ aria-hidden="true"
126
120
  >
127
- </thead>
128
- <tbody>
129
- {#each result.r.rows as row, r (r)}
121
+ <circle cx="12" cy="12" r="10" stroke="currentColor" stroke-opacity="0.25" stroke-width="4"
122
+ ></circle>
123
+ <path d="M4 12a8 8 0 0 1 8-8" stroke="currentColor" stroke-width="4" stroke-linecap="round"
124
+ ></path>
125
+ </svg>
126
+ {/if}
127
+ Execute SQL
128
+ </button>
129
+ {#if error}
130
+ <pre
131
+ class="flex-1 overflow-auto border border-red-500/40 bg-red-500/10 p-3 text-sm text-red-200">{error.replaceAll(
132
+ '\\n',
133
+ '\n',
134
+ )}</pre>
135
+ {/if}
136
+ {#if result}
137
+ <div
138
+ class="flex flex-1 justify-between border border-emerald-500/40 bg-emerald-500/10 p-3 text-sm text-emerald-200"
139
+ >
140
+ <span>{result.took.toFixed(0)} ms</span>
141
+ <span>{result.r.rowCount} rows</span>
142
+ </div>
143
+ {/if}
144
+ </div>
145
+ </form>
146
+ {#if result}
147
+ <!-- contain: paint isolates the scroll container's repaint area; without
148
+ it, scrolling a wide result table forces the whole page to repaint
149
+ every frame, which is what made horizontal scroll feel laggy. -->
150
+ <div class="max-h-[600px] overflow-auto border border-slate-700 [contain:paint]">
151
+ {#if result.r.rows && result.r.rows.length > 0}
152
+ <table class="w-full border-collapse text-sm">
153
+ <thead class="sticky top-0 z-10 bg-slate-700">
130
154
  <tr>
131
- {#each Object.values(row) as cell, c (c)}
132
- <td class="align-top">
133
- {#if typeof cell === 'object'}<pre class="text-xs">{JSON.stringify(cell, null, 2)}</pre>
134
- {:else}{cell === null ? 'null' : cell}{/if}
135
- </td>
155
+ {#each getHeaders(result.r.rows) as header, i (i)}
156
+ <th class="border-b border-slate-600 px-3 py-2 text-left font-semibold text-slate-100"
157
+ >{header}</th
158
+ >
136
159
  {/each}
137
160
  </tr>
138
- {/each}
139
- </tbody>
140
- </table>
141
- {:else}
142
- <div class="alert alert-info">No rows returned</div>
143
- {/if}
144
- </div>
145
- {/if}
161
+ </thead>
162
+ <tbody>
163
+ {#each result.r.rows as row, r (r)}
164
+ <!-- Solid stripe (no /50 alpha) so the GPU doesn't have to alpha-
165
+ composite every cell on every scroll frame. -->
166
+ <tr class="even:bg-slate-900">
167
+ {#each Object.values(row) as cell, c (c)}
168
+ <!-- min-w to keep short cells readable, max-w-xs to cap
169
+ wide ones, break-all so long unbroken strings (URLs,
170
+ DIDs) wrap inside their cell instead of forcing the
171
+ column to ~940px and the table to 2.5kpx (which is
172
+ what made horizontal scroll laggy). -->
173
+ <td
174
+ class="max-w-xs min-w-[8rem] border-b border-slate-700 px-3 py-2 align-top text-sm break-all text-slate-200"
175
+ >
176
+ {#if typeof cell === 'object'}<pre
177
+ class="text-xs whitespace-pre-wrap text-slate-400">{JSON.stringify(
178
+ cell,
179
+ null,
180
+ 2,
181
+ )}</pre>
182
+ {:else}{cell === null ? 'null' : cell}{/if}
183
+ </td>
184
+ {/each}
185
+ </tr>
186
+ {/each}
187
+ </tbody>
188
+ </table>
189
+ {:else}
190
+ <div class="border border-slate-700 bg-slate-800 p-3 text-sm text-slate-300">
191
+ No rows returned
192
+ </div>
193
+ {/if}
194
+ </div>
195
+ {/if}
196
+ </div>
146
197
  </div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "firstly",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "type": "module",
5
5
  "description": "Firstly, an opinionated Remult setup!",
6
6
  "funding": "https://github.com/sponsors/jycouet",
@@ -40,8 +40,8 @@
40
40
  "nodemailer": "8.0.5",
41
41
  "tailwind-merge": "3.5.0",
42
42
  "tailwindcss": "4.2.2",
43
- "vite-plugin-kit-routes": "1.0.3",
44
- "vite-plugin-stripper": "0.10.1"
43
+ "vite-plugin-kit-routes": "1.0.5",
44
+ "vite-plugin-stripper": "0.10.3"
45
45
  },
46
46
  "sideEffects": false,
47
47
  "exports": {
@@ -85,6 +85,7 @@
85
85
  },
86
86
  "./mail": {
87
87
  "types": "./esm/mail/index.d.ts",
88
+ "svelte": "./esm/mail/index.js",
88
89
  "default": "./esm/mail/index.js"
89
90
  },
90
91
  "./mail/server": {