@fayz-ai/plugin-blog 0.9.0 → 0.10.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fayz-ai/plugin-blog",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "[experimental] Fayz SDK — blog / articles plugin (website surface)",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -9,14 +9,12 @@
9
9
  "types": "./dist/index.d.ts",
10
10
  "exports": {
11
11
  ".": {
12
- "source": "./src/index.ts",
13
12
  "types": "./dist/index.d.ts",
14
13
  "import": "./dist/index.js"
15
14
  }
16
15
  },
17
16
  "files": [
18
- "dist",
19
- "src"
17
+ "dist"
20
18
  ],
21
19
  "peerDependencies": {
22
20
  "react": "^18.0.0 || ^19.0.0",
@@ -25,7 +23,7 @@
25
23
  "lucide-react": ">=0.400.0 <1.0.0"
26
24
  },
27
25
  "dependencies": {
28
- "@fayz-ai/core": "^0.9.0"
26
+ "@fayz-ai/core": "^0.10.0"
29
27
  },
30
28
  "devDependencies": {
31
29
  "@types/react": "^18.3.0",
@@ -35,7 +33,8 @@
35
33
  "tsup": "^8.2.0",
36
34
  "typescript": "^5.5.0",
37
35
  "@types/react-dom": "^18.3.0",
38
- "lucide-react": "^0.400.0"
36
+ "lucide-react": "^0.400.0",
37
+ "vitest": "^4.1.5"
39
38
  },
40
39
  "license": "MIT",
41
40
  "keywords": [
@@ -47,6 +46,7 @@
47
46
  "build": "tsup && tsc --emitDeclarationOnly --declaration --declarationMap --noEmit false",
48
47
  "dev": "tsup --watch",
49
48
  "typecheck": "tsc --noEmit",
50
- "clean": "rm -rf dist"
49
+ "clean": "rm -rf dist",
50
+ "test": "vitest run"
51
51
  }
52
52
  }
package/dist/seo.d.ts DELETED
@@ -1,16 +0,0 @@
1
- import type { BlogPost } from './types';
2
- /** Absolute canonical URL for a post. */
3
- export declare function postUrl(basePath: string, slug: string): string;
4
- /** schema.org BlogPosting + BreadcrumbList for an article page. */
5
- export declare function articleJsonLd(post: BlogPost, opts: {
6
- basePath: string;
7
- siteName: string;
8
- }): Array<Record<string, unknown>>;
9
- /** schema.org Blog + post list for the listing page. */
10
- export declare function blogJsonLd(posts: BlogPost[], opts: {
11
- basePath: string;
12
- siteName: string;
13
- title: string;
14
- }): Array<Record<string, unknown>>;
15
- export { useSeo } from '@fayz-ai/core';
16
- //# sourceMappingURL=seo.d.ts.map
package/dist/seo.d.ts.map DELETED
@@ -1 +0,0 @@
1
- {"version":3,"file":"seo.d.ts","sourceRoot":"","sources":["../src/seo.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAA;AAQvC,yCAAyC;AACzC,wBAAgB,OAAO,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAE9D;AAED,mEAAmE;AACnE,wBAAgB,aAAa,CAAC,IAAI,EAAE,QAAQ,EAAE,IAAI,EAAE;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,GAAG,KAAK,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAuB1H;AAED,wDAAwD;AACxD,wBAAgB,UAAU,CAAC,KAAK,EAAE,QAAQ,EAAE,EAAE,IAAI,EAAE;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAAG,KAAK,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAkBzI;AAGD,OAAO,EAAE,MAAM,EAAE,MAAM,eAAe,CAAA"}
@@ -1,20 +0,0 @@
1
- import type { BlogAuthor } from '../types'
2
-
3
- /** Round author avatar with a name-initial fallback (token-only). */
4
- export function AuthorAvatar({ author, className = 'h-8 w-8 text-xs' }: { author: BlogAuthor; className?: string }) {
5
- if (author.avatarUrl) {
6
- return (
7
- <img
8
- src={author.avatarUrl}
9
- alt={author.name}
10
- loading="lazy"
11
- className={`shrink-0 rounded-full object-cover ${className}`}
12
- />
13
- )
14
- }
15
- return (
16
- <span className={`flex shrink-0 items-center justify-center rounded-full bg-accent font-semibold text-primary ${className}`}>
17
- {author.name.charAt(0).toUpperCase()}
18
- </span>
19
- )
20
- }
@@ -1,54 +0,0 @@
1
- import { useBlogPosts } from '../hooks/useBlogPosts'
2
- import { useBlogContext } from '../context'
3
- import { PostCard } from './PostCard'
4
- import { useSeo, blogJsonLd } from '../seo'
5
-
6
- /**
7
- * Full /blog list screen. Rendered inside the host's chrome (Header/Footer),
8
- * so it only owns the section body — token-only for theme inheritance.
9
- */
10
- export function BlogList() {
11
- const { config } = useBlogContext()
12
- const { posts, loading, error } = useBlogPosts()
13
-
14
- const listTitle = config.labels.listSubtitle || config.labels.listTitle
15
- useSeo({
16
- title: `${listTitle}${config.siteName ? ` · ${config.siteName}` : ''}`,
17
- description: config.labels.listSubtitle || `Artigos do blog${config.siteName ? ` da ${config.siteName}` : ''}.`,
18
- canonical: `${typeof window !== 'undefined' ? window.location.origin : ''}${config.basePath}`,
19
- type: 'website',
20
- siteName: config.siteName,
21
- jsonLd: posts.length ? blogJsonLd(posts, { basePath: config.basePath, siteName: config.siteName, title: listTitle }) : undefined,
22
- })
23
-
24
- return (
25
- <section className="py-20 bg-background">
26
- <div className="container mx-auto px-6">
27
- <div className="mb-12 max-w-2xl">
28
- <span className="inline-block rounded-full bg-accent px-4 py-1 text-xs font-semibold uppercase tracking-widest text-primary mb-4">
29
- {config.labels.listTitle}
30
- </span>
31
- {config.labels.listSubtitle ? (
32
- <h1 className="font-heading text-4xl md:text-5xl font-bold text-foreground">
33
- {config.labels.listSubtitle}
34
- </h1>
35
- ) : null}
36
- </div>
37
-
38
- {loading ? (
39
- <p className="text-muted-foreground">Carregando artigos…</p>
40
- ) : error ? (
41
- <p className="text-muted-foreground">Não foi possível carregar os artigos.</p>
42
- ) : posts.length === 0 ? (
43
- <p className="text-muted-foreground">Nenhum artigo publicado ainda.</p>
44
- ) : (
45
- <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
46
- {posts.map((post) => (
47
- <PostCard key={post.slug} post={post} basePath={config.basePath} />
48
- ))}
49
- </div>
50
- )}
51
- </div>
52
- </section>
53
- )
54
- }
@@ -1,44 +0,0 @@
1
- import { Link } from 'react-router-dom'
2
- import { Clock } from 'lucide-react'
3
- import type { BlogPost } from '../types'
4
- import { AuthorAvatar } from './AuthorAvatar'
5
-
6
- /**
7
- * Default post card — token-only so it inherits the host theme. Optional: a host
8
- * with bespoke card markup can ignore this and map `useBlogPosts()` itself.
9
- */
10
- export function PostCard({ post, basePath = '/blog' }: { post: BlogPost; basePath?: string }) {
11
- return (
12
- <Link
13
- to={`${basePath}/${post.slug}`}
14
- className="group flex flex-col rounded-2xl border border-border bg-card overflow-hidden shadow-sm hover:shadow-lg hover:-translate-y-1 transition-all duration-300"
15
- >
16
- <div className="overflow-hidden h-48">
17
- <img
18
- src={post.image}
19
- alt={post.title}
20
- loading="lazy"
21
- className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
22
- />
23
- </div>
24
- <div className="flex flex-col flex-1 p-6">
25
- <span className="inline-block rounded-full bg-accent px-3 py-0.5 text-xs font-semibold text-primary mb-3 w-fit">
26
- {post.tag}
27
- </span>
28
- <h3 className="font-heading text-lg font-semibold text-foreground leading-snug mb-3 group-hover:text-primary transition-colors duration-200">
29
- {post.title}
30
- </h3>
31
- <p className="text-muted-foreground text-sm leading-relaxed flex-1 mb-4">{post.excerpt}</p>
32
- <div className="flex items-center gap-2.5 pt-4 border-t border-border">
33
- <AuthorAvatar author={post.author} className="h-7 w-7 text-xs" />
34
- <div className="min-w-0">
35
- <p className="truncate text-xs font-semibold text-foreground">{post.author.name}</p>
36
- <p className="flex items-center gap-1 text-[11px] text-muted-foreground">
37
- {post.date}<span aria-hidden>·</span><Clock className="h-3 w-3" />{post.readTime}
38
- </p>
39
- </div>
40
- </div>
41
- </div>
42
- </Link>
43
- )
44
- }
@@ -1,108 +0,0 @@
1
- import { Link, useParams } from 'react-router-dom'
2
- import { Clock } from 'lucide-react'
3
- import { usePost } from '../hooks/usePost'
4
- import { useBlogContext } from '../context'
5
- import { AuthorAvatar } from './AuthorAvatar'
6
- import { useSeo, articleJsonLd, postUrl } from '../seo'
7
-
8
- /**
9
- * /blog/:slug detail screen. Reads the slug from the router and renders the
10
- * article inside the host chrome. Token-only for theme inheritance. Sets rich
11
- * SEO/JSON-LD per article (BlogPosting + BreadcrumbList).
12
- */
13
- export function PostDetail() {
14
- const { config } = useBlogContext()
15
- const { slug } = useParams<{ slug: string }>()
16
- const { post, loading, error } = usePost(slug)
17
-
18
- useSeo(
19
- post
20
- ? {
21
- title: `${post.title}${config.siteName ? ` · ${config.siteName}` : ''}`,
22
- description: post.excerpt,
23
- canonical: postUrl(config.basePath, post.slug),
24
- image: post.image,
25
- type: 'article',
26
- siteName: config.siteName,
27
- jsonLd: articleJsonLd(post, { basePath: config.basePath, siteName: config.siteName }),
28
- }
29
- : { title: config.siteName || 'Blog' },
30
- )
31
-
32
- const paragraphs = post?.body ? post.body.split(/\n\s*\n/).map((p) => p.trim()).filter(Boolean) : []
33
-
34
- return (
35
- <article className="py-16 bg-background">
36
- <div className="container mx-auto px-6 max-w-3xl">
37
- <Link to={config.basePath} className="text-sm font-medium text-primary hover:underline">
38
- {config.labels.backToList}
39
- </Link>
40
-
41
- {loading ? (
42
- <p className="mt-10 text-muted-foreground">Carregando…</p>
43
- ) : error || !post ? (
44
- <div className="mt-10">
45
- <h1 className="font-heading text-3xl font-bold text-foreground">Artigo não encontrado</h1>
46
- <p className="mt-2 text-muted-foreground">Este artigo pode ter sido movido ou removido.</p>
47
- </div>
48
- ) : (
49
- <>
50
- <h1 className="mt-8 font-heading text-4xl md:text-5xl font-bold text-foreground leading-tight">
51
- {post.title}
52
- </h1>
53
-
54
- {/* Medium-style byline */}
55
- <div className="mt-6 flex items-center gap-3">
56
- <AuthorAvatar author={post.author} className="h-11 w-11 text-base" />
57
- <div>
58
- <p className="font-semibold text-foreground">{post.author.name}</p>
59
- <p className="flex flex-wrap items-center gap-1.5 text-sm text-muted-foreground">
60
- {post.author.role ? <span>{post.author.role}<span aria-hidden> · </span></span> : null}
61
- <time dateTime={post.publishedAt}>{post.date}</time>
62
- <span className="flex items-center gap-1"><Clock className="h-3 w-3" />{post.readTime} de leitura</span>
63
- </p>
64
- </div>
65
- </div>
66
-
67
- <div className="mt-8 overflow-hidden rounded-2xl">
68
- <img src={post.image} alt={post.title} className="w-full object-cover" loading="lazy" />
69
- </div>
70
-
71
- <div className="mt-8 space-y-5">
72
- <p className="text-lg text-foreground/90 leading-relaxed font-medium">{post.excerpt}</p>
73
- {paragraphs.map((para, i) => (
74
- <p key={i} className="text-muted-foreground leading-relaxed">
75
- {para}
76
- </p>
77
- ))}
78
- </div>
79
-
80
- {/* Category tag — at the bottom, Medium-style */}
81
- <div className="mt-10 flex flex-wrap items-center gap-2 border-t border-border pt-6">
82
- <span className="text-sm text-muted-foreground">Categoria:</span>
83
- <Link
84
- to={config.basePath}
85
- className="rounded-full bg-accent px-3 py-1 text-xs font-semibold text-primary transition-colors hover:bg-accent/70"
86
- >
87
- {post.tag}
88
- </Link>
89
- </div>
90
-
91
- {/* About the author */}
92
- {post.author.bio ? (
93
- <div className="mt-8 flex items-start gap-4 rounded-2xl border border-border bg-muted/40 p-6">
94
- <AuthorAvatar author={post.author} className="h-14 w-14 text-lg" />
95
- <div>
96
- <p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Escrito por</p>
97
- <p className="font-heading text-lg font-bold text-foreground">{post.author.name}</p>
98
- {post.author.role ? <p className="text-sm text-primary">{post.author.role}</p> : null}
99
- <p className="mt-2 text-sm text-muted-foreground leading-relaxed">{post.author.bio}</p>
100
- </div>
101
- </div>
102
- ) : null}
103
- </>
104
- )}
105
- </div>
106
- </article>
107
- )
108
- }
package/src/config.ts DELETED
@@ -1,55 +0,0 @@
1
- import type { PluginScope, VerticalId } from '@fayz-ai/core'
2
- import type { BlogDataProvider } from './data/types'
3
- import type { BlogSeed } from './data/mock'
4
- import type { BlogAuthor } from './types'
5
-
6
- export interface BlogPluginOptions {
7
- /** Base path the plugin mounts its public routes under. Default '/blog'. */
8
- basePath?: string
9
- /** Seed posts for the mock provider (used until a real backend is wired). */
10
- seed?: BlogSeed
11
- /** Default byline author, applied to any post that doesn't specify its own. */
12
- defaultAuthor?: BlogAuthor
13
- /** Publisher/brand name — used for SEO (og:site_name, JSON-LD publisher). */
14
- siteName?: string
15
- /** Inject a custom provider (e.g. a CMS). Overrides the safe mock/Supabase resolver. */
16
- dataProvider?: BlogDataProvider
17
- /** Tenant whose PUBLISHED posts the public (anon) site reads from Supabase.
18
- * Required for the Supabase provider on an anon website (mirrors how the
19
- * public booking plugin takes an explicit tenantId). Without it the safe
20
- * resolver falls back to the mock/seed provider. */
21
- tenantId?: string
22
- /** Section eyebrow / labels for the default list screen. */
23
- labels?: {
24
- /** Title shown at the top of the /blog list screen. */
25
- listTitle?: string
26
- /** Subtitle under the list title. */
27
- listSubtitle?: string
28
- /** "Back to blog" link label on the detail screen. */
29
- backToList?: string
30
- }
31
- scope?: PluginScope
32
- verticalId?: VerticalId
33
- }
34
-
35
- export interface ResolvedBlogConfig {
36
- basePath: string
37
- siteName: string
38
- labels: {
39
- listTitle: string
40
- listSubtitle: string
41
- backToList: string
42
- }
43
- }
44
-
45
- export function resolveConfig(options?: BlogPluginOptions): ResolvedBlogConfig {
46
- return {
47
- basePath: options?.basePath ?? '/blog',
48
- siteName: options?.siteName ?? '',
49
- labels: {
50
- listTitle: options?.labels?.listTitle ?? 'Blog',
51
- listSubtitle: options?.labels?.listSubtitle ?? '',
52
- backToList: options?.labels?.backToList ?? '← Voltar ao blog',
53
- },
54
- }
55
- }
package/src/context.tsx DELETED
@@ -1,23 +0,0 @@
1
- import { createContext, useContext, type ReactNode } from 'react'
2
- import type { BlogDataProvider } from './data/types'
3
- import type { ResolvedBlogConfig } from './config'
4
-
5
- export interface BlogContextValue {
6
- provider: BlogDataProvider
7
- config: ResolvedBlogConfig
8
- }
9
-
10
- const BlogContext = createContext<BlogContextValue | null>(null)
11
-
12
- export function BlogProvider({ value, children }: { value: BlogContextValue; children: ReactNode }) {
13
- return <BlogContext.Provider value={value}>{children}</BlogContext.Provider>
14
- }
15
-
16
- /** Read the blog provider + config. Throws if used outside a BlogProvider. */
17
- export function useBlogContext(): BlogContextValue {
18
- const ctx = useContext(BlogContext)
19
- if (!ctx) {
20
- throw new Error('[plugin-blog] useBlogContext must be used within <BlogProvider>. Mount the plugin Provider at the app root.')
21
- }
22
- return ctx
23
- }
package/src/data/index.ts DELETED
@@ -1,4 +0,0 @@
1
- export type { BlogDataProvider } from './types'
2
- export { createMockBlogProvider } from './mock'
3
- export type { BlogSeed, BlogSeedPost, MockBlogProviderOptions } from './mock'
4
- export { createSupabaseBlogProvider } from './supabase'
package/src/data/mock.ts DELETED
@@ -1,71 +0,0 @@
1
- import type { BlogDataProvider } from './types'
2
- import type { BlogPost, BlogListQuery, BlogAuthor } from '../types'
3
-
4
- /** Slugify a title when a seed post omits an explicit slug. */
5
- function slugify(input: string): string {
6
- return input
7
- .normalize('NFD')
8
- // Drop combining accent marks (and any other non-ASCII) so "saúde" -> "saude".
9
- .replace(/[^\x00-\x7f]/g, '')
10
- .toLowerCase()
11
- .replace(/[^a-z0-9]+/g, '-')
12
- .replace(/(^-|-$)/g, '')
13
- .slice(0, 80)
14
- }
15
-
16
- /** Seed post — same shape as BlogPost but slug/body/author may be derived. */
17
- export type BlogSeedPost = Omit<BlogPost, 'slug' | 'body' | 'author'> & {
18
- slug?: string
19
- body?: string
20
- author?: BlogAuthor
21
- }
22
-
23
- export interface BlogSeed {
24
- posts: BlogSeedPost[]
25
- }
26
-
27
- export interface MockBlogProviderOptions {
28
- seed?: BlogSeed
29
- /** Fallback byline used for posts without their own author. */
30
- defaultAuthor?: BlogAuthor
31
- }
32
-
33
- const FALLBACK_AUTHOR: BlogAuthor = { name: 'Equipe' }
34
-
35
- function normalize(post: BlogSeedPost, defaultAuthor: BlogAuthor): BlogPost {
36
- return {
37
- ...post,
38
- slug: post.slug ?? slugify(post.title),
39
- body: post.body ?? post.excerpt,
40
- author: post.author ?? defaultAuthor,
41
- }
42
- }
43
-
44
- /** A built-in fallback post so an unseeded plugin still renders something. */
45
- const FALLBACK: BlogSeedPost[] = [
46
- {
47
- title: 'Bem-vindo ao blog',
48
- excerpt: 'Este é um artigo de exemplo servido pelo plugin de blog do Fayz.',
49
- tag: 'Novidades',
50
- readTime: '2 min',
51
- date: 'Hoje',
52
- image: 'https://images.unsplash.com/photo-1499750310107-5fef28a66643?w=800&q=80',
53
- },
54
- ]
55
-
56
- export function createMockBlogProvider(options?: MockBlogProviderOptions): BlogDataProvider {
57
- const defaultAuthor = options?.defaultAuthor ?? FALLBACK_AUTHOR
58
- const posts: BlogPost[] = (options?.seed?.posts ?? FALLBACK).map((p) => normalize(p, defaultAuthor))
59
-
60
- return {
61
- async listPosts(query?: BlogListQuery): Promise<BlogPost[]> {
62
- let result = posts
63
- if (query?.tag) result = result.filter((p) => p.tag === query.tag)
64
- if (query?.limit != null) result = result.slice(0, query.limit)
65
- return result
66
- },
67
- async getPost(slug: string): Promise<BlogPost | null> {
68
- return posts.find((p) => p.slug === slug) ?? null
69
- },
70
- }
71
- }
@@ -1,95 +0,0 @@
1
- import { getSupabaseClientOptional, getActiveTenantId } from '@fayz-ai/core'
2
- import type { BlogDataProvider } from './types'
3
- import type { BlogPost, BlogListQuery, BlogAuthor } from '../types'
4
-
5
- // ---------------------------------------------------------------------------
6
- // Supabase-backed blog provider (public/website read side).
7
- //
8
- // Reads PUBLISHED posts from the anon-safe view public.v_public_blog_posts
9
- // (see src/migrations/001_blog.sql §4), scoped to a tenant. The backoffice
10
- // writes to plg_blog_posts directly via the generic CRUD provider; here we only
11
- // read the published, column-whitelisted view — safe under the publishable key.
12
- //
13
- // The tenant is taken from the configured tenantId (anon site) or, failing that,
14
- // the active session tenant. Without a client or a tenant we return nothing, and
15
- // createSafeDataProvider falls back to the mock/seed provider.
16
- // ---------------------------------------------------------------------------
17
-
18
- const PUBLIC_VIEW = 'v_public_blog_posts'
19
-
20
- export interface SupabaseBlogProviderOptions {
21
- /** Tenant whose published posts to read (anon website). Defaults to the active session tenant. */
22
- tenantId?: string
23
- }
24
-
25
- function getClient() {
26
- const supabase = getSupabaseClientOptional() as {
27
- from: (t: string) => any
28
- } | null
29
- if (!supabase) throw new Error('[plugin-blog] Supabase client not initialized')
30
- return supabase
31
- }
32
-
33
- /** Human month-year label (pt-BR) derived from an ISO date, e.g. "Janeiro 2025". */
34
- function humanDate(iso?: string | null): string {
35
- if (!iso) return ''
36
- const d = new Date(iso)
37
- if (Number.isNaN(d.getTime())) return ''
38
- const label = d.toLocaleDateString('pt-BR', { month: 'long', year: 'numeric' })
39
- return label.charAt(0).toUpperCase() + label.slice(1)
40
- }
41
-
42
- function toPost(row: Record<string, any>): BlogPost {
43
- const author: BlogAuthor = {
44
- name: row.author_name ?? 'Equipe',
45
- avatarUrl: row.author_avatar_url ?? undefined,
46
- role: row.author_role ?? undefined,
47
- bio: row.author_bio ?? undefined,
48
- }
49
- return {
50
- slug: row.slug,
51
- title: row.title,
52
- excerpt: row.excerpt ?? '',
53
- body: row.body ?? '',
54
- tag: row.tag ?? '',
55
- readTime: row.read_time ?? '',
56
- date: humanDate(row.published_at),
57
- publishedAt: row.published_at ?? undefined,
58
- image: row.cover_image ?? '',
59
- author,
60
- }
61
- }
62
-
63
- export function createSupabaseBlogProvider(options?: SupabaseBlogProviderOptions): BlogDataProvider {
64
- const resolveTenantId = (): string | undefined => options?.tenantId ?? getActiveTenantId() ?? undefined
65
-
66
- return {
67
- async listPosts(query?: BlogListQuery): Promise<BlogPost[]> {
68
- const tenantId = resolveTenantId()
69
- if (!tenantId) return []
70
- let q = getClient()
71
- .from(PUBLIC_VIEW)
72
- .select('*')
73
- .eq('tenant_id', tenantId)
74
- .order('published_at', { ascending: false, nullsFirst: false })
75
- if (query?.tag) q = q.eq('tag', query.tag)
76
- if (query?.limit != null) q = q.limit(query.limit)
77
- const { data, error } = await q
78
- if (error) throw error
79
- return (data ?? []).map(toPost)
80
- },
81
-
82
- async getPost(slug: string): Promise<BlogPost | null> {
83
- const tenantId = resolveTenantId()
84
- if (!tenantId) return null
85
- const { data, error } = await getClient()
86
- .from(PUBLIC_VIEW)
87
- .select('*')
88
- .eq('tenant_id', tenantId)
89
- .eq('slug', slug)
90
- .maybeSingle()
91
- if (error) throw error
92
- return data ? toPost(data) : null
93
- },
94
- }
95
- }
package/src/data/types.ts DELETED
@@ -1,10 +0,0 @@
1
- import type { BlogPost, BlogListQuery } from '../types'
2
-
3
- /**
4
- * The data seam for the blog plugin. A mock/seed implementation powers the POC;
5
- * a Supabase (or CMS) implementation swaps in later with no component change.
6
- */
7
- export interface BlogDataProvider {
8
- listPosts(query?: BlogListQuery): Promise<BlogPost[]>
9
- getPost(slug: string): Promise<BlogPost | null>
10
- }
@@ -1,4 +0,0 @@
1
- export { useBlogPosts } from './useBlogPosts'
2
- export type { UseBlogPostsResult } from './useBlogPosts'
3
- export { usePost } from './usePost'
4
- export type { UsePostResult } from './usePost'
@@ -1,47 +0,0 @@
1
- import { useEffect, useState } from 'react'
2
- import { useBlogContext } from '../context'
3
- import type { BlogPost, BlogListQuery } from '../types'
4
-
5
- export interface UseBlogPostsResult {
6
- posts: BlogPost[]
7
- loading: boolean
8
- error: Error | null
9
- }
10
-
11
- /**
12
- * Fetch the post list from the active blog provider. The host maps `posts` into
13
- * its own card markup; the plugin never dictates presentation.
14
- */
15
- export function useBlogPosts(query?: BlogListQuery): UseBlogPostsResult {
16
- const { provider } = useBlogContext()
17
- const [posts, setPosts] = useState<BlogPost[]>([])
18
- const [loading, setLoading] = useState(true)
19
- const [error, setError] = useState<Error | null>(null)
20
-
21
- const limit = query?.limit
22
- const tag = query?.tag
23
-
24
- useEffect(() => {
25
- let active = true
26
- setLoading(true)
27
- provider
28
- .listPosts({ limit, tag })
29
- .then((result) => {
30
- if (!active) return
31
- setPosts(result)
32
- setError(null)
33
- })
34
- .catch((err) => {
35
- if (!active) return
36
- setError(err instanceof Error ? err : new Error(String(err)))
37
- })
38
- .finally(() => {
39
- if (active) setLoading(false)
40
- })
41
- return () => {
42
- active = false
43
- }
44
- }, [provider, limit, tag])
45
-
46
- return { posts, loading, error }
47
- }
@@ -1,46 +0,0 @@
1
- import { useEffect, useState } from 'react'
2
- import { useBlogContext } from '../context'
3
- import type { BlogPost } from '../types'
4
-
5
- export interface UsePostResult {
6
- post: BlogPost | null
7
- loading: boolean
8
- error: Error | null
9
- }
10
-
11
- /** Fetch a single post by slug from the active blog provider. */
12
- export function usePost(slug: string | undefined): UsePostResult {
13
- const { provider } = useBlogContext()
14
- const [post, setPost] = useState<BlogPost | null>(null)
15
- const [loading, setLoading] = useState(true)
16
- const [error, setError] = useState<Error | null>(null)
17
-
18
- useEffect(() => {
19
- let active = true
20
- if (!slug) {
21
- setPost(null)
22
- setLoading(false)
23
- return
24
- }
25
- setLoading(true)
26
- provider
27
- .getPost(slug)
28
- .then((result) => {
29
- if (!active) return
30
- setPost(result)
31
- setError(null)
32
- })
33
- .catch((err) => {
34
- if (!active) return
35
- setError(err instanceof Error ? err : new Error(String(err)))
36
- })
37
- .finally(() => {
38
- if (active) setLoading(false)
39
- })
40
- return () => {
41
- active = false
42
- }
43
- }, [provider, slug])
44
-
45
- return { post, loading, error }
46
- }