@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.
- package/README.md +65 -0
- package/about.md +93 -0
- package/api/authors/[username]/route.ts +150 -0
- package/api/authors/route.ts +63 -0
- package/api/posts/public/route.ts +151 -0
- package/components/ExportPostsButton.tsx +102 -0
- package/components/ImportPostsDialog.tsx +284 -0
- package/components/PostsToolbar.tsx +24 -0
- package/components/editor/FeaturedImageUpload.tsx +185 -0
- package/components/editor/WysiwygEditor.tsx +340 -0
- package/components/index.ts +4 -0
- package/components/public/AuthorBio.tsx +105 -0
- package/components/public/AuthorCard.tsx +130 -0
- package/components/public/BlogFooter.tsx +185 -0
- package/components/public/BlogNavbar.tsx +201 -0
- package/components/public/PostCard.tsx +306 -0
- package/components/public/ReadingProgress.tsx +70 -0
- package/components/public/RelatedPosts.tsx +78 -0
- package/config/app.config.ts +200 -0
- package/config/billing.config.ts +146 -0
- package/config/dashboard.config.ts +333 -0
- package/config/dev.config.ts +48 -0
- package/config/features.config.ts +196 -0
- package/config/flows.config.ts +333 -0
- package/config/permissions.config.ts +101 -0
- package/config/theme.config.ts +128 -0
- package/entities/categories/categories.config.ts +60 -0
- package/entities/categories/categories.fields.ts +115 -0
- package/entities/categories/categories.service.ts +333 -0
- package/entities/categories/categories.types.ts +58 -0
- package/entities/categories/messages/en.json +33 -0
- package/entities/categories/messages/es.json +33 -0
- package/entities/posts/messages/en.json +100 -0
- package/entities/posts/messages/es.json +100 -0
- package/entities/posts/migrations/001_posts_table.sql +110 -0
- package/entities/posts/migrations/002_add_featured.sql +19 -0
- package/entities/posts/migrations/003_post_categories_pivot.sql +47 -0
- package/entities/posts/posts.config.ts +61 -0
- package/entities/posts/posts.fields.ts +234 -0
- package/entities/posts/posts.service.ts +464 -0
- package/entities/posts/posts.types.ts +80 -0
- package/lib/selectors.ts +179 -0
- package/messages/en.json +113 -0
- package/messages/es.json +113 -0
- package/migrations/002_author_profile_fields.sql +37 -0
- package/migrations/003_categories_table.sql +90 -0
- package/migrations/999_sample_data.sql +412 -0
- package/migrations/999_theme_sample_data.sql +1070 -0
- package/package.json +18 -0
- package/permissions-matrix.md +63 -0
- package/styles/article.css +333 -0
- package/styles/components.css +204 -0
- package/styles/globals.css +327 -0
- package/styles/theme.css +167 -0
- package/templates/(public)/author/[username]/page.tsx +247 -0
- package/templates/(public)/authors/page.tsx +161 -0
- package/templates/(public)/layout.tsx +44 -0
- package/templates/(public)/page.tsx +276 -0
- package/templates/(public)/posts/[slug]/page.tsx +342 -0
- package/templates/dashboard/(main)/page.tsx +385 -0
- package/templates/dashboard/(main)/posts/[id]/edit/page.tsx +529 -0
- package/templates/dashboard/(main)/posts/[id]/page.tsx +33 -0
- package/templates/dashboard/(main)/posts/create/page.tsx +353 -0
- package/templates/dashboard/(main)/posts/page.tsx +833 -0
package/lib/selectors.ts
ADDED
|
@@ -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 }
|
package/messages/en.json
ADDED
|
@@ -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
|
+
}
|
package/messages/es.json
ADDED
|
@@ -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)';
|