Skip to content

Commit 960dc07

Browse files
committed
✨ Implement Articles Feature
1 parent 4846e23 commit 960dc07

12 files changed

Lines changed: 648 additions & 19 deletions

File tree

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { HttpContext } from '@adonisjs/core/http'
2+
import { articleValidator } from '#validators/article_validator'
3+
import Article from '#models/article'
4+
import { DateTime } from 'luxon'
5+
6+
export default class ArticlesController {
7+
async index({ inertia, request }: HttpContext) {
8+
const page = request.input('page', 1)
9+
const articles = await Article.query()
10+
.preload('author')
11+
.where('is_published', true)
12+
.orderBy('published_at', 'desc')
13+
.paginate(page, 10)
14+
15+
return inertia.render('articles/index', {
16+
articles: articles.toJSON(),
17+
})
18+
}
19+
20+
async create({ inertia }: HttpContext) {
21+
return inertia.render('articles/create')
22+
}
23+
24+
async store({ request, auth, response }: HttpContext) {
25+
const data = await articleValidator.validate(request.all())
26+
const article = new Article()
27+
28+
article.title = data.title
29+
article.content = data.content
30+
article.excerpt = data.excerpt
31+
article.isPublished = data.isPublished || false
32+
article.authorId = auth.user!.id
33+
if (data.isPublished) {
34+
article.publishedAt = DateTime.now()
35+
}
36+
37+
await article.generateSlug()
38+
await article.save()
39+
40+
return response.redirect().toRoute('articles.show', { slug: article.slug })
41+
}
42+
43+
async show({ inertia, params }: HttpContext) {
44+
const article = await Article.query().where('slug', params.slug).preload('author').firstOrFail()
45+
46+
return inertia.render('articles/[slug]', { article })
47+
}
48+
}

app/models/article.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { DateTime } from 'luxon'
2+
import { BaseModel, belongsTo, column } from '@adonisjs/lucid/orm'
3+
import type { BelongsTo } from '@adonisjs/lucid/types/relations'
4+
import string from '@adonisjs/core/helpers/string'
5+
import User from '#models/user'
6+
7+
export default class Article extends BaseModel {
8+
@column({ isPrimary: true })
9+
declare id: number
10+
11+
@column()
12+
declare title: string
13+
14+
@column()
15+
declare slug: string
16+
17+
@column()
18+
declare content: string
19+
20+
@column()
21+
declare excerpt: string
22+
23+
@column()
24+
declare isPublished: boolean
25+
26+
@column()
27+
declare authorId: number
28+
29+
@belongsTo(() => User, { foreignKey: 'authorId' })
30+
declare author: BelongsTo<typeof User>
31+
32+
@column.dateTime({ autoCreate: true })
33+
declare createdAt: DateTime
34+
35+
@column.dateTime({ autoCreate: true, autoUpdate: true })
36+
declare updatedAt: DateTime
37+
38+
@column.dateTime()
39+
declare publishedAt: DateTime | null
40+
41+
// Generate slug before saving
42+
public async generateSlug() {
43+
this.slug = string.slug(this.title)
44+
}
45+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import vine from '@vinejs/vine'
2+
3+
export const articleValidator = vine.compile(
4+
vine.object({
5+
title: vine.string().minLength(10).maxLength(255),
6+
content: vine.string().minLength(100),
7+
excerpt: vine.string().minLength(50).maxLength(160),
8+
tags: vine.array(vine.string()),
9+
isPublished: vine.boolean().optional(),
10+
})
11+
)
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { BaseSchema } from '@adonisjs/lucid/schema'
2+
3+
export default class extends BaseSchema {
4+
protected tableName = 'articles'
5+
6+
async up() {
7+
this.schema.createTable(this.tableName, (table) => {
8+
table.increments('id')
9+
table.string('title').notNullable()
10+
table.string('slug').unique().notNullable()
11+
table.text('excerpt').notNullable()
12+
table.text('content').notNullable()
13+
table.integer('user_id').unsigned().references('id').inTable('users').onDelete('CASCADE')
14+
table.boolean('is_published').defaultTo(false)
15+
table.timestamp('published_at').nullable()
16+
table.timestamps(true, true)
17+
})
18+
}
19+
20+
async down() {
21+
this.schema.dropTable(this.tableName)
22+
}
23+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { BaseSchema } from '@adonisjs/lucid/schema'
2+
3+
export default class extends BaseSchema {
4+
protected tableName = 'articles'
5+
6+
async up() {
7+
this.schema.alterTable(this.tableName, (table) => {
8+
table.integer('author_id').unsigned().references('id').inTable('users').onDelete('CASCADE')
9+
})
10+
}
11+
12+
async down() {
13+
this.schema.alterTable(this.tableName, (table) => {
14+
table.dropColumn('author_id')
15+
})
16+
}
17+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { Link } from '@inertiajs/react'
2+
3+
interface Article {
4+
id: number
5+
title: string
6+
slug: string
7+
excerpt: string
8+
author: {
9+
name: string
10+
username: string
11+
}
12+
createdAt: string
13+
}
14+
15+
interface ArticlesSectionProps {
16+
articles: Article[]
17+
}
18+
19+
export default function ArticlesSection({ articles }: ArticlesSectionProps) {
20+
return (
21+
<div className="bg-white py-12">
22+
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
23+
<div className="lg:text-center">
24+
<h2 className="text-3xl font-semibold text-gray-900">Articles Populaires</h2>
25+
<p className="mt-4 text-lg text-gray-600">
26+
Découvrez les articles les plus appréciés et partagés par les membres de la communauté
27+
</p>
28+
</div>
29+
30+
<div className="mt-12 grid gap-8 md:grid-cols-2 lg:grid-cols-3">
31+
{articles.map((article) => (
32+
<article
33+
key={article.id}
34+
className="flex flex-col rounded-lg shadow-lg overflow-hidden"
35+
>
36+
<div className="flex-1 bg-white p-6 flex flex-col justify-between">
37+
<div className="flex-1">
38+
<Link href={`/articles/${article.slug}`} className="block mt-2">
39+
<p className="text-xl font-semibold text-gray-900">{article.title}</p>
40+
<p className="mt-3 text-base text-gray-500">{article.excerpt}</p>
41+
</Link>
42+
</div>
43+
<div className="mt-6 flex items-center">
44+
<div className="flex-shrink-0">
45+
<Link href={`/@${article.author.username}`}>
46+
<span className="sr-only">{article.author.name}</span>
47+
<img
48+
className="h-10 w-10 rounded-full"
49+
src={`https://ui-avatars.com/api/?name=${article.author.name}`}
50+
alt=""
51+
/>
52+
</Link>
53+
</div>
54+
<div className="ml-3">
55+
<p className="text-sm font-medium text-gray-900">
56+
<Link href={`/@${article.author.username}`} className="hover:underline">
57+
{article.author.name}
58+
</Link>
59+
</p>
60+
<div className="flex space-x-1 text-sm text-gray-500">
61+
<time dateTime={article.createdAt}>
62+
{new Date(article.createdAt).toLocaleDateString()}
63+
</time>
64+
</div>
65+
</div>
66+
</div>
67+
</div>
68+
</article>
69+
))}
70+
</div>
71+
72+
<div className="mt-12 text-center">
73+
<Link
74+
href="/articles"
75+
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-indigo-700 bg-indigo-100 hover:bg-indigo-200"
76+
>
77+
Voir tous les articles
78+
</Link>
79+
</div>
80+
</div>
81+
</div>
82+
)
83+
}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { Link } from '@inertiajs/react'
2+
3+
interface Article {
4+
id: number
5+
title: string
6+
slug: string
7+
excerpt: string
8+
author: {
9+
name: string
10+
username: string
11+
}
12+
publishedAt: string
13+
}
14+
15+
interface ArticlesListProps {
16+
articles: {
17+
data: Article[]
18+
meta: {
19+
total: number
20+
per_page: number
21+
current_page: number
22+
last_page: number
23+
}
24+
}
25+
}
26+
27+
export default function ArticlesList({ articles }: ArticlesListProps) {
28+
return (
29+
<div className="mt-8 space-y-8">
30+
{articles.data.map((article) => (
31+
<article key={article.id} className="relative bg-white p-8 rounded-lg shadow-sm">
32+
<div className="flex items-center gap-x-4 text-xs">
33+
<time dateTime={article.publishedAt} className="text-gray-500">
34+
{new Date(article.publishedAt).toLocaleDateString('fr-FR', {
35+
year: 'numeric',
36+
month: 'long',
37+
day: 'numeric',
38+
})}
39+
</time>
40+
</div>
41+
<div className="group relative">
42+
<h3 className="mt-3 text-lg font-semibold leading-6 text-gray-900 group-hover:text-gray-600">
43+
<Link href={`/articles/${article.slug}`}>
44+
{/* <span className="absolute inset-0" /> */}
45+
{article.title}
46+
</Link>
47+
</h3>
48+
<p className="mt-5 line-clamp-3 text-sm leading-6 text-gray-600">{article.excerpt}</p>
49+
</div>
50+
<div className="mt-6 flex items-center gap-x-4">
51+
<div className="flex items-center gap-x-4">
52+
<img
53+
src={`https://ui-avatars.com/api/?name=${article.author.name}`}
54+
alt=""
55+
className="h-10 w-10 rounded-full bg-gray-100"
56+
/>
57+
<div className="text-sm leading-6">
58+
<p className="font-semibold text-gray-900">
59+
<Link href={`/@${article.author.username}`}>
60+
{/* <span className="absolute inset-0" /> */}
61+
{article.author.name}
62+
</Link>
63+
</p>
64+
</div>
65+
</div>
66+
</div>
67+
</article>
68+
))}
69+
70+
{/* Pagination */}
71+
{articles.meta.last_page > 1 && (
72+
<nav className="flex items-center justify-between border-t border-gray-200 px-4 sm:px-0 mt-8 pt-8">
73+
<div className="-mt-px flex w-0 flex-1">
74+
{articles.meta.current_page > 1 && (
75+
<Link
76+
href={`/articles?page=${articles.meta.current_page - 1}`}
77+
className="inline-flex items-center border-t-2 border-transparent pr-1 pt-4 text-sm font-medium text-gray-500 hover:border-gray-300 hover:text-gray-700"
78+
>
79+
<span>Previous</span>
80+
</Link>
81+
)}
82+
</div>
83+
<div className="hidden md:-mt-px md:flex">
84+
{[...Array(articles.meta.last_page)].map((_, i) => (
85+
<Link
86+
key={i}
87+
href={`/articles?page=${i + 1}`}
88+
className={`inline-flex items-center border-t-2 px-4 pt-4 text-sm font-medium ${
89+
articles.meta.current_page === i + 1
90+
? 'border-indigo-500 text-indigo-600'
91+
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700'
92+
}`}
93+
>
94+
{i + 1}
95+
</Link>
96+
))}
97+
</div>
98+
<div className="-mt-px flex w-0 flex-1 justify-end">
99+
{articles.meta.current_page < articles.meta.last_page && (
100+
<Link
101+
href={`/articles?page=${articles.meta.current_page + 1}`}
102+
className="inline-flex items-center border-t-2 border-transparent pl-1 pt-4 text-sm font-medium text-gray-500 hover:border-gray-300 hover:text-gray-700"
103+
>
104+
<span>Next</span>
105+
</Link>
106+
)}
107+
</div>
108+
</nav>
109+
)}
110+
</div>
111+
)
112+
}

0 commit comments

Comments
 (0)