@nextsparkjs/theme-blog 0.1.0-beta.1

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 (64) hide show
  1. package/README.md +65 -0
  2. package/about.md +93 -0
  3. package/api/authors/[username]/route.ts +150 -0
  4. package/api/authors/route.ts +63 -0
  5. package/api/posts/public/route.ts +151 -0
  6. package/components/ExportPostsButton.tsx +102 -0
  7. package/components/ImportPostsDialog.tsx +284 -0
  8. package/components/PostsToolbar.tsx +24 -0
  9. package/components/editor/FeaturedImageUpload.tsx +185 -0
  10. package/components/editor/WysiwygEditor.tsx +340 -0
  11. package/components/index.ts +4 -0
  12. package/components/public/AuthorBio.tsx +105 -0
  13. package/components/public/AuthorCard.tsx +130 -0
  14. package/components/public/BlogFooter.tsx +185 -0
  15. package/components/public/BlogNavbar.tsx +201 -0
  16. package/components/public/PostCard.tsx +306 -0
  17. package/components/public/ReadingProgress.tsx +70 -0
  18. package/components/public/RelatedPosts.tsx +78 -0
  19. package/config/app.config.ts +200 -0
  20. package/config/billing.config.ts +146 -0
  21. package/config/dashboard.config.ts +333 -0
  22. package/config/dev.config.ts +48 -0
  23. package/config/features.config.ts +196 -0
  24. package/config/flows.config.ts +333 -0
  25. package/config/permissions.config.ts +101 -0
  26. package/config/theme.config.ts +128 -0
  27. package/entities/categories/categories.config.ts +60 -0
  28. package/entities/categories/categories.fields.ts +115 -0
  29. package/entities/categories/categories.service.ts +333 -0
  30. package/entities/categories/categories.types.ts +58 -0
  31. package/entities/categories/messages/en.json +33 -0
  32. package/entities/categories/messages/es.json +33 -0
  33. package/entities/posts/messages/en.json +100 -0
  34. package/entities/posts/messages/es.json +100 -0
  35. package/entities/posts/migrations/001_posts_table.sql +110 -0
  36. package/entities/posts/migrations/002_add_featured.sql +19 -0
  37. package/entities/posts/migrations/003_post_categories_pivot.sql +47 -0
  38. package/entities/posts/posts.config.ts +61 -0
  39. package/entities/posts/posts.fields.ts +234 -0
  40. package/entities/posts/posts.service.ts +464 -0
  41. package/entities/posts/posts.types.ts +80 -0
  42. package/lib/selectors.ts +179 -0
  43. package/messages/en.json +113 -0
  44. package/messages/es.json +113 -0
  45. package/migrations/002_author_profile_fields.sql +37 -0
  46. package/migrations/003_categories_table.sql +90 -0
  47. package/migrations/999_sample_data.sql +412 -0
  48. package/migrations/999_theme_sample_data.sql +1070 -0
  49. package/package.json +18 -0
  50. package/permissions-matrix.md +63 -0
  51. package/styles/article.css +333 -0
  52. package/styles/components.css +204 -0
  53. package/styles/globals.css +327 -0
  54. package/styles/theme.css +167 -0
  55. package/templates/(public)/author/[username]/page.tsx +247 -0
  56. package/templates/(public)/authors/page.tsx +161 -0
  57. package/templates/(public)/layout.tsx +44 -0
  58. package/templates/(public)/page.tsx +276 -0
  59. package/templates/(public)/posts/[slug]/page.tsx +342 -0
  60. package/templates/dashboard/(main)/page.tsx +385 -0
  61. package/templates/dashboard/(main)/posts/[id]/edit/page.tsx +529 -0
  62. package/templates/dashboard/(main)/posts/[id]/page.tsx +33 -0
  63. package/templates/dashboard/(main)/posts/create/page.tsx +353 -0
  64. package/templates/dashboard/(main)/posts/page.tsx +833 -0
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Blog Theme - Block Selectors
3
+ *
4
+ * This file defines selectors for block components in the theme.
5
+ * It's placed in lib/ instead of tests/ so TypeScript can resolve imports.
6
+ *
7
+ * Used by:
8
+ * - Block components (for data-cy attributes)
9
+ * - Cypress tests (via tests/cypress/src/selectors.ts)
10
+ */
11
+
12
+ import { createSelectorHelpers } from '@nextsparkjs/core/lib/test/selector-factory'
13
+ import { CORE_SELECTORS } from '@nextsparkjs/core/lib/test/core-selectors'
14
+
15
+ // =============================================================================
16
+ // BLOCK SELECTORS
17
+ // =============================================================================
18
+
19
+ /**
20
+ * Block-specific selectors for the blog theme.
21
+ * Each block has at minimum a 'container' selector.
22
+ * Dynamic selectors use {index} placeholder.
23
+ */
24
+ export const BLOCK_SELECTORS = {
25
+ // Blog theme currently has no custom blocks
26
+ // Add block selectors here when blocks are created
27
+ } as const
28
+
29
+ // =============================================================================
30
+ // ENTITY SELECTORS
31
+ // =============================================================================
32
+
33
+ /**
34
+ * Entity-specific selectors for the blog theme.
35
+ */
36
+ export const ENTITY_SELECTORS = {
37
+ posts: {
38
+ list: 'posts-list',
39
+ listItem: 'post-item-{index}',
40
+ card: 'post-card-{id}',
41
+ title: 'post-title',
42
+ excerpt: 'post-excerpt',
43
+ content: 'post-content',
44
+ featuredImage: 'post-featured-image',
45
+ status: 'post-status',
46
+ publishedAt: 'post-published-at',
47
+ createButton: 'post-create-button',
48
+ editButton: 'post-edit-button',
49
+ deleteButton: 'post-delete-button',
50
+ publishButton: 'post-publish-button',
51
+ unpublishButton: 'post-unpublish-button',
52
+ form: {
53
+ container: 'post-form',
54
+ title: 'post-form-title',
55
+ slug: 'post-form-slug',
56
+ excerpt: 'post-form-excerpt',
57
+ content: 'post-form-content',
58
+ featuredImage: 'post-form-featured-image',
59
+ featured: 'post-form-featured',
60
+ status: 'post-form-status',
61
+ publishedAt: 'post-form-published-at',
62
+ submit: 'post-form-submit',
63
+ cancel: 'post-form-cancel',
64
+ },
65
+ },
66
+ categories: {
67
+ list: 'categories-list',
68
+ listItem: 'category-item-{index}',
69
+ card: 'category-card-{id}',
70
+ name: 'category-name',
71
+ slug: 'category-slug',
72
+ description: 'category-description',
73
+ createButton: 'category-create-button',
74
+ editButton: 'category-edit-button',
75
+ deleteButton: 'category-delete-button',
76
+ form: {
77
+ container: 'category-form',
78
+ name: 'category-form-name',
79
+ slug: 'category-form-slug',
80
+ description: 'category-form-description',
81
+ submit: 'category-form-submit',
82
+ cancel: 'category-form-cancel',
83
+ },
84
+ },
85
+ } as const
86
+
87
+ // =============================================================================
88
+ // BLOG-SPECIFIC SELECTORS
89
+ // =============================================================================
90
+
91
+ /**
92
+ * Blog-specific UI selectors.
93
+ */
94
+ export const BLOG_SELECTORS = {
95
+ editor: {
96
+ container: 'post-editor',
97
+ toolbar: 'editor-toolbar',
98
+ preview: 'editor-preview',
99
+ wysiwyg: 'wysiwyg-editor',
100
+ },
101
+ publicBlog: {
102
+ container: 'public-blog',
103
+ postList: 'public-post-list',
104
+ postDetail: 'public-post-detail',
105
+ authorInfo: 'post-author-info',
106
+ categoryFilter: 'category-filter',
107
+ searchInput: 'blog-search-input',
108
+ },
109
+ } as const
110
+
111
+ // =============================================================================
112
+ // THEME SELECTORS (CORE + BLOCKS + ENTITIES + BLOG)
113
+ // =============================================================================
114
+
115
+ /**
116
+ * Complete theme selectors merging core, blocks, and entities.
117
+ */
118
+ export const THEME_SELECTORS = {
119
+ ...CORE_SELECTORS,
120
+ blocks: BLOCK_SELECTORS,
121
+ entities: ENTITY_SELECTORS,
122
+ blog: BLOG_SELECTORS,
123
+ } as const
124
+
125
+ // =============================================================================
126
+ // EXPORTS
127
+ // =============================================================================
128
+
129
+ /**
130
+ * Create helpers bound to theme selectors
131
+ */
132
+ const helpers = createSelectorHelpers(THEME_SELECTORS)
133
+
134
+ /**
135
+ * Full selectors object (core + theme extensions)
136
+ */
137
+ export const SELECTORS = helpers.SELECTORS
138
+
139
+ /**
140
+ * Get a selector value by path
141
+ *
142
+ * @example
143
+ * sel('auth.login.form') // 'login-form'
144
+ * sel('entities.posts.list') // 'posts-list'
145
+ * sel('entities.posts.listItem', { index: '0' }) // 'post-item-0'
146
+ */
147
+ export const sel = helpers.sel
148
+
149
+ /**
150
+ * Alias for sel
151
+ */
152
+ export const s = helpers.s
153
+
154
+ /**
155
+ * Get selector only in dev/test environments
156
+ */
157
+ export const selDev = helpers.selDev
158
+
159
+ /**
160
+ * Get Cypress selector string [data-cy="..."]
161
+ *
162
+ * @example
163
+ * cySelector('entities.posts.list') // '[data-cy="posts-list"]'
164
+ */
165
+ export const cySelector = helpers.cySelector
166
+
167
+ /**
168
+ * Create entity-specific selector helpers
169
+ */
170
+ export const entitySelectors = helpers.entitySelectors
171
+
172
+ /**
173
+ * Type exports
174
+ */
175
+ export type ThemeSelectorsType = typeof THEME_SELECTORS
176
+ export type BlockSelectorsType = typeof BLOCK_SELECTORS
177
+ export type EntitySelectorsType = typeof ENTITY_SELECTORS
178
+ export type { Replacements } from '@nextsparkjs/core/lib/test/selector-factory'
179
+ export { CORE_SELECTORS }
@@ -0,0 +1,113 @@
1
+ {
2
+ "common": {
3
+ "blog": {
4
+ "viewBlog": "View Blog",
5
+ "export": "Export Posts"
6
+ }
7
+ },
8
+ "blog": {
9
+ "title": "Blog Platform",
10
+ "description": "Thoughts, stories, and ideas from our community of writers",
11
+ "viewBlog": "View Blog",
12
+ "writtenBy": "Written by",
13
+ "publishedOn": "Published on",
14
+ "readMore": "Read more",
15
+ "backToHome": "Back to Home",
16
+ "backToPosts": "Back to Posts",
17
+ "noPosts": "No posts yet",
18
+ "noPostsDescription": "Check back soon for new content.",
19
+
20
+ "publicFeed": {
21
+ "title": "Latest Stories",
22
+ "subtitle": "Thoughts, stories, and ideas from our community of writers",
23
+ "recentPosts": "Recent Posts",
24
+ "loadMore": "Load More",
25
+ "noResults": "No posts found",
26
+ "filterByCategory": "Filter by Category"
27
+ },
28
+
29
+ "newsletter": {
30
+ "title": "Stay in the loop",
31
+ "description": "Subscribe to get notified when new articles are published. No spam, unsubscribe anytime.",
32
+ "placeholder": "your@email.com",
33
+ "subscribe": "Subscribe"
34
+ },
35
+
36
+ "author": {
37
+ "profileTitle": "{name}'s Profile",
38
+ "postsCount": "{count} published posts",
39
+ "postsCountSingular": "{count} published post",
40
+ "viewAllPosts": "View all posts",
41
+ "followOn": "Follow on {platform}",
42
+ "visitWebsite": "Visit website",
43
+ "moreFrom": "More from {name}",
44
+ "notFound": "Author not found",
45
+ "notFoundDescription": "The author you are looking for does not exist."
46
+ },
47
+
48
+ "category": {
49
+ "title": "Posts in {category}",
50
+ "noResults": "No posts in this category yet"
51
+ },
52
+
53
+ "dashboard": {
54
+ "myPosts": "My Posts",
55
+ "newPost": "New Post",
56
+ "editProfile": "Edit Profile",
57
+ "publishPost": "Publish",
58
+ "unpublishPost": "Unpublish",
59
+ "confirmPublish": "Are you sure you want to publish this post?",
60
+ "confirmUnpublish": "Are you sure you want to unpublish this post?",
61
+ "statusDraft": "Draft",
62
+ "statusPublished": "Published"
63
+ },
64
+
65
+ "profile": {
66
+ "username": "Username",
67
+ "usernameHint": "This will be your public profile URL: /author/{username}",
68
+ "bio": "Bio",
69
+ "bioPlaceholder": "Tell readers about yourself...",
70
+ "socialLinks": "Social Links",
71
+ "twitter": "Twitter/X Profile",
72
+ "linkedin": "LinkedIn Profile",
73
+ "website": "Personal Website",
74
+ "saveProfile": "Save Profile",
75
+ "profileUpdated": "Profile updated successfully"
76
+ },
77
+
78
+ "export": "Export Posts",
79
+ "import": "Import Posts",
80
+ "exportDescription": "Download all your posts as a JSON file",
81
+ "importDescription": "Upload a JSON file to import posts",
82
+ "exportSuccess": "Posts exported successfully",
83
+ "importSuccess": "Posts imported successfully",
84
+ "importError": "Failed to import posts",
85
+
86
+ "quickActions": {
87
+ "writePost": "Write a new post"
88
+ },
89
+
90
+ "categories": {
91
+ "all": "All Categories",
92
+ "uncategorized": "Uncategorized"
93
+ },
94
+
95
+ "search": {
96
+ "placeholder": "Search posts...",
97
+ "noResults": "No posts found",
98
+ "resultsFor": "Results for"
99
+ },
100
+
101
+ "pagination": {
102
+ "previous": "Previous",
103
+ "next": "Next",
104
+ "page": "Page"
105
+ },
106
+
107
+ "meta": {
108
+ "minRead": "min read",
109
+ "draft": "Draft",
110
+ "scheduled": "Scheduled for"
111
+ }
112
+ }
113
+ }
@@ -0,0 +1,113 @@
1
+ {
2
+ "common": {
3
+ "blog": {
4
+ "viewBlog": "Ver Blog",
5
+ "export": "Exportar Posts"
6
+ }
7
+ },
8
+ "blog": {
9
+ "title": "Plataforma de Blog",
10
+ "description": "Pensamientos, historias e ideas de nuestra comunidad de escritores",
11
+ "viewBlog": "Ver Blog",
12
+ "writtenBy": "Escrito por",
13
+ "publishedOn": "Publicado el",
14
+ "readMore": "Leer más",
15
+ "backToHome": "Volver al Inicio",
16
+ "backToPosts": "Volver a Posts",
17
+ "noPosts": "Aún no hay posts",
18
+ "noPostsDescription": "Vuelve pronto para ver nuevo contenido.",
19
+
20
+ "publicFeed": {
21
+ "title": "Últimas Historias",
22
+ "subtitle": "Pensamientos, historias e ideas de nuestra comunidad de escritores",
23
+ "recentPosts": "Posts Recientes",
24
+ "loadMore": "Cargar Más",
25
+ "noResults": "No se encontraron posts",
26
+ "filterByCategory": "Filtrar por Categoría"
27
+ },
28
+
29
+ "newsletter": {
30
+ "title": "Mantente informado",
31
+ "description": "Suscríbete para recibir notificaciones cuando se publiquen nuevos artículos. Sin spam, cancela cuando quieras.",
32
+ "placeholder": "tu@email.com",
33
+ "subscribe": "Suscribirse"
34
+ },
35
+
36
+ "author": {
37
+ "profileTitle": "Perfil de {name}",
38
+ "postsCount": "{count} posts publicados",
39
+ "postsCountSingular": "{count} post publicado",
40
+ "viewAllPosts": "Ver todos los posts",
41
+ "followOn": "Seguir en {platform}",
42
+ "visitWebsite": "Visitar sitio web",
43
+ "moreFrom": "Más de {name}",
44
+ "notFound": "Autor no encontrado",
45
+ "notFoundDescription": "El autor que buscas no existe."
46
+ },
47
+
48
+ "category": {
49
+ "title": "Posts en {category}",
50
+ "noResults": "Aún no hay posts en esta categoría"
51
+ },
52
+
53
+ "dashboard": {
54
+ "myPosts": "Mis Posts",
55
+ "newPost": "Nuevo Post",
56
+ "editProfile": "Editar Perfil",
57
+ "publishPost": "Publicar",
58
+ "unpublishPost": "Despublicar",
59
+ "confirmPublish": "¿Estás seguro de que quieres publicar este post?",
60
+ "confirmUnpublish": "¿Estás seguro de que quieres despublicar este post?",
61
+ "statusDraft": "Borrador",
62
+ "statusPublished": "Publicado"
63
+ },
64
+
65
+ "profile": {
66
+ "username": "Nombre de usuario",
67
+ "usernameHint": "Esta será tu URL de perfil público: /author/{username}",
68
+ "bio": "Biografía",
69
+ "bioPlaceholder": "Cuéntale a los lectores sobre ti...",
70
+ "socialLinks": "Enlaces Sociales",
71
+ "twitter": "Perfil de Twitter/X",
72
+ "linkedin": "Perfil de LinkedIn",
73
+ "website": "Sitio Web Personal",
74
+ "saveProfile": "Guardar Perfil",
75
+ "profileUpdated": "Perfil actualizado exitosamente"
76
+ },
77
+
78
+ "export": "Exportar Posts",
79
+ "import": "Importar Posts",
80
+ "exportDescription": "Descarga todos tus posts como archivo JSON",
81
+ "importDescription": "Sube un archivo JSON para importar posts",
82
+ "exportSuccess": "Posts exportados exitosamente",
83
+ "importSuccess": "Posts importados exitosamente",
84
+ "importError": "Error al importar posts",
85
+
86
+ "quickActions": {
87
+ "writePost": "Escribir un nuevo post"
88
+ },
89
+
90
+ "categories": {
91
+ "all": "Todas las Categorías",
92
+ "uncategorized": "Sin categoría"
93
+ },
94
+
95
+ "search": {
96
+ "placeholder": "Buscar posts...",
97
+ "noResults": "No se encontraron posts",
98
+ "resultsFor": "Resultados para"
99
+ },
100
+
101
+ "pagination": {
102
+ "previous": "Anterior",
103
+ "next": "Siguiente",
104
+ "page": "Página"
105
+ },
106
+
107
+ "meta": {
108
+ "minRead": "min de lectura",
109
+ "draft": "Borrador",
110
+ "scheduled": "Programado para"
111
+ }
112
+ }
113
+ }
@@ -0,0 +1,37 @@
1
+ -- ============================================================================
2
+ -- Author Profile Fields Migration
3
+ -- Blog theme: Add public profile fields for multi-author platform
4
+ -- ============================================================================
5
+
6
+ -- Add profile fields to users table
7
+ ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "username" TEXT;
8
+ ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "bio" TEXT;
9
+ ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "social_twitter" TEXT;
10
+ ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "social_linkedin" TEXT;
11
+ ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "social_website" TEXT;
12
+
13
+ -- Create unique index for username (case-insensitive)
14
+ CREATE UNIQUE INDEX IF NOT EXISTS "users_username_unique_idx"
15
+ ON "users" (LOWER("username"))
16
+ WHERE "username" IS NOT NULL;
17
+
18
+ -- Index for username lookups
19
+ CREATE INDEX IF NOT EXISTS "users_username_idx" ON "users" ("username");
20
+
21
+ -- Add check constraint for username format (alphanumeric, underscore, min 3 chars)
22
+ DO $$
23
+ BEGIN
24
+ IF NOT EXISTS (
25
+ SELECT 1 FROM pg_constraint WHERE conname = 'check_username_format'
26
+ ) THEN
27
+ ALTER TABLE "users" ADD CONSTRAINT "check_username_format"
28
+ CHECK ("username" IS NULL OR "username" ~ '^[a-zA-Z0-9_]{3,30}$');
29
+ END IF;
30
+ END $$;
31
+
32
+ -- Add comments
33
+ COMMENT ON COLUMN "users"."username" IS 'Public username for author profile URL (/author/[username])';
34
+ COMMENT ON COLUMN "users"."bio" IS 'Author biography shown on public profile';
35
+ COMMENT ON COLUMN "users"."social_twitter" IS 'Twitter/X profile URL';
36
+ COMMENT ON COLUMN "users"."social_linkedin" IS 'LinkedIn profile URL';
37
+ COMMENT ON COLUMN "users"."social_website" IS 'Personal website URL';
@@ -0,0 +1,90 @@
1
+ -- ============================================================================
2
+ -- Categories Table Migration
3
+ -- Blog theme: Categories entity for post categorization
4
+ -- ============================================================================
5
+
6
+ -- Create categories table
7
+ CREATE TABLE IF NOT EXISTS "categories" (
8
+ "id" TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
9
+ "teamId" TEXT NOT NULL REFERENCES "teams"("id") ON DELETE CASCADE,
10
+ "userId" TEXT NOT NULL REFERENCES "users"("id") ON DELETE CASCADE,
11
+ "name" VARCHAR(100) NOT NULL,
12
+ "slug" VARCHAR(100) NOT NULL,
13
+ "description" TEXT,
14
+ "createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
15
+ "updatedAt" TIMESTAMPTZ NOT NULL DEFAULT NOW()
16
+ );
17
+
18
+ -- Unique slug per team
19
+ CREATE UNIQUE INDEX IF NOT EXISTS "categories_slug_teamId_idx"
20
+ ON "categories" ("slug", "teamId");
21
+
22
+ -- Index for team queries
23
+ CREATE INDEX IF NOT EXISTS "categories_teamId_idx" ON "categories" ("teamId");
24
+
25
+ -- Index for user queries
26
+ CREATE INDEX IF NOT EXISTS "categories_userId_idx" ON "categories" ("userId");
27
+
28
+ -- Index for slug lookups (public pages)
29
+ CREATE INDEX IF NOT EXISTS "categories_slug_idx" ON "categories" ("slug");
30
+
31
+ -- Enable RLS
32
+ ALTER TABLE "categories" ENABLE ROW LEVEL SECURITY;
33
+
34
+ -- Drop existing policies if any
35
+ DROP POLICY IF EXISTS "categories_select_policy" ON "categories";
36
+ DROP POLICY IF EXISTS "categories_insert_policy" ON "categories";
37
+ DROP POLICY IF EXISTS "categories_update_policy" ON "categories";
38
+ DROP POLICY IF EXISTS "categories_delete_policy" ON "categories";
39
+ DROP POLICY IF EXISTS "categories_public_select_policy" ON "categories";
40
+
41
+ -- Policy: Users can manage their own categories
42
+ CREATE POLICY "categories_select_policy" ON "categories"
43
+ FOR SELECT TO authenticated
44
+ USING (
45
+ "teamId" = ANY(public.get_user_team_ids())
46
+ OR public.is_superadmin()
47
+ );
48
+
49
+ CREATE POLICY "categories_insert_policy" ON "categories"
50
+ FOR INSERT TO authenticated
51
+ WITH CHECK (
52
+ "teamId" = ANY(public.get_user_team_ids())
53
+ );
54
+
55
+ CREATE POLICY "categories_update_policy" ON "categories"
56
+ FOR UPDATE TO authenticated
57
+ USING (
58
+ "teamId" = ANY(public.get_user_team_ids())
59
+ OR public.is_superadmin()
60
+ );
61
+
62
+ CREATE POLICY "categories_delete_policy" ON "categories"
63
+ FOR DELETE TO authenticated
64
+ USING (
65
+ "teamId" = ANY(public.get_user_team_ids())
66
+ OR public.is_superadmin()
67
+ );
68
+
69
+ -- Policy: Public can see all categories (for public pages)
70
+ CREATE POLICY "categories_public_select_policy" ON "categories"
71
+ FOR SELECT TO anon
72
+ USING (true);
73
+
74
+ -- Trigger for updatedAt
75
+ CREATE OR REPLACE FUNCTION update_categories_updated_at()
76
+ RETURNS TRIGGER AS $$
77
+ BEGIN
78
+ NEW."updatedAt" = NOW();
79
+ RETURN NEW;
80
+ END;
81
+ $$ LANGUAGE plpgsql;
82
+
83
+ DROP TRIGGER IF EXISTS categories_updated_at_trigger ON "categories";
84
+ CREATE TRIGGER categories_updated_at_trigger
85
+ BEFORE UPDATE ON "categories"
86
+ FOR EACH ROW
87
+ EXECUTE FUNCTION update_categories_updated_at();
88
+
89
+ -- Comments
90
+ COMMENT ON TABLE "categories" IS 'Blog post categories per author (team isolated)';