@jant/core 0.2.16 → 0.2.18

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.
Files changed (120) hide show
  1. package/dist/app.d.ts +5 -1
  2. package/dist/app.d.ts.map +1 -1
  3. package/dist/app.js +332 -119
  4. package/dist/i18n/context.d.ts +2 -2
  5. package/dist/i18n/context.js +1 -1
  6. package/dist/i18n/i18n.d.ts +1 -1
  7. package/dist/i18n/i18n.js +1 -1
  8. package/dist/i18n/index.d.ts +1 -1
  9. package/dist/i18n/index.js +1 -1
  10. package/dist/i18n/locales/en.d.ts.map +1 -1
  11. package/dist/i18n/locales/en.js +1 -1
  12. package/dist/i18n/locales/zh-Hans.d.ts.map +1 -1
  13. package/dist/i18n/locales/zh-Hans.js +1 -1
  14. package/dist/i18n/locales/zh-Hant.d.ts.map +1 -1
  15. package/dist/i18n/locales/zh-Hant.js +1 -1
  16. package/dist/index.d.ts +1 -1
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/lib/config.d.ts +83 -0
  19. package/dist/lib/config.d.ts.map +1 -0
  20. package/dist/lib/config.js +104 -0
  21. package/dist/lib/constants.d.ts +2 -1
  22. package/dist/lib/constants.d.ts.map +1 -1
  23. package/dist/lib/constants.js +5 -2
  24. package/dist/lib/sse.d.ts +15 -0
  25. package/dist/lib/sse.d.ts.map +1 -1
  26. package/dist/lib/sse.js +13 -0
  27. package/dist/lib/theme.d.ts +44 -0
  28. package/dist/lib/theme.d.ts.map +1 -0
  29. package/dist/lib/theme.js +65 -0
  30. package/dist/routes/dash/appearance.d.ts +13 -0
  31. package/dist/routes/dash/appearance.d.ts.map +1 -0
  32. package/dist/routes/dash/appearance.js +164 -0
  33. package/dist/routes/dash/collections.d.ts.map +1 -1
  34. package/dist/routes/dash/collections.js +5 -4
  35. package/dist/routes/dash/index.d.ts.map +1 -1
  36. package/dist/routes/dash/index.js +2 -1
  37. package/dist/routes/dash/media.d.ts.map +1 -1
  38. package/dist/routes/dash/media.js +3 -2
  39. package/dist/routes/dash/pages.d.ts.map +1 -1
  40. package/dist/routes/dash/pages.js +5 -4
  41. package/dist/routes/dash/posts.d.ts.map +1 -1
  42. package/dist/routes/dash/posts.js +5 -4
  43. package/dist/routes/dash/redirects.d.ts.map +1 -1
  44. package/dist/routes/dash/redirects.js +3 -2
  45. package/dist/routes/dash/settings.d.ts.map +1 -1
  46. package/dist/routes/dash/settings.js +39 -38
  47. package/dist/routes/pages/archive.d.ts.map +1 -1
  48. package/dist/routes/pages/archive.js +2 -1
  49. package/dist/routes/pages/collection.d.ts.map +1 -1
  50. package/dist/routes/pages/collection.js +2 -1
  51. package/dist/routes/pages/home.d.ts.map +1 -1
  52. package/dist/routes/pages/home.js +2 -1
  53. package/dist/routes/pages/page.d.ts.map +1 -1
  54. package/dist/routes/pages/page.js +2 -1
  55. package/dist/routes/pages/post.d.ts.map +1 -1
  56. package/dist/routes/pages/post.js +2 -1
  57. package/dist/routes/pages/search.d.ts.map +1 -1
  58. package/dist/routes/pages/search.js +2 -1
  59. package/dist/services/settings.d.ts +1 -0
  60. package/dist/services/settings.d.ts.map +1 -1
  61. package/dist/services/settings.js +3 -0
  62. package/dist/theme/color-themes.d.ts +30 -0
  63. package/dist/theme/color-themes.d.ts.map +1 -0
  64. package/dist/theme/color-themes.js +268 -0
  65. package/dist/theme/layouts/BaseLayout.d.ts +5 -0
  66. package/dist/theme/layouts/BaseLayout.d.ts.map +1 -1
  67. package/dist/theme/layouts/BaseLayout.js +70 -3
  68. package/dist/theme/layouts/DashLayout.d.ts +2 -0
  69. package/dist/theme/layouts/DashLayout.d.ts.map +1 -1
  70. package/dist/theme/layouts/DashLayout.js +10 -1
  71. package/dist/theme/layouts/index.d.ts +1 -1
  72. package/dist/theme/layouts/index.d.ts.map +1 -1
  73. package/dist/types.d.ts +64 -32
  74. package/dist/types.d.ts.map +1 -1
  75. package/dist/types.js +52 -0
  76. package/package.json +1 -1
  77. package/src/app.tsx +286 -59
  78. package/src/db/migrations/{0000_solid_moon_knight.sql → 0000_square_wallflower.sql} +3 -3
  79. package/src/db/migrations/meta/0000_snapshot.json +9 -9
  80. package/src/db/migrations/meta/_journal.json +2 -30
  81. package/src/i18n/context.tsx +2 -2
  82. package/src/i18n/i18n.ts +1 -1
  83. package/src/i18n/index.ts +1 -1
  84. package/src/i18n/locales/en.po +328 -252
  85. package/src/i18n/locales/en.ts +1 -1
  86. package/src/i18n/locales/zh-Hans.po +315 -278
  87. package/src/i18n/locales/zh-Hans.ts +1 -1
  88. package/src/i18n/locales/zh-Hant.po +315 -278
  89. package/src/i18n/locales/zh-Hant.ts +1 -1
  90. package/src/index.ts +0 -2
  91. package/src/lib/config.ts +120 -0
  92. package/src/lib/constants.ts +3 -0
  93. package/src/lib/sse.ts +38 -0
  94. package/src/lib/theme.ts +86 -0
  95. package/src/preset.css +9 -0
  96. package/src/routes/dash/appearance.tsx +180 -0
  97. package/src/routes/dash/collections.tsx +5 -4
  98. package/src/routes/dash/index.tsx +2 -1
  99. package/src/routes/dash/media.tsx +3 -2
  100. package/src/routes/dash/pages.tsx +5 -4
  101. package/src/routes/dash/posts.tsx +5 -4
  102. package/src/routes/dash/redirects.tsx +3 -2
  103. package/src/routes/dash/settings.tsx +51 -49
  104. package/src/routes/pages/archive.tsx +2 -1
  105. package/src/routes/pages/collection.tsx +2 -1
  106. package/src/routes/pages/home.tsx +2 -1
  107. package/src/routes/pages/page.tsx +2 -1
  108. package/src/routes/pages/post.tsx +2 -1
  109. package/src/routes/pages/search.tsx +2 -1
  110. package/src/services/settings.ts +5 -0
  111. package/src/styles/components.css +93 -0
  112. package/src/theme/color-themes.ts +321 -0
  113. package/src/theme/layouts/BaseLayout.tsx +61 -1
  114. package/src/theme/layouts/DashLayout.tsx +13 -2
  115. package/src/theme/layouts/index.ts +5 -1
  116. package/src/types.ts +74 -34
  117. package/src/db/migrations/0001_add_search_fts.sql +0 -40
  118. package/src/db/migrations/0002_collection_path.sql +0 -2
  119. package/src/db/migrations/0003_collection_path_nullable.sql +0 -21
  120. package/src/db/migrations/0004_media_uuid.sql +0 -35
@@ -1,3 +1,4 @@
1
+ import { getSiteName } from "../../lib/config.js";
1
2
  /**
2
3
  * Dashboard Posts Routes
3
4
  */
@@ -54,7 +55,7 @@ postsRoutes.get("/", async (c) => {
54
55
  const posts = await c.var.services.posts.list({
55
56
  visibility: ["featured", "quiet", "unlisted", "draft"],
56
57
  });
57
- const siteName = (await c.var.services.settings.get("SITE_NAME")) ?? "Jant";
58
+ const siteName = await getSiteName(c);
58
59
 
59
60
  return c.html(
60
61
  <DashLayout
@@ -70,7 +71,7 @@ postsRoutes.get("/", async (c) => {
70
71
 
71
72
  // New post form
72
73
  postsRoutes.get("/new", async (c) => {
73
- const siteName = (await c.var.services.settings.get("SITE_NAME")) ?? "Jant";
74
+ const siteName = await getSiteName(c);
74
75
 
75
76
  return c.html(
76
77
  <DashLayout
@@ -166,7 +167,7 @@ postsRoutes.get("/:id", async (c) => {
166
167
  const post = await c.var.services.posts.getById(id);
167
168
  if (!post) return c.notFound();
168
169
 
169
- const siteName = (await c.var.services.settings.get("SITE_NAME")) ?? "Jant";
170
+ const siteName = await getSiteName(c);
170
171
  const pageTitle = post.title || "Post";
171
172
 
172
173
  return c.html(
@@ -189,7 +190,7 @@ postsRoutes.get("/:id/edit", async (c) => {
189
190
  const post = await c.var.services.posts.getById(id);
190
191
  if (!post) return c.notFound();
191
192
 
192
- const siteName = (await c.var.services.settings.get("SITE_NAME")) ?? "Jant";
193
+ const siteName = await getSiteName(c);
193
194
 
194
195
  return c.html(
195
196
  <DashLayout
@@ -1,3 +1,4 @@
1
+ import { getSiteName } from "../../lib/config.js";
1
2
  /**
2
3
  * Dashboard Redirects Routes
3
4
  */
@@ -176,7 +177,7 @@ function NewRedirectContent() {
176
177
 
177
178
  // List redirects
178
179
  redirectsRoutes.get("/", async (c) => {
179
- const siteName = (await c.var.services.settings.get("SITE_NAME")) ?? "Jant";
180
+ const siteName = await getSiteName(c);
180
181
  const redirects = await c.var.services.redirects.list();
181
182
 
182
183
  return c.html(
@@ -193,7 +194,7 @@ redirectsRoutes.get("/", async (c) => {
193
194
 
194
195
  // New redirect form
195
196
  redirectsRoutes.get("/new", async (c) => {
196
- const siteName = (await c.var.services.settings.get("SITE_NAME")) ?? "Jant";
197
+ const siteName = await getSiteName(c);
197
198
 
198
199
  return c.html(
199
200
  <DashLayout
@@ -8,6 +8,7 @@ import type { Bindings } from "../../types.js";
8
8
  import type { AppVariables } from "../../app.js";
9
9
  import { DashLayout } from "../../theme/layouts/index.js";
10
10
  import { sse } from "../../lib/sse.js";
11
+ import { getSiteLanguage, getConfigFallback } from "../../lib/config.js";
11
12
 
12
13
  type Env = { Bindings: Bindings; Variables: AppVariables };
13
14
 
@@ -17,12 +18,14 @@ function SettingsContent({
17
18
  siteName,
18
19
  siteDescription,
19
20
  siteLanguage,
20
- saved,
21
+ siteNameFallback,
22
+ siteDescriptionFallback,
21
23
  }: {
22
24
  siteName: string;
23
25
  siteDescription: string;
24
26
  siteLanguage: string;
25
- saved: boolean;
27
+ siteNameFallback: string;
28
+ siteDescriptionFallback: string;
26
29
  }) {
27
30
  const { t } = useLingui();
28
31
 
@@ -38,27 +41,11 @@ function SettingsContent({
38
41
  {t({ message: "Settings", comment: "@context: Dashboard heading" })}
39
42
  </h1>
40
43
 
41
- {saved && (
42
- <div
43
- id="settings-saved-toast"
44
- class="alert mb-4 max-w-lg transition-opacity duration-300"
45
- data-init={`console.log('[toast] init fired at', Date.now()); history.replaceState({}, '', '/dash/settings'); setTimeout(() => { console.log('[toast] hiding at', Date.now()); const el = document.getElementById('settings-saved-toast'); if (el) { el.style.opacity = '0'; setTimeout(() => el.remove(), 300) } }, 3000)`}
46
- >
47
- <h2>
48
- {t({
49
- message: "Settings saved successfully.",
50
- comment: "@context: Toast message after saving settings",
51
- })}
52
- </h2>
53
- </div>
54
- )}
55
-
56
44
  <div class="flex flex-col gap-6 max-w-lg">
57
45
  <form
58
46
  data-signals={generalSignals}
59
47
  data-on:submit__prevent="@post('/dash/settings')"
60
48
  >
61
- <div id="settings-message"></div>
62
49
  <div class="card">
63
50
  <header>
64
51
  <h2>
@@ -80,7 +67,7 @@ function SettingsContent({
80
67
  type="text"
81
68
  data-bind="siteName"
82
69
  class="input"
83
- required
70
+ placeholder={siteNameFallback}
84
71
  />
85
72
  </div>
86
73
 
@@ -91,7 +78,12 @@ function SettingsContent({
91
78
  comment: "@context: Settings form field",
92
79
  })}
93
80
  </label>
94
- <textarea data-bind="siteDescription" class="textarea" rows={3}>
81
+ <textarea
82
+ data-bind="siteDescription"
83
+ class="textarea"
84
+ rows={3}
85
+ placeholder={siteDescriptionFallback}
86
+ >
95
87
  {siteDescription}
96
88
  </textarea>
97
89
  </div>
@@ -130,7 +122,6 @@ function SettingsContent({
130
122
  data-signals="{currentPassword: '', newPassword: '', confirmPassword: ''}"
131
123
  data-on:submit__prevent="@post('/dash/settings/password')"
132
124
  >
133
- <div id="password-message"></div>
134
125
  <div class="card">
135
126
  <header>
136
127
  <h2>
@@ -207,24 +198,33 @@ function SettingsContent({
207
198
 
208
199
  // Settings page
209
200
  settingsRoutes.get("/", async (c) => {
210
- const all = await c.var.services.settings.getAll();
211
- const siteName = all["SITE_NAME"] ?? "Jant";
212
- const siteDescription = all["SITE_DESCRIPTION"] ?? "";
213
- const siteLanguage = all["SITE_LANGUAGE"] ?? "en";
201
+ const { settings } = c.var.services;
202
+
203
+ // Fetch raw DB values (null if not set)
204
+ const dbSiteName = await settings.get("SITE_NAME");
205
+ const dbSiteDescription = await settings.get("SITE_DESCRIPTION");
206
+ const siteLanguage = await getSiteLanguage(c);
207
+
208
+ // Fallback values (ENV > Default) for placeholders
209
+ const siteNameFallback = getConfigFallback(c, "SITE_NAME");
210
+ const siteDescriptionFallback = getConfigFallback(c, "SITE_DESCRIPTION");
211
+
214
212
  const saved = c.req.query("saved") !== undefined;
215
213
 
216
214
  return c.html(
217
215
  <DashLayout
218
216
  c={c}
219
217
  title="Settings"
220
- siteName={siteName}
218
+ siteName={dbSiteName || siteNameFallback}
221
219
  currentPath="/dash/settings"
220
+ toast={saved ? { message: "Settings saved successfully." } : undefined}
222
221
  >
223
222
  <SettingsContent
224
- siteName={siteName}
225
- siteDescription={siteDescription}
223
+ siteName={dbSiteName || ""}
224
+ siteDescription={dbSiteDescription || ""}
226
225
  siteLanguage={siteLanguage}
227
- saved={saved}
226
+ siteNameFallback={siteNameFallback}
227
+ siteDescriptionFallback={siteDescriptionFallback}
228
228
  />
229
229
  </DashLayout>,
230
230
  );
@@ -238,14 +238,25 @@ settingsRoutes.post("/", async (c) => {
238
238
  siteLanguage: string;
239
239
  }>();
240
240
 
241
- const oldLanguage =
242
- (await c.var.services.settings.get("SITE_LANGUAGE")) ?? "en";
241
+ const { settings } = c.var.services;
243
242
 
244
- await c.var.services.settings.setMany({
245
- SITE_NAME: body.siteName,
246
- SITE_DESCRIPTION: body.siteDescription,
247
- SITE_LANGUAGE: body.siteLanguage,
248
- });
243
+ const oldLanguage = (await settings.get("SITE_LANGUAGE")) ?? "en";
244
+
245
+ // For text fields: empty = remove from DB (fall back to ENV > Default)
246
+ if (body.siteName.trim()) {
247
+ await settings.set("SITE_NAME", body.siteName.trim());
248
+ } else {
249
+ await settings.remove("SITE_NAME");
250
+ }
251
+
252
+ if (body.siteDescription.trim()) {
253
+ await settings.set("SITE_DESCRIPTION", body.siteDescription.trim());
254
+ } else {
255
+ await settings.remove("SITE_DESCRIPTION");
256
+ }
257
+
258
+ // Language always has a value from the select
259
+ await settings.set("SITE_LANGUAGE", body.siteLanguage);
249
260
 
250
261
  const languageChanged = oldLanguage !== body.siteLanguage;
251
262
 
@@ -254,10 +265,7 @@ settingsRoutes.post("/", async (c) => {
254
265
  // Language changed - full reload needed to update all UI text
255
266
  await stream.redirect("/dash/settings?saved");
256
267
  } else {
257
- // No language change - show inline success message
258
- await stream.patchElements(
259
- '<div id="settings-message"><div class="alert mb-4 transition-opacity duration-300" data-init="setTimeout(() => { el.style.opacity = \'0\'; setTimeout(() => el.remove(), 300) }, 3000)"><h2>Settings saved successfully.</h2></div></div>',
260
- );
268
+ await stream.toast("Settings saved successfully.");
261
269
  }
262
270
  });
263
271
  });
@@ -272,9 +280,7 @@ settingsRoutes.post("/password", async (c) => {
272
280
 
273
281
  if (body.newPassword !== body.confirmPassword) {
274
282
  return sse(c, async (stream) => {
275
- await stream.patchElements(
276
- '<div id="password-message"><div class="alert-destructive mb-4"><h2>Passwords do not match.</h2></div></div>',
277
- );
283
+ await stream.toast("Passwords do not match.", "error");
278
284
  });
279
285
  }
280
286
 
@@ -289,16 +295,12 @@ settingsRoutes.post("/password", async (c) => {
289
295
  });
290
296
  } catch {
291
297
  return sse(c, async (stream) => {
292
- await stream.patchElements(
293
- '<div id="password-message"><div class="alert-destructive mb-4"><h2>Current password is incorrect.</h2></div></div>',
294
- );
298
+ await stream.toast("Current password is incorrect.", "error");
295
299
  });
296
300
  }
297
301
 
298
302
  return sse(c, async (stream) => {
299
- await stream.patchElements(
300
- '<div id="password-message"><div class="alert mb-4"><h2>Password changed successfully.</h2></div></div>',
301
- );
303
+ await stream.toast("Password changed successfully.");
302
304
  await stream.patchSignals({
303
305
  currentPassword: "",
304
306
  newPassword: "",
@@ -1,3 +1,4 @@
1
+ import { getSiteName } from "../../lib/config.js";
1
2
  /**
2
3
  * Archive Page Route
3
4
  *
@@ -225,7 +226,7 @@ archiveRoutes.get("/", async (c) => {
225
226
  const cursorParam = c.req.query("cursor");
226
227
  const cursor = cursorParam ? parseInt(cursorParam, 10) : undefined;
227
228
 
228
- const siteName = (await c.var.services.settings.get("SITE_NAME")) ?? "Jant";
229
+ const siteName = await getSiteName(c);
229
230
 
230
231
  // Fetch one extra to check for more
231
232
  const posts = await c.var.services.posts.list({
@@ -1,3 +1,4 @@
1
+ import { getSiteName } from "../../lib/config.js";
1
2
  /**
2
3
  * Collection Page Route
3
4
  */
@@ -89,7 +90,7 @@ collectionRoutes.get("/:path", async (c) => {
89
90
  if (!collection) return c.notFound();
90
91
 
91
92
  const posts = await c.var.services.collections.getPosts(collection.id);
92
- const siteName = (await c.var.services.settings.get("SITE_NAME")) ?? "Jant";
93
+ const siteName = await getSiteName(c);
93
94
 
94
95
  return c.html(
95
96
  <BaseLayout
@@ -9,6 +9,7 @@ import type { AppVariables } from "../../app.js";
9
9
  import { BaseLayout } from "../../theme/layouts/index.js";
10
10
  import * as sqid from "../../lib/sqid.js";
11
11
  import * as time from "../../lib/time.js";
12
+ import { getSiteName } from "../../lib/config.js";
12
13
 
13
14
  type Env = { Bindings: Bindings; Variables: AppVariables };
14
15
 
@@ -106,7 +107,7 @@ homeRoutes.get("/", async (c) => {
106
107
  return c.redirect("/setup");
107
108
  }
108
109
 
109
- const siteName = (await c.var.services.settings.get("SITE_NAME")) ?? "Jant";
110
+ const siteName = await getSiteName(c);
110
111
 
111
112
  const posts = await c.var.services.posts.list({
112
113
  visibility: ["featured", "quiet"],
@@ -1,3 +1,4 @@
1
+ import { getSiteName } from "../../lib/config.js";
1
2
  /**
2
3
  * Custom Page Route
3
4
  *
@@ -60,7 +61,7 @@ pageRoutes.get("/:path", async (c) => {
60
61
  return c.notFound();
61
62
  }
62
63
 
63
- const siteName = (await c.var.services.settings.get("SITE_NAME")) ?? "Jant";
64
+ const siteName = await getSiteName(c);
64
65
 
65
66
  return c.html(
66
67
  <BaseLayout
@@ -1,3 +1,4 @@
1
+ import { getSiteName } from "../../lib/config.js";
1
2
  /**
2
3
  * Single Post Page Route
3
4
  */
@@ -81,7 +82,7 @@ postRoutes.get("/:id", async (c) => {
81
82
  return c.notFound();
82
83
  }
83
84
 
84
- const siteName = (await c.var.services.settings.get("SITE_NAME")) ?? "Jant";
85
+ const siteName = await getSiteName(c);
85
86
  const title = post.title || siteName;
86
87
 
87
88
  return c.html(
@@ -1,3 +1,4 @@
1
+ import { getSiteName } from "../../lib/config.js";
1
2
  /**
2
3
  * Search Page Route
3
4
  */
@@ -155,7 +156,7 @@ searchRoutes.get("/", async (c) => {
155
156
  const pageParam = c.req.query("page");
156
157
  const page = pageParam ? Math.max(1, parseInt(pageParam, 10) || 1) : 1;
157
158
 
158
- const siteName = (await c.var.services.settings.get("SITE_NAME")) ?? "Jant";
159
+ const siteName = await getSiteName(c);
159
160
 
160
161
  // Only search if there's a query
161
162
  let results: Awaited<ReturnType<typeof c.var.services.search.search>> = [];
@@ -19,6 +19,7 @@ export interface SettingsService {
19
19
  getAll(): Promise<Record<string, string>>;
20
20
  set(key: SettingsKey, value: string): Promise<void>;
21
21
  setMany(entries: Partial<Record<SettingsKey, string>>): Promise<void>;
22
+ remove(key: SettingsKey): Promise<void>;
22
23
  isOnboardingComplete(): Promise<boolean>;
23
24
  completeOnboarding(): Promise<void>;
24
25
  }
@@ -54,6 +55,10 @@ export function createSettingsService(db: Database): SettingsService {
54
55
  });
55
56
  },
56
57
 
58
+ async remove(key) {
59
+ await db.delete(settings).where(eq(settings.key, key));
60
+ },
61
+
57
62
  async setMany(entries) {
58
63
  const timestamp = now();
59
64
  const keys = Object.keys(entries) as SettingsKey[];
@@ -12,6 +12,32 @@
12
12
  }
13
13
  }
14
14
 
15
+ /* Alert variants */
16
+ @layer components {
17
+ .alert-success {
18
+ @apply relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current;
19
+ @apply text-success bg-card [&>svg]:text-current;
20
+
21
+ > h2,
22
+ > h3,
23
+ > h4,
24
+ > h5,
25
+ > h6,
26
+ > strong,
27
+ > [data-title] {
28
+ @apply col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight;
29
+ }
30
+
31
+ > section {
32
+ @apply text-success col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed;
33
+
34
+ ul {
35
+ @apply list-inside list-disc text-sm;
36
+ }
37
+ }
38
+ }
39
+ }
40
+
15
41
  /* Badge components */
16
42
  @layer components {
17
43
  .badge {
@@ -45,3 +71,70 @@
45
71
  color: white;
46
72
  }
47
73
  }
74
+
75
+ /* Toast notifications */
76
+ @layer components {
77
+ .toast-container {
78
+ @apply fixed top-4 right-4 z-50 flex flex-col gap-2 pointer-events-none;
79
+ }
80
+
81
+ .toast {
82
+ @apply pointer-events-auto rounded-lg border px-4 py-3 text-sm shadow-lg;
83
+ @apply flex items-start gap-2.5 min-w-64 max-w-sm;
84
+ background-color: var(--color-card);
85
+ animation: toast-in 0.3s ease-out;
86
+
87
+ > svg:first-child {
88
+ @apply size-4 shrink-0 translate-y-0.5;
89
+ }
90
+
91
+ > span {
92
+ @apply flex-1;
93
+ }
94
+ }
95
+
96
+ .toast-success {
97
+ color: var(--color-success);
98
+ }
99
+
100
+ .toast-error {
101
+ color: var(--color-destructive);
102
+ }
103
+
104
+ .toast-close {
105
+ @apply shrink-0 translate-y-0.5 cursor-pointer rounded-sm p-0 border-0 bg-transparent;
106
+ color: var(--color-muted-foreground);
107
+
108
+ &:hover {
109
+ color: var(--color-foreground);
110
+ }
111
+
112
+ > svg {
113
+ @apply size-3.5;
114
+ }
115
+ }
116
+
117
+ .toast-out {
118
+ animation: toast-out 0.3s ease-in forwards;
119
+ }
120
+ }
121
+
122
+ @keyframes toast-in {
123
+ from {
124
+ opacity: 0;
125
+ transform: translateY(-0.5rem);
126
+ }
127
+ to {
128
+ opacity: 1;
129
+ transform: translateY(0);
130
+ }
131
+ }
132
+
133
+ @keyframes toast-out {
134
+ from {
135
+ opacity: 1;
136
+ }
137
+ to {
138
+ opacity: 0;
139
+ }
140
+ }