Ajout des fichiers de l'application
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
import { Pagination, PaginationItem } from "@/modules/common/components/pagination";
|
||||
import { calcTotalPageNumber } from "@/modules/common/functions/pagination";
|
||||
import { ArticleCard } from "@/modules/features/article/components/articleCard";
|
||||
import { fetchFeedArticles } from "@/modules/features/article/fetch/fetchArticleList";
|
||||
|
||||
const Pages = ({ totalPages, currentPage }: { totalPages: number; currentPage: number }) => (
|
||||
<Pagination>
|
||||
{[...Array(totalPages)].map((_, index) => {
|
||||
const page = index + 1;
|
||||
const href = `/?tab=global&page=${page}`;
|
||||
return (
|
||||
<PaginationItem href={href} active={page === currentPage} key={index}>
|
||||
{page}
|
||||
</PaginationItem>
|
||||
);
|
||||
})}
|
||||
</Pagination>
|
||||
);
|
||||
|
||||
export const FeedArticleList = async ({ currentPage }: { currentPage: number }) => {
|
||||
const { articles, articlesCount } = await fetchFeedArticles(currentPage);
|
||||
const totalPages = calcTotalPageNumber(articlesCount, 10);
|
||||
|
||||
return articles.length < 1 ? (
|
||||
<p>No articles found.</p>
|
||||
) : (
|
||||
<>
|
||||
{articles.map((article, index) => (
|
||||
<ArticleCard key={index} article={article} />
|
||||
))}
|
||||
{1 < totalPages && <Pages currentPage={currentPage} totalPages={totalPages} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./feedArticleList";
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Pagination, PaginationItem } from "@/modules/common/components/pagination";
|
||||
import { calcTotalPageNumber } from "@/modules/common/functions/pagination";
|
||||
import { ArticleCard } from "@/modules/features/article/components/articleCard";
|
||||
import { fetchGlobalArticles } from "@/modules/features/article/fetch/fetchArticleList";
|
||||
|
||||
const Pages = ({ totalPages, currentPage }: { totalPages: number; currentPage: number }) => (
|
||||
<Pagination>
|
||||
{[...Array(totalPages)].map((_, index) => {
|
||||
const page = index + 1;
|
||||
const href = `/?tab=global&page=${page}`;
|
||||
return (
|
||||
<PaginationItem href={href} active={page === currentPage} key={index}>
|
||||
{page}
|
||||
</PaginationItem>
|
||||
);
|
||||
})}
|
||||
</Pagination>
|
||||
);
|
||||
|
||||
export const GlobalArticleList = async ({ currentPage }: { currentPage: number }) => {
|
||||
const { articles, articlesCount } = await fetchGlobalArticles(currentPage);
|
||||
const totalPages = calcTotalPageNumber(articlesCount, 10);
|
||||
|
||||
return articles.length < 1 ? (
|
||||
<p>No articles found.</p>
|
||||
) : (
|
||||
<>
|
||||
{articles.map((article, index) => (
|
||||
<ArticleCard key={index} article={article} />
|
||||
))}
|
||||
{1 < totalPages && <Pages currentPage={currentPage} totalPages={totalPages} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./globalArticleList";
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./logoHeader";
|
||||
export * from "./logoSmall";
|
||||
@@ -0,0 +1,44 @@
|
||||
const LogoHeader: React.FC = () => {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="272" height="60" viewBox="0 0 272 60">
|
||||
<g id="Ad_Astra_3_">
|
||||
<g>
|
||||
<polygon fill="#D7C067" points="82.246,32.325 80.22,24.75 67.628,24.75 65.604,32.325 " />
|
||||
<polygon fill="#D7C067" points="78.265,47.726 79.182,51.349 87.329,51.349 82.955,35.167 74.905,35.167 76.599,41.509 71.25,41.509 72.944,35.166 64.795,35.167 60.519,51.349 68.62,51.349 69.587,47.726 " />
|
||||
</g>
|
||||
<g>
|
||||
<path fill="#D7C067" d="M115.256,32.325v-1.523c0-1.663-0.637-3.104-1.895-4.28c-1.258-1.176-2.797-1.773-4.575-1.773H89.241 v7.576H115.256z" />
|
||||
<path fill="#D7C067" d="M115.256,35.167h-8.1v8.606h-9.814v-8.606h-8.101v16.182h19.544c1.779,0,3.318-0.596,4.575-1.772 c1.258-1.177,1.895-2.617,1.895-4.28V35.167z" />
|
||||
</g>
|
||||
<g>
|
||||
<polygon fill="#D7C067" points="149.168,32.325 147.142,24.75 134.552,24.75 132.527,32.325 " />
|
||||
<polygon fill="#D7C067" points="145.187,47.726 146.104,51.349 154.25,51.349 149.878,35.167 141.827,35.167 143.522,41.509 138.171,41.509 139.866,35.166 131.718,35.167 127.441,51.349 135.542,51.349 136.51,47.726 " />
|
||||
</g>
|
||||
<g>
|
||||
<path fill="#D7C067" d="M182.177,41.219c0-1.662-0.637-3.103-1.894-4.279c-1.257-1.177-2.797-1.773-4.576-1.773h-19.537 c0.07,1.543,0.704,2.885,1.888,3.992c1.257,1.175,2.797,1.771,4.576,1.771h11.444v2.843h-17.915v7.576h19.543 c1.779,0,3.319-0.596,4.576-1.772c1.256-1.177,1.894-2.617,1.894-4.28V41.219z" />
|
||||
<path fill="#D7C067" d="M156.164,32.325h26.013V24.75h-19.542c-1.778,0-3.319,0.597-4.576,1.773 c-1.257,1.175-1.895,2.617-1.895,4.28V32.325z" />
|
||||
</g>
|
||||
<g>
|
||||
<rect x="194.171" y="35.167" fill="#D7C067" width="8.101" height="16.182" />
|
||||
<rect x="185.214" y="24.75" fill="#D7C067" width="26.015" height="7.576" />
|
||||
</g>
|
||||
<g>
|
||||
<path fill="#D7C067" d="M240.278,32.325v-1.523c0-1.663-0.637-3.104-1.895-4.28c-1.259-1.176-2.798-1.773-4.576-1.773h-19.544 v7.576H240.278z" />
|
||||
<path fill="#D7C067" d="M214.264,51.349h8.1V40.93h9.815v10.418h8.1V41.219c0-0.852-0.604-1.85-1.796-2.966l-0.219-0.204 l0.219-0.205c1.061-0.992,1.655-1.891,1.774-2.677h-25.992V51.349z" />
|
||||
</g>
|
||||
<g>
|
||||
<polygon fill="#D7C067" points="259.936,47.726 260.854,51.349 269,51.349 264.627,35.167 256.577,35.167 258.272,41.509 252.92,41.509 254.616,35.166 246.466,35.167 242.189,51.349 250.291,51.349 251.26,47.726 " />
|
||||
<polygon fill="#D7C067" points="263.916,32.325 261.893,24.75 249.301,24.75 247.275,32.325 " />
|
||||
</g>
|
||||
</g>
|
||||
<g id="Star_1_">
|
||||
<path fillRule="evenodd" clipRule="evenodd" fill="#DDDCDB" d="M83.315,18.245c-18.977-0.521-37.805,14.08-53.743,25.012 l6.676-20.473l2.715,7.387c0.54,1.35,1.15,1.016,2.676,0.347C56.554,23.297,63.892,15.637,83.315,18.245z" />
|
||||
<path fill="#D7C067" d="M25.725,48.51l10.771-8.175l11.39,11.317l-11.007-6.955L19.927,57l5.516-21.621L3,23.726l27.347,8.99 L25.725,48.51z" />
|
||||
<path fill="#D7C067" d="M31.53,28.94L8.308,22.501l20.681,1.383l3.331-10.934l2.55,6.38L31.53,28.94z" />
|
||||
<path fill="#D7C067" d="M35.352,3l8.294,19.697c0.14,0.322,0.265,0.545,0.554,0.408C56.45,16.93,66.625,7.79,96.508,17.836 c-31.457-6.516-38.135,2.598-55.074,10.246c-0.337,0.14-0.624-0.113-0.914-0.832L33.412,9.368L35.352,3z" />
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default LogoHeader;
|
||||
@@ -0,0 +1,44 @@
|
||||
const LogoSmall: React.FC = () => {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="218" height="48" viewBox="0 0 218 48">
|
||||
<g id="Ad_Astra_3_">
|
||||
<g>
|
||||
<polygon fill="#D7C067" points="66.158,26.352 64.543,20.319 54.508,20.319 52.896,26.352 " />
|
||||
<polygon fill="#D7C067" points="62.985,38.615 63.716,41.5 70.209,41.5 66.724,28.614 60.308,28.614 61.658,33.665 57.394,33.665 58.745,28.614 52.25,28.614 48.842,41.5 55.299,41.5 56.069,38.615 " />
|
||||
</g>
|
||||
<g>
|
||||
<path fill="#D7C067" d="M92.467,26.352v-1.212c0-1.325-0.508-2.472-1.51-3.409c-1.003-0.937-2.23-1.412-3.647-1.412H71.733v6.033 H92.467z" />
|
||||
<path fill="#D7C067" d="M92.467,28.614h-6.456v6.853H78.19v-6.853h-6.457V41.5H87.31c1.418,0,2.645-0.475,3.647-1.411 c1.002-0.938,1.51-2.083,1.51-3.409V28.614z" />
|
||||
</g>
|
||||
<g>
|
||||
<polygon fill="#D7C067" points="119.495,26.352 117.88,20.319 107.846,20.319 106.232,26.352 " />
|
||||
<polygon fill="#D7C067" points="116.322,38.615 117.053,41.5 123.546,41.5 120.061,28.614 113.644,28.614 114.995,33.665 110.73,33.665 112.081,28.614 105.588,28.614 102.179,41.5 108.635,41.5 109.406,38.615 " />
|
||||
</g>
|
||||
<g>
|
||||
<path fill="#D7C067" d="M145.803,33.434c0-1.324-0.508-2.471-1.509-3.408c-1.002-0.937-2.229-1.412-3.647-1.412h-15.571 c0.056,1.229,0.561,2.297,1.505,3.179c1.001,0.936,2.229,1.41,3.647,1.41h9.121v2.264H125.07V41.5h15.576 c1.418,0,2.645-0.475,3.647-1.411c1.001-0.938,1.509-2.083,1.509-3.409V33.434z" />
|
||||
<path fill="#D7C067" d="M125.07,26.352h20.732v-6.033h-15.575c-1.417,0-2.646,0.475-3.647,1.412 c-1.002,0.936-1.51,2.084-1.51,3.409V26.352z" />
|
||||
</g>
|
||||
<g>
|
||||
<rect x="155.362" y="28.614" fill="#D7C067" width="6.456" height="12.886" />
|
||||
<rect x="148.223" y="20.319" fill="#D7C067" width="20.733" height="6.033" />
|
||||
</g>
|
||||
<g>
|
||||
<path fill="#D7C067" d="M192.109,26.352v-1.212c0-1.325-0.508-2.472-1.51-3.409c-1.003-0.937-2.23-1.412-3.647-1.412h-15.576 v6.033H192.109z" />
|
||||
<path fill="#D7C067" d="M171.375,41.5h6.456v-8.296h7.823V41.5h6.456v-8.066c0-0.679-0.481-1.473-1.432-2.362l-0.174-0.163 l0.174-0.163c0.846-0.79,1.319-1.506,1.414-2.132h-20.716V41.5z" />
|
||||
</g>
|
||||
<g>
|
||||
<polygon fill="#D7C067" points="207.776,38.615 208.508,41.5 215,41.5 211.515,28.614 205.099,28.614 206.45,33.665 202.185,33.665 203.536,28.614 197.041,28.614 193.632,41.5 200.088,41.5 200.861,38.615 " />
|
||||
<polygon fill="#D7C067" points="210.948,26.352 209.335,20.319 199.3,20.319 197.686,26.352 " />
|
||||
</g>
|
||||
</g>
|
||||
<g id="Star_1_">
|
||||
<path fillRule="evenodd" clipRule="evenodd" fill="#DDDCDB" d="M67.011,15.14c-15.125-0.415-30.13,11.212-42.833,19.917 l5.321-16.303l2.163,5.883c0.43,1.075,0.917,0.809,2.133,0.276C45.682,19.162,51.53,13.062,67.011,15.14z" />
|
||||
<path fill="#D7C067" d="M21.111,39.24l8.584-6.51l9.078,9.012l-8.772-5.538L16.491,46l4.396-17.216L3,19.504l21.795,7.159 L21.111,39.24z" />
|
||||
<path fill="#D7C067" d="M25.738,23.656L7.23,18.529l16.482,1.101l2.655-8.707l2.032,5.081L25.738,23.656z" />
|
||||
<path fill="#D7C067" d="M28.784,3l6.61,15.685c0.112,0.257,0.211,0.435,0.442,0.326c9.763-4.918,17.872-12.196,41.689-4.196 c-25.071-5.188-30.394,2.068-43.894,8.159c-0.269,0.111-0.497-0.09-0.729-0.662L27.238,8.07L28.784,3z" />
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default LogoSmall;
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./tagArticleList";
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Pagination, PaginationItem } from "@/modules/common/components/pagination";
|
||||
import { calcTotalPageNumber } from "@/modules/common/functions/pagination";
|
||||
import { ArticleCard } from "@/modules/features/article/components/articleCard";
|
||||
import { fetchArticlesByTag } from "@/modules/features/article/fetch/fetchArticleList";
|
||||
|
||||
const Pages = ({ totalPages, currentPage }: { totalPages: number; currentPage: number }) => (
|
||||
<Pagination>
|
||||
{[...Array(totalPages)].map((_, index) => {
|
||||
const page = index + 1;
|
||||
const href = `/?tab=global&page=${page}`;
|
||||
return (
|
||||
<PaginationItem href={href} active={page === currentPage} key={index}>
|
||||
{page}
|
||||
</PaginationItem>
|
||||
);
|
||||
})}
|
||||
</Pagination>
|
||||
);
|
||||
|
||||
export const TagArticleList = async ({ tag, currentPage }: { tag: string; currentPage: number }) => {
|
||||
const { articles, articlesCount } = await fetchArticlesByTag(tag, currentPage);
|
||||
const totalPages = calcTotalPageNumber(articlesCount, 10);
|
||||
|
||||
return articles.length < 1 ? (
|
||||
<p>No articles found.</p>
|
||||
) : (
|
||||
<>
|
||||
{articles.map((article, index) => (
|
||||
<ArticleCard key={index} article={article} />
|
||||
))}
|
||||
{1 < totalPages && <Pages currentPage={currentPage} totalPages={totalPages} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import { fetchTagsList } from "@/modules/features/article/fetch/fetchTagsList";
|
||||
import { TagList as TagListPresentation } from "./presentation";
|
||||
|
||||
export const TagList = async () => {
|
||||
const tags = await fetchTagsList();
|
||||
return <TagListPresentation tags={tags} />;
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./container";
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Tag } from "@/modules/features/article/components/tag";
|
||||
|
||||
type Props = { tags: string[] };
|
||||
|
||||
export const TagList = ({ tags }: Props) => (
|
||||
<div className="sidebar">
|
||||
<p>Popular Tags</p>
|
||||
<div className="tag-list">
|
||||
{tags.map((tag, index) => (
|
||||
<Tag component="a" variant="filled" href={`/?tab=tag&tag=${tag}`} key={index}>
|
||||
{tag}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./timeClock";
|
||||
@@ -0,0 +1,174 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
|
||||
const TimeClock: React.FC = () => {
|
||||
useEffect(() => {
|
||||
|
||||
const numbers = [
|
||||
[1, 1, 1, 1, 1, 1, 0, 0, 0, 1, 1, 1, 1, 1, 1], // 0
|
||||
[1, 0, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 1], // 1
|
||||
[1, 0, 1, 1, 1, 1, 0, 1, 0, 1, 1, 1, 1, 0, 1], // 2
|
||||
[1, 0, 1, 0, 1, 1, 0, 1, 0, 1, 1, 1, 1, 1, 1], // 3
|
||||
[1, 1, 1, 0, 0, 0, 0, 1, 0, 0, 1, 1, 1, 1, 1], // 4
|
||||
[1, 1, 1, 0, 1, 1, 0, 1, 0, 1, 1, 0, 1, 1, 1], // 5
|
||||
[1, 1, 1, 1, 1, 1, 0, 1, 0, 1, 1, 0, 1, 1, 1], // 6
|
||||
[1, 0, 0, 0, 0, 1, 0, 1, 1, 1, 1, 1, 0, 0, 0], // 7
|
||||
[1, 1, 1, 1, 1, 1, 0, 1, 0, 1, 1, 1, 1, 1, 1], // 8
|
||||
[1, 1, 1, 0, 1, 1, 0, 1, 0, 1, 1, 1, 1, 1, 1] // 9
|
||||
];
|
||||
|
||||
const blocks: Array<Element[]> = [];
|
||||
const digits = Array.from(document.querySelectorAll('.block'));
|
||||
|
||||
for (let i = 0; i < 4; i++) {
|
||||
blocks.push(digits.slice(i * 15, i * 15 + 15));
|
||||
}
|
||||
|
||||
const setNum = (block: Array<Element>, num: number) => {
|
||||
let n = numbers[num];
|
||||
for (let i = 0; i < block.length; i++) {
|
||||
block[i].classList[n[i] === 1 ? 'add' : 'remove']('active');
|
||||
}
|
||||
};
|
||||
|
||||
const time = {
|
||||
s: '',
|
||||
m: '',
|
||||
h: '',
|
||||
p: 0
|
||||
};
|
||||
|
||||
// time loop
|
||||
const animator = () => {
|
||||
let d = new Date(),
|
||||
h = d.getHours().toString(),
|
||||
m = d.getMinutes().toString(),
|
||||
s = d.getSeconds().toString();
|
||||
|
||||
s = s.length === 1 ? '0' + s : s;
|
||||
m = m.length === 1 ? '0' + m : m;
|
||||
h = h.length === 1 ? '0' + h : h;
|
||||
|
||||
if (s !== time.s) {
|
||||
for (let i = 0; i < digits.length; i++) {
|
||||
let d = digits[i];
|
||||
if (i === +s) {
|
||||
d.classList.add('second');
|
||||
if (time.p !== null)
|
||||
digits[time.p].classList.remove('second');
|
||||
time.p = i;
|
||||
time.s = s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (m !== time.m) {
|
||||
setNum(blocks[2], parseInt(Array.from(m)[0]));
|
||||
setNum(blocks[3], parseInt(Array.from(m)[1]));
|
||||
time.m = m;
|
||||
}
|
||||
|
||||
if (h !== time.h) {
|
||||
setNum(blocks[0], parseInt(Array.from(h)[0]));
|
||||
setNum(blocks[1], parseInt(Array.from(h)[1]));
|
||||
time.h = h;
|
||||
}
|
||||
window.requestAnimationFrame(animator)
|
||||
}
|
||||
|
||||
// init
|
||||
window.requestAnimationFrame(animator)
|
||||
|
||||
// toggle button
|
||||
var d = new Date();
|
||||
var days = ["Dimanche", "Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi"];
|
||||
var months = ["Janvier", "Février", "Mars", "Avril", "Mai", "Juin", "Juillet", "Août", "Septembre", "Octobre", "Novembre", "Décembre"];
|
||||
var lib_d = days[d.getDay()];
|
||||
var lib_m = months[d.getMonth()];
|
||||
var lib_n = d.getDate();
|
||||
var lib_y = d.getFullYear();
|
||||
document.getElementById("datelib")!.innerHTML = lib_d + " " + lib_n + " " + lib_m + " " + lib_y;
|
||||
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="date d-flex mb-2 pt-2">
|
||||
<div className="flex-grow-1"></div>
|
||||
<div className="datelib mt-2 pt-1 ps-1 font-monospace opacity-50 fs-5">
|
||||
<span className="icon-calendar me-2" aria-hidden="true"></span>
|
||||
<span id="datelib"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="time d-flex">
|
||||
<div className="flex-grow-1"></div>
|
||||
<div className="clock position-relative d-flex flex-column flex-wrap">
|
||||
<div className="block" data-num="0"></div>
|
||||
<div className="block" data-num="1"></div>
|
||||
<div className="block" data-num="2"></div>
|
||||
<div className="block" data-num="3"></div>
|
||||
<div className="block" data-num="4"></div>
|
||||
<div className="block" data-num="5"></div>
|
||||
<div className="block" data-num="6"></div>
|
||||
<div className="block" data-num="7"></div>
|
||||
<div className="block" data-num="8"></div>
|
||||
<div className="block" data-num="9"></div>
|
||||
<div className="block" data-num="10"></div>
|
||||
<div className="block" data-num="11"></div>
|
||||
<div className="block" data-num="12"></div>
|
||||
<div className="block" data-num="13"></div>
|
||||
<div className="block" data-num="14"></div>
|
||||
<div className="block" data-num="15"></div>
|
||||
<div className="block" data-num="16"></div>
|
||||
<div className="block" data-num="17"></div>
|
||||
<div className="block" data-num="18"></div>
|
||||
<div className="block" data-num="19"></div>
|
||||
<div className="block" data-num="20"></div>
|
||||
<div className="block" data-num="21"></div>
|
||||
<div className="block" data-num="22"></div>
|
||||
<div className="block" data-num="23"></div>
|
||||
<div className="block" data-num="24"></div>
|
||||
<div className="block" data-num="25"></div>
|
||||
<div className="block" data-num="26"></div>
|
||||
<div className="block" data-num="27"></div>
|
||||
<div className="block" data-num="28"></div>
|
||||
<div className="block" data-num="29"></div>
|
||||
<div className="block" data-num="30"></div>
|
||||
<div className="block" data-num="31"></div>
|
||||
<div className="block" data-num="32"></div>
|
||||
<div className="block" data-num="33"></div>
|
||||
<div className="block" data-num="34"></div>
|
||||
<div className="block" data-num="35"></div>
|
||||
<div className="block" data-num="36"></div>
|
||||
<div className="block" data-num="37"></div>
|
||||
<div className="block" data-num="38"></div>
|
||||
<div className="block" data-num="39"></div>
|
||||
<div className="block" data-num="40"></div>
|
||||
<div className="block" data-num="41"></div>
|
||||
<div className="block" data-num="42"></div>
|
||||
<div className="block" data-num="43"></div>
|
||||
<div className="block" data-num="44"></div>
|
||||
<div className="block" data-num="45"></div>
|
||||
<div className="block" data-num="46"></div>
|
||||
<div className="block" data-num="47"></div>
|
||||
<div className="block" data-num="48"></div>
|
||||
<div className="block" data-num="49"></div>
|
||||
<div className="block" data-num="50"></div>
|
||||
<div className="block" data-num="51"></div>
|
||||
<div className="block" data-num="52"></div>
|
||||
<div className="block" data-num="53"></div>
|
||||
<div className="block" data-num="54"></div>
|
||||
<div className="block" data-num="55"></div>
|
||||
<div className="block" data-num="56"></div>
|
||||
<div className="block" data-num="57"></div>
|
||||
<div className="block" data-num="58"></div>
|
||||
<div className="block" data-num="59"></div>
|
||||
<div className="divider position-absolute"></div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TimeClock;
|
||||
@@ -0,0 +1,9 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const SearchParams = z.object({
|
||||
page: z.coerce.number().int().min(1).catch(1),
|
||||
tab: z.enum(["yours", "global", "tag"]).default("global"),
|
||||
tag: z.string().optional(),
|
||||
});
|
||||
|
||||
export type SearchParams = z.infer<typeof SearchParams>;
|
||||
@@ -0,0 +1,9 @@
|
||||
.actions {
|
||||
& * + * {
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.action-btn + .action-btn {
|
||||
margin-left: 5px;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Button } from "@/modules/common/components/button";
|
||||
import { DeleteArticleButton } from "@/modules/features/article/components/deleteArticleButton";
|
||||
import { FavoriteButton } from "@/modules/features/article/components/favoriteButton";
|
||||
import { Tag } from "@/modules/features/article/components/tag";
|
||||
import { fetchArticle } from "@/modules/features/article/fetch/fetchArticle";
|
||||
import { convertMarkdownToHtml } from "@/modules/features/article/functions";
|
||||
import { FollowButton } from "@/modules/features/profile/components/followButton";
|
||||
import { Article, User } from "@/utils/types/models";
|
||||
import Link from "next/link";
|
||||
import { ReactNode } from "react";
|
||||
import styles from "./articleArea.module.css";
|
||||
import { showDeleteArticleButton, showEditArticleButton, showFollowButton } from "./functions";
|
||||
import { getSession } from "@/utils/auth/session";
|
||||
import { fetchCurrentUser } from "@/modules/features/auth/fetch/fetchCurrentUser";
|
||||
|
||||
const Actions = ({ article, currentUser }: { article: Article; currentUser?: User }) => {
|
||||
const profile = article.author;
|
||||
|
||||
return (
|
||||
<div className="article-meta">
|
||||
<Link href={`/profile/${profile.username}`}>{profile.image && <img src={profile.image} alt="" />}</Link>
|
||||
<div className="info">
|
||||
<Link href={`/profile/${profile.username}`} className="author">
|
||||
{profile.username}
|
||||
</Link>
|
||||
<span className="date">{article.createdAt.toDateString()}</span>
|
||||
</div>
|
||||
{showFollowButton(profile.username, currentUser) && (
|
||||
<FollowButton {...profile} className={styles["action-btn"]} />
|
||||
)}
|
||||
<FavoriteButton {...article} showMessage={true} className={styles["action-btn"]} />
|
||||
{showEditArticleButton(profile.username, currentUser) && (
|
||||
<Button component="a" href={`/editor/${article.slug}`} color="secondary" className={styles["action-btn"]}>
|
||||
<i className="ion-edit"></i> Edit Article
|
||||
</Button>
|
||||
)}
|
||||
{showDeleteArticleButton(profile.username, currentUser) && (
|
||||
<DeleteArticleButton slug={article.slug} className={styles["action-btn"]} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ArticleArea = async ({ slug, children }: { slug: string; children: ReactNode }) => {
|
||||
const article = await fetchArticle(slug);
|
||||
const body = await convertMarkdownToHtml(article.body);
|
||||
const currentUser = (await getSession()) ? await fetchCurrentUser() : undefined;
|
||||
|
||||
return (
|
||||
<div className="article-page">
|
||||
<div className="banner">
|
||||
<div className="container">
|
||||
<h1>{article.title}</h1>
|
||||
<Actions article={article} currentUser={currentUser} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="container page">
|
||||
<div className="row article-content">
|
||||
<div className="col-md-12">
|
||||
<div dangerouslySetInnerHTML={{ __html: body }} />
|
||||
<ul className="tag-list">
|
||||
{article.tagList.map((tag, index) => (
|
||||
<Tag component="li" variant="outline" key={index}>
|
||||
{tag}
|
||||
</Tag>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<hr />
|
||||
<div className="article-actions">
|
||||
<Actions article={article} currentUser={currentUser} />
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import { User } from "@/utils/types/models";
|
||||
|
||||
export const showFollowButton = (authorUsername: string, currentUser: User | undefined) => {
|
||||
return authorUsername !== currentUser?.username;
|
||||
};
|
||||
|
||||
export const showEditArticleButton = (authorUsername: string, currentUser: User | undefined) => {
|
||||
return authorUsername === currentUser?.username;
|
||||
};
|
||||
|
||||
export const showDeleteArticleButton = (authorUsername: string, currentUser: User | undefined) => {
|
||||
return authorUsername === currentUser?.username;
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./articleArea";
|
||||
@@ -0,0 +1,30 @@
|
||||
"use server";
|
||||
|
||||
import { createApiClient } from "@/utils/api/apiClient";
|
||||
import { getSession } from "@/utils/auth/session";
|
||||
import { revalidateTag } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { Inputs } from "./types";
|
||||
|
||||
export const deleteCommentAction = async (_prevState: undefined, inputs: Inputs): Promise<undefined> => {
|
||||
if ((await getSession()) == null) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const apiClient = createApiClient({
|
||||
path: "/articles/{slug}/comments/{id}",
|
||||
method: "delete",
|
||||
params: {
|
||||
path: inputs,
|
||||
},
|
||||
});
|
||||
|
||||
const response = await apiClient.sendRequest();
|
||||
|
||||
if (response.result === "success") {
|
||||
revalidateTag("");
|
||||
return undefined;
|
||||
}
|
||||
|
||||
throw new Error("api error");
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { Comment, User } from "@/utils/types/models";
|
||||
import { use, useActionState } from "react";
|
||||
import { deleteCommentAction as serverAction } from "./action";
|
||||
import { CommentCard as CommentCardPresentation } from "./presentation";
|
||||
import { showDeleteCommentButton } from "./functions";
|
||||
|
||||
type Props = {
|
||||
slug: string;
|
||||
comment: Comment;
|
||||
currentUserPromise?: Promise<User>;
|
||||
};
|
||||
|
||||
export const CommentCard = ({ slug, comment, currentUserPromise }: Props) => {
|
||||
const [_state, dispatch, isPending] = useActionState(serverAction, undefined);
|
||||
|
||||
const action = () => {
|
||||
if (!confirm("Delete comment?")) {
|
||||
return;
|
||||
}
|
||||
dispatch({ slug, id: comment.id });
|
||||
};
|
||||
|
||||
return (
|
||||
<CommentCardPresentation
|
||||
comment={comment}
|
||||
showDeleteCommentButton={showDeleteCommentButton(
|
||||
comment.author.username,
|
||||
currentUserPromise && use(currentUserPromise),
|
||||
)}
|
||||
deleteCommentAction={action}
|
||||
isPending={isPending}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
import { User } from "@/utils/types/models";
|
||||
|
||||
export const showDeleteCommentButton = (commentAuthorUsername: string, currentUser?: User) => {
|
||||
return commentAuthorUsername === currentUser?.username;
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./container";
|
||||
@@ -0,0 +1,13 @@
|
||||
.form {
|
||||
display: inline;
|
||||
|
||||
& button {
|
||||
background: none;
|
||||
color: inherit;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
outline: inherit;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Comment } from "@/utils/types/models";
|
||||
import Link from "next/link";
|
||||
import styles from "./presentation.module.css";
|
||||
|
||||
type Props = {
|
||||
comment: Comment;
|
||||
showDeleteCommentButton?: boolean;
|
||||
deleteCommentAction?: () => void;
|
||||
isPending?: boolean;
|
||||
};
|
||||
|
||||
export const CommentCard = ({ comment, showDeleteCommentButton, deleteCommentAction }: Props) => {
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-block">
|
||||
<p className="card-text">{comment.body}</p>
|
||||
</div>
|
||||
<div className="card-footer">
|
||||
<Link href={`/profile/${comment.author.username}`} className="comment-author">
|
||||
{comment.author.image && <img src={comment.author.image} className="comment-author-img" alt="" />}
|
||||
</Link>
|
||||
|
||||
<Link href={`/profile/${comment.author.username}`} className="comment-author">
|
||||
{comment.author.username}
|
||||
</Link>
|
||||
<span className="date-posted">{comment.createdAt.toDateString()}</span>
|
||||
{showDeleteCommentButton && (
|
||||
<form action={deleteCommentAction} className={styles["form"]}>
|
||||
<button className="mod-options" type="submit">
|
||||
<i className="ion-trash-a" />
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export type Inputs = {
|
||||
slug: string;
|
||||
id: string;
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
"use server";
|
||||
|
||||
import { createApiClient } from "@/utils/api/apiClient";
|
||||
import { getSession } from "@/utils/auth/session";
|
||||
import { SubmissionResult } from "@conform-to/react";
|
||||
import { parseWithZod } from "@conform-to/zod";
|
||||
import { revalidateTag } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { inputsSchema } from "./types";
|
||||
|
||||
export const postCommentAction = async (
|
||||
_prevState: unknown,
|
||||
formData: FormData,
|
||||
): Promise<SubmissionResult<string[]>> => {
|
||||
if ((await getSession()) == null) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const submission = parseWithZod(formData, { schema: inputsSchema });
|
||||
|
||||
if (submission.status !== "success") {
|
||||
return submission.reply();
|
||||
}
|
||||
|
||||
const { slug, body } = submission.value;
|
||||
|
||||
const apiClient = createApiClient({
|
||||
path: "/articles/{slug}/comments",
|
||||
method: "post",
|
||||
params: {
|
||||
path: {
|
||||
slug,
|
||||
},
|
||||
body: {
|
||||
comment: {
|
||||
body,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const response = await apiClient.sendRequest();
|
||||
|
||||
if (response.result === "success") {
|
||||
revalidateTag("");
|
||||
return submission.reply();
|
||||
}
|
||||
|
||||
switch (response.statusCode) {
|
||||
case 422:
|
||||
return submission.reply({
|
||||
formErrors: Object.values(response.error.errors).flat(),
|
||||
});
|
||||
default:
|
||||
throw new Error("api error");
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { User } from "@/utils/types/models";
|
||||
import { use, useActionState } from "react";
|
||||
import { postCommentAction } from "./action";
|
||||
import { CommentForm as CommentFormPresentation } from "./presentation";
|
||||
|
||||
type Props = {
|
||||
slug: string;
|
||||
currentUserPromise?: Promise<User>;
|
||||
};
|
||||
|
||||
export const CommentForm = ({ slug, currentUserPromise }: Props) => {
|
||||
const currentUser = currentUserPromise && use(currentUserPromise);
|
||||
const [state, action, isPending] = useActionState(postCommentAction, undefined);
|
||||
|
||||
return (
|
||||
<CommentFormPresentation
|
||||
slug={slug}
|
||||
authorImage={currentUser?.image}
|
||||
result={state}
|
||||
postCommentAction={action}
|
||||
isPending={isPending}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./container";
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Button } from "@/modules/common/components/button";
|
||||
import { ErrorMessage } from "@/modules/common/components/errorMessage";
|
||||
import { SubmissionResult, useForm } from "@conform-to/react";
|
||||
import { parseWithZod } from "@conform-to/zod";
|
||||
import { startTransition } from "react";
|
||||
import { inputsSchema } from "./types";
|
||||
|
||||
type Props = {
|
||||
slug?: string;
|
||||
authorImage?: string;
|
||||
result?: SubmissionResult<string[]>;
|
||||
postCommentAction?: (formData: FormData) => void;
|
||||
isPending?: boolean;
|
||||
};
|
||||
|
||||
export const CommentForm = ({ slug, authorImage, result, postCommentAction, isPending }: Props) => {
|
||||
const [form, fields] = useForm({
|
||||
defaultValue: {
|
||||
slug,
|
||||
},
|
||||
lastResult: result,
|
||||
onValidate({ formData }) {
|
||||
return parseWithZod(formData, { schema: inputsSchema });
|
||||
},
|
||||
// onSubmit is defined to avoid resetting form after successful submission
|
||||
// see https://github.com/edmundhung/conform/discussions/606
|
||||
onSubmit(event, { formData }) {
|
||||
event.preventDefault();
|
||||
startTransition(() => {
|
||||
postCommentAction?.(formData);
|
||||
});
|
||||
},
|
||||
shouldRevalidate: "onBlur",
|
||||
});
|
||||
|
||||
const errors = Object.values(form.allErrors).flat();
|
||||
|
||||
return (
|
||||
<>
|
||||
<form id={form.id} action={postCommentAction} onSubmit={form.onSubmit} className="card comment-form">
|
||||
<input type="hidden" key={fields.slug.key} name={fields.slug.name} defaultValue={fields.slug.initialValue} />
|
||||
<div className="card-block">
|
||||
<textarea
|
||||
key={fields.body.key}
|
||||
name={fields.body.name}
|
||||
defaultValue={fields.body.initialValue}
|
||||
placeholder="Write a comment..."
|
||||
rows={3}
|
||||
className="form-control"
|
||||
></textarea>
|
||||
<ErrorMessage messages={errors} />
|
||||
</div>
|
||||
<div className="card-footer">
|
||||
{authorImage && <img src={authorImage} alt="" className="comment-author-img" />}
|
||||
<Button component="button" type="submit" disabled={isPending}>
|
||||
Post Comment
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const inputsSchema = z.object({
|
||||
slug: z.string().max(200),
|
||||
body: z.string().max(200),
|
||||
});
|
||||
|
||||
export type Inputs = z.infer<typeof inputsSchema>;
|
||||
@@ -0,0 +1,21 @@
|
||||
import { fetchComments } from "@/modules/features/article/fetch/fetchComments";
|
||||
import { fetchCurrentUser } from "@/modules/features/auth/fetch/fetchCurrentUser";
|
||||
import { getSession } from "@/utils/auth/session";
|
||||
import { CommentCard } from "../commentCard";
|
||||
|
||||
type Props = {
|
||||
slug: string;
|
||||
};
|
||||
|
||||
export const CommentList = async ({ slug }: Props) => {
|
||||
const comments = await fetchComments(slug);
|
||||
const currentUserPromise = (await getSession()) ? fetchCurrentUser() : undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
{comments.map((comment, index) => (
|
||||
<CommentCard key={index} slug={slug} comment={comment} currentUserPromise={currentUserPromise} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./commentList";
|
||||
@@ -0,0 +1,30 @@
|
||||
import { fetchCurrentUser } from "@/modules/features/auth/fetch/fetchCurrentUser";
|
||||
import { getSession } from "@/utils/auth/session";
|
||||
import { Suspense } from "react";
|
||||
import { ArticleArea } from "./_components/articleArea";
|
||||
import { CommentForm } from "./_components/commentForm";
|
||||
import { CommentList } from "./_components/commentList";
|
||||
|
||||
type Params = Promise<{
|
||||
slug: string;
|
||||
}>;
|
||||
|
||||
const Page = async (props: { params: Params }) => {
|
||||
const currentUserPromise = (await getSession()) ? fetchCurrentUser() : undefined;
|
||||
const params = await props.params;
|
||||
|
||||
return (
|
||||
<ArticleArea slug={params.slug}>
|
||||
<div className="row">
|
||||
<div className="col-xs-12 col-md-8 offset-md-2">
|
||||
<CommentForm slug={params.slug} currentUserPromise={currentUserPromise} />
|
||||
<Suspense fallback={<p>⌛Loading comments...</p>}>
|
||||
<CommentList slug={params.slug} />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</ArticleArea>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
@@ -0,0 +1,29 @@
|
||||
import { UpdateArticleEditor } from "@/modules/features/article/components/articleEditor";
|
||||
import { fetchArticle } from "@/modules/features/article/fetch/fetchArticle";
|
||||
import { Suspense } from "react";
|
||||
|
||||
type Props = {
|
||||
params: Promise<{
|
||||
slug: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
const Page = async ({ params }: Props) => {
|
||||
const article = fetchArticle((await params).slug);
|
||||
|
||||
return (
|
||||
<div className="editor-page">
|
||||
<div className="container page">
|
||||
<div className="row">
|
||||
<div className="col-md-10 offset-md-1 col-xs-12">
|
||||
<Suspense fallback={<p>⌛Loading...</p>}>
|
||||
<UpdateArticleEditor defaultValues={article} />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
@@ -0,0 +1,15 @@
|
||||
import { CreateArticleEditor } from "@/modules/features/article/components/articleEditor";
|
||||
|
||||
const Page = () => (
|
||||
<div className="editor-page">
|
||||
<div className="container page">
|
||||
<div className="row">
|
||||
<div className="col-md-10 offset-md-1 col-xs-12">
|
||||
<CreateArticleEditor />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default Page;
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/modules/common/components/button";
|
||||
|
||||
const Error = ({
|
||||
reset,
|
||||
}: {
|
||||
error: Error & {
|
||||
digest?: string;
|
||||
};
|
||||
reset: () => void;
|
||||
}) => {
|
||||
return (
|
||||
<div className="error-page">
|
||||
<div className="container page">
|
||||
<p className="error-message">⚠️Something went wrong!</p>
|
||||
<Button component="button" size="lg" onClick={() => reset()}>
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Error;
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 8.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "App",
|
||||
"icons": [
|
||||
{
|
||||
"src": "\/favicon\/android-icon-36x36.png",
|
||||
"sizes": "36x36",
|
||||
"type": "image\/png",
|
||||
"density": "0.75"
|
||||
},
|
||||
{
|
||||
"src": "\/favicon\/android-icon-48x48.png",
|
||||
"sizes": "48x48",
|
||||
"type": "image\/png",
|
||||
"density": "1.0"
|
||||
},
|
||||
{
|
||||
"src": "\/favicon\/android-icon-72x72.png",
|
||||
"sizes": "72x72",
|
||||
"type": "image\/png",
|
||||
"density": "1.5"
|
||||
},
|
||||
{
|
||||
"src": "\/favicon\/android-icon-96x96.png",
|
||||
"sizes": "96x96",
|
||||
"type": "image\/png",
|
||||
"density": "2.0"
|
||||
},
|
||||
{
|
||||
"src": "\/favicon\/android-icon-144x144.png",
|
||||
"sizes": "144x144",
|
||||
"type": "image\/png",
|
||||
"density": "3.0"
|
||||
},
|
||||
{
|
||||
"src": "\/favicon\/android-icon-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image\/png",
|
||||
"density": "4.0"
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
@@ -0,0 +1,72 @@
|
||||
import { Footer } from "@/modules/common/components/footer";
|
||||
import { Header } from "@/modules/features/auth/components/header";
|
||||
//import "@/styles/font.css";
|
||||
import "@/styles/styles.scss";
|
||||
import "@/styles/icomoon.scss";
|
||||
//import "ionicons/css/ionicons.min.css";
|
||||
import type { Viewport } from "next";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
export const fetchCache = "default-no-store";
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: 'device-width',
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
userScalable: false,
|
||||
themeColor: "#00a3ff",
|
||||
}
|
||||
|
||||
export async function generateMetadata() {
|
||||
return {
|
||||
title: "Ad Astra",
|
||||
description: "Ad Astra, fleurs et résines de CBD et autre cannabinoïdes.",
|
||||
manifest: "/favicon/manifest.json",
|
||||
icons: {
|
||||
icon: [
|
||||
{ url: '/favicon/android-icon-192x192.png', sizes: '192x192', type: 'image/png' },
|
||||
{ url: '/favicon/favicon-16x16.png', sizes: '16x16', type: 'image/png' },
|
||||
{ url: '/favicon/favicon-32x32.png', sizes: '32x32', type: 'image/png' },
|
||||
{ url: '/favicon/favicon-96x96.png', sizes: '96x96', type: 'image/png' },
|
||||
],
|
||||
shortcut: [
|
||||
{ url: '/favicon/favicon.ico', type:'image/vnd.microsoft.icon'}
|
||||
],
|
||||
apple: [
|
||||
{ url: '/favicon/apple-icon-57x57.png', sizes: '57x57', type: 'image/png' },
|
||||
{ url: '/favicon/apple-icon-60x60.png', sizes: '60x60', type: 'image/png' },
|
||||
{ url: '/favicon/apple-icon-72x72.png', sizes: '72x72', type: 'image/png' },
|
||||
{ url: '/favicon/apple-icon-76x76.png', sizes: '76x76', type: 'image/png' },
|
||||
{ url: '/favicon/apple-icon-114x114.png', sizes: '114x114', type: 'image/png' },
|
||||
{ url: '/favicon/apple-icon-120x120.png', sizes: '120x120', type: 'image/png' },
|
||||
{ url: '/favicon/apple-icon-144x144.png', sizes: '144x144', type: 'image/png' },
|
||||
{ url: '/favicon/apple-icon-180x180.png', sizes: '180x180', type: 'image/png' },
|
||||
],
|
||||
},
|
||||
other: {
|
||||
'msapplication-TileColor': '#da532c',
|
||||
'msapplication-TileImage': `${'images/tileimage.jpg'}`,
|
||||
'msapplication-config': '/favicon/browserconfig.xml',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const RootLayout = ({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: ReactNode;
|
||||
}>) => {
|
||||
return (
|
||||
<html className="h-100" lang="en">
|
||||
<body className="d-flex flex-column h-100" data-bs-theme="dark">
|
||||
<Header />
|
||||
<div className="h-100">
|
||||
{children}
|
||||
</div>
|
||||
<Footer />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
};
|
||||
|
||||
export default RootLayout;
|
||||
@@ -0,0 +1,47 @@
|
||||
"use server";
|
||||
|
||||
import { createApiClient } from "@/utils/api/apiClient";
|
||||
import { createSession } from "@/utils/auth/session";
|
||||
import { parseWithZod } from "@conform-to/zod";
|
||||
import { redirect } from "next/navigation";
|
||||
import { inputsSchema } from "./types";
|
||||
|
||||
export const signInAction = async (_prevState: unknown, formData: FormData) => {
|
||||
const submission = parseWithZod(formData, {
|
||||
schema: inputsSchema,
|
||||
});
|
||||
|
||||
if (submission.status !== "success") {
|
||||
return submission.reply();
|
||||
}
|
||||
|
||||
const client = createApiClient({
|
||||
path: "/users/login",
|
||||
method: "post",
|
||||
params: {
|
||||
body: {
|
||||
user: submission.value,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const response = await client.sendRequest();
|
||||
|
||||
if (response.result === "success") {
|
||||
await createSession(response.data.user.token);
|
||||
redirect("/");
|
||||
}
|
||||
|
||||
switch (response.statusCode) {
|
||||
case 401:
|
||||
return submission.reply({
|
||||
formErrors: ["Login failed. The email or password is incorrect."],
|
||||
});
|
||||
case 422:
|
||||
return submission.reply({
|
||||
formErrors: Object.values(response.error.errors).flat(),
|
||||
});
|
||||
default:
|
||||
throw new Error("api error");
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { LoginForm as LoginFormPresentation } from "./presentation";
|
||||
import { signInAction } from "./action";
|
||||
import { useActionState } from "react";
|
||||
|
||||
export const LoginForm = () => {
|
||||
const [state, action, isPending] = useActionState(signInAction, undefined);
|
||||
|
||||
return <LoginFormPresentation result={state} action={action} isPending={isPending} />;
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./container";
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Button } from "@/modules/common/components/button";
|
||||
import { ErrorMessage } from "@/modules/common/components/errorMessage";
|
||||
import { SubmissionResult, useForm } from "@conform-to/react";
|
||||
import { parseWithZod } from "@conform-to/zod";
|
||||
import { startTransition } from "react";
|
||||
import { inputsSchema } from "./types";
|
||||
|
||||
type Props = {
|
||||
action?: (formData: FormData) => void;
|
||||
isPending?: boolean;
|
||||
result?: SubmissionResult<string[]>;
|
||||
};
|
||||
|
||||
export const LoginForm = ({ result, action, isPending }: Props) => {
|
||||
const [form, fields] = useForm({
|
||||
lastResult: result,
|
||||
onValidate({ formData }) {
|
||||
return parseWithZod(formData, { schema: inputsSchema });
|
||||
},
|
||||
// onSubmit is defined to avoid resetting form after successful submission
|
||||
// see https://github.com/edmundhung/conform/discussions/606
|
||||
onSubmit(event, { formData }) {
|
||||
event.preventDefault();
|
||||
startTransition(() => {
|
||||
action?.(formData);
|
||||
});
|
||||
},
|
||||
shouldValidate: "onBlur",
|
||||
shouldRevalidate: "onBlur",
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<ErrorMessage messages={form.errors} />
|
||||
<form id={form.id} onSubmit={form.onSubmit} action={action} noValidate={true}>
|
||||
<fieldset className="form-group">
|
||||
<input
|
||||
type="email"
|
||||
key={fields.email.key}
|
||||
name={fields.email.name}
|
||||
defaultValue={fields.email.initialValue}
|
||||
placeholder="Email"
|
||||
className="form-control form-control-lg"
|
||||
/>
|
||||
<ErrorMessage messages={fields.email.errors} />
|
||||
</fieldset>
|
||||
<fieldset className="form-group">
|
||||
<input
|
||||
type="password"
|
||||
key={fields.password.key}
|
||||
name={fields.password.name}
|
||||
defaultValue={fields.password.initialValue}
|
||||
placeholder="Password"
|
||||
className="form-control form-control-lg"
|
||||
/>
|
||||
<ErrorMessage messages={fields.password.errors} />
|
||||
</fieldset>
|
||||
<Button component="button" size="lg" type="submit" className="pull-xs-right" disabled={isPending}>
|
||||
Se connecter
|
||||
</Button>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const inputsSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string(),
|
||||
});
|
||||
|
||||
export type Inputs = z.infer<typeof inputsSchema>;
|
||||
@@ -0,0 +1,19 @@
|
||||
import Link from "next/link";
|
||||
import { LoginForm } from "./_components/loginForm";
|
||||
|
||||
const Page = () => (
|
||||
<div className="auth-page">
|
||||
<div className="container page">
|
||||
<div className="row">
|
||||
<div className="col-md-6 offset-md-3 col-xs-12">
|
||||
<h1 className="text-xs-center">Se connecter</h1>
|
||||
<p className="text-xs-center">
|
||||
<Link href="/register">Pas encore de compte?</Link>
|
||||
</p>
|
||||
<LoginForm />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
export default Page;
|
||||
@@ -0,0 +1,11 @@
|
||||
const NotFound = () => {
|
||||
return (
|
||||
<div className="not-found-page">
|
||||
<div className="container page">
|
||||
<p className="error-message">This page could not be found.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotFound;
|
||||
@@ -0,0 +1,91 @@
|
||||
import { getSession } from "@/utils/auth/session";
|
||||
import { ExhaustiveError } from "@/utils/errors";
|
||||
import clsx from "clsx";
|
||||
import Link from "next/link";
|
||||
import { Suspense } from "react";
|
||||
import { Container, Col, Row } from "react-bootstrap";
|
||||
import { FeedArticleList } from "./_components/feedArticleList";
|
||||
import { GlobalArticleList } from "./_components/globalArticleList";
|
||||
import { TagArticleList } from "./_components/tagArticleList";
|
||||
import { TagList } from "./_components/tagList";
|
||||
import { SearchParams } from "./_types";
|
||||
import TimeClock from "./_components/timeClock/timeClock";
|
||||
|
||||
const ArticleList = (searchParams: SearchParams) => {
|
||||
switch (searchParams.tab) {
|
||||
case "yours":
|
||||
return <FeedArticleList currentPage={searchParams.page} />;
|
||||
case "global":
|
||||
return <GlobalArticleList currentPage={searchParams.page} />;
|
||||
case "tag":
|
||||
return <TagArticleList currentPage={searchParams.page} tag={searchParams.tag ?? ""} />;
|
||||
default:
|
||||
throw new ExhaustiveError(searchParams.tab, "all tab cases are not covered");
|
||||
}
|
||||
};
|
||||
|
||||
const Page = async (props: { searchParams: Promise<{ [key: string]: string | string[] | undefined }> }) => {
|
||||
const session = await getSession();
|
||||
const searchParams = SearchParams.parse(await props.searchParams);
|
||||
|
||||
return (
|
||||
<div className="home-page h-100">
|
||||
<Container className="banner">
|
||||
<Row>
|
||||
<Col sm={6} md={7} lg={8}>
|
||||
<h1 className="mt-4">Ad Astra, bientôt en ligne!</h1>
|
||||
<p className="fs-4">
|
||||
Encore un peu de patience, notre site sera disponible d'ici peu.
|
||||
</p>
|
||||
</Col>
|
||||
<Col sm={6} md={5} lg={4}>
|
||||
<TimeClock />
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
<Container className="page">
|
||||
<hr className="col-1 my-5 mx-0" />
|
||||
<Row>
|
||||
<Col md={9}>
|
||||
<div className="feed-toggle">
|
||||
<ul className="nav nav-pills outline-active">
|
||||
{session && (
|
||||
<li className="nav-item">
|
||||
<Link className={clsx("nav-link", searchParams.tab === "yours" && "active")} href="/?tab=yours">
|
||||
Your Feed
|
||||
</Link>
|
||||
</li>
|
||||
)}
|
||||
<li className="nav-item">
|
||||
<Link className={clsx("nav-link", searchParams.tab === "global" && "active")} href="/?tab=global">
|
||||
Global Feed
|
||||
</Link>
|
||||
</li>
|
||||
{searchParams.tag && (
|
||||
<li className="nav-item">
|
||||
<Link
|
||||
className={clsx("nav-link", searchParams.tab === "tag" && "active")}
|
||||
href={`/?tab=tag&tag=${searchParams.tag}`}
|
||||
>
|
||||
#{searchParams.tag}
|
||||
</Link>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
<Suspense key={JSON.stringify(searchParams)} fallback={<p>⌛Chargement...</p>}>
|
||||
<ArticleList {...searchParams} />
|
||||
</Suspense>
|
||||
</Col>
|
||||
<Col md={3}>
|
||||
<Suspense fallback={<p>⌛Chargement...</p>}>
|
||||
<TagList />
|
||||
</Suspense>
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./myArticleList";
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Pagination, PaginationItem } from "@/modules/common/components/pagination";
|
||||
import { calcTotalPageNumber } from "@/modules/common/functions/pagination";
|
||||
import { ArticleCard } from "@/modules/features/article/components/articleCard";
|
||||
import { fetchArticlesByAuthor } from "@/modules/features/article/fetch/fetchArticleList";
|
||||
|
||||
const Pages = ({ totalPages, currentPage }: { totalPages: number; currentPage: number }) => (
|
||||
<Pagination>
|
||||
{[...Array(totalPages)].map((_, index) => {
|
||||
const page = index + 1;
|
||||
const href = `/?tab=global&page=${page}`;
|
||||
return (
|
||||
<PaginationItem href={href} active={page === currentPage} key={index}>
|
||||
{page}
|
||||
</PaginationItem>
|
||||
);
|
||||
})}
|
||||
</Pagination>
|
||||
);
|
||||
|
||||
export const MyArticleList = async ({ username, currentPage }: { currentPage: number; username: string }) => {
|
||||
const { articles, articlesCount } = await fetchArticlesByAuthor(username, currentPage);
|
||||
const totalPages = calcTotalPageNumber(articlesCount, 10);
|
||||
|
||||
return articles.length < 1 ? (
|
||||
<p>No articles found.</p>
|
||||
) : (
|
||||
<>
|
||||
{articles.map((article, index) => (
|
||||
<ArticleCard key={index} article={article} />
|
||||
))}
|
||||
{1 < totalPages && <Pages currentPage={currentPage} totalPages={totalPages} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import { User } from "@/utils/types/models";
|
||||
|
||||
export const showEditProfileSettingsButton = (profileUsername: string, currentUser: User | undefined) => {
|
||||
if (currentUser == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return profileUsername === currentUser.username;
|
||||
};
|
||||
|
||||
export const showFollowButton = (profileUsername: string, currentUser: User | undefined) => {
|
||||
return profileUsername !== currentUser?.username;
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const searchParamsSchema = z.object({
|
||||
page: z.coerce.number().min(1).catch(1),
|
||||
});
|
||||
|
||||
export type SearchParams = z.infer<typeof searchParamsSchema>;
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
import { Pagination, PaginationItem } from "@/modules/common/components/pagination";
|
||||
import { calcTotalPageNumber } from "@/modules/common/functions/pagination";
|
||||
import { ArticleCard } from "@/modules/features/article/components/articleCard";
|
||||
import { fetchFavoriteArticles } from "@/modules/features/article/fetch/fetchArticleList";
|
||||
|
||||
const Pages = ({ totalPages, currentPage }: { totalPages: number; currentPage: number }) => (
|
||||
<Pagination>
|
||||
{[...Array(totalPages)].map((_, index) => {
|
||||
const page = index + 1;
|
||||
const href = `/?tab=global&page=${page}`;
|
||||
return (
|
||||
<PaginationItem href={href} active={page === currentPage} key={index}>
|
||||
{page}
|
||||
</PaginationItem>
|
||||
);
|
||||
})}
|
||||
</Pagination>
|
||||
);
|
||||
|
||||
export const FavoriteArticleList = async ({ username, currentPage }: { currentPage: number; username: string }) => {
|
||||
const { articles, articlesCount } = await fetchFavoriteArticles(username, currentPage);
|
||||
const totalPages = calcTotalPageNumber(articlesCount, 10);
|
||||
|
||||
return articles.length < 1 ? (
|
||||
<p>No articles found.</p>
|
||||
) : (
|
||||
<>
|
||||
{articles.map((article, index) => (
|
||||
<ArticleCard key={index} article={article} />
|
||||
))}
|
||||
{1 < totalPages && <Pages currentPage={currentPage} totalPages={totalPages} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./favoriteArticleList";
|
||||
@@ -0,0 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const SearchParams = z.object({
|
||||
page: z.coerce.number().min(1).catch(1),
|
||||
});
|
||||
|
||||
export type SearchParams = z.infer<typeof SearchParams>;
|
||||
@@ -0,0 +1,39 @@
|
||||
import { decodePathParams } from "@/modules/common/functions/url";
|
||||
import Link from "next/link";
|
||||
import { Suspense } from "react";
|
||||
import { FavoriteArticleList } from "./_components/favoriteArticleList";
|
||||
import { SearchParams } from "./_types/types";
|
||||
|
||||
const FavoritesPage = async (props: {
|
||||
params: Promise<{
|
||||
username: string;
|
||||
}>;
|
||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||
}) => {
|
||||
const searchParams = SearchParams.parse(await props.searchParams);
|
||||
const pathParams = decodePathParams(await props.params);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="articles-toggle">
|
||||
<ul className="nav nav-pills outline-active">
|
||||
<li className="nav-item">
|
||||
<Link className="nav-link" href={`/profile/${pathParams.username}`}>
|
||||
My Articles
|
||||
</Link>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<Link className="nav-link active" href={`/profile/${pathParams.username}/favorites`}>
|
||||
Favorited Articles
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<Suspense key={JSON.stringify(searchParams)} fallback={<p>⌛Loading...</p>}>
|
||||
<FavoriteArticleList currentPage={searchParams.page} username={pathParams.username} />
|
||||
</Suspense>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default FavoritesPage;
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Button } from "@/modules/common/components/button";
|
||||
import { fetchCurrentUser } from "@/modules/features/auth/fetch/fetchCurrentUser";
|
||||
import { FollowButton } from "@/modules/features/profile/components/followButton";
|
||||
import { fetchProfile } from "@/modules/features/profile/fetch/fetchProfile";
|
||||
import { getSession } from "@/utils/auth/session";
|
||||
import { ReactNode } from "react";
|
||||
import { showEditProfileSettingsButton, showFollowButton } from "./_functions";
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
params: Promise<{
|
||||
username: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
const Layout = async ({ children, params }: Props) => {
|
||||
const profile = await fetchProfile((await params).username);
|
||||
const currentUser = (await getSession()) ? await fetchCurrentUser() : undefined;
|
||||
|
||||
return (
|
||||
<div className="profile-page">
|
||||
<div className="user-info">
|
||||
<div className="container">
|
||||
<div className="row">
|
||||
<div className="col-xs-12 col-md-10 offset-md-1">
|
||||
{profile.image && <img src={profile.image} className="user-img" alt="" />}
|
||||
<h4>{profile.username}</h4>
|
||||
{profile.bio && <p>{profile.bio}</p>}
|
||||
{showFollowButton(profile.username, currentUser) && <FollowButton {...profile} color="secondary" />}
|
||||
{showEditProfileSettingsButton(profile.username, currentUser) && (
|
||||
<Button component="a" href="/settings" className="action-btn" color="secondary">
|
||||
<i className="ion-plus-round"></i> Edit Profile Settings
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="container">
|
||||
<div className="row">
|
||||
<div className="col-xs-12 col-md-10 offset-md-1">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
@@ -0,0 +1,39 @@
|
||||
import { decodePathParams } from "@/modules/common/functions/url";
|
||||
import Link from "next/link";
|
||||
import { Suspense } from "react";
|
||||
import { MyArticleList } from "./_components/myArticleList";
|
||||
import { searchParamsSchema } from "./_types";
|
||||
|
||||
const ProfilePage = async (props: {
|
||||
params: Promise<{
|
||||
username: string;
|
||||
}>;
|
||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||
}) => {
|
||||
const pathParams = decodePathParams(await props.params);
|
||||
const searchParams = searchParamsSchema.parse(await props.searchParams);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="articles-toggle">
|
||||
<ul className="nav nav-pills outline-active">
|
||||
<li className="nav-item">
|
||||
<Link className="nav-link active" href={`/profile/${pathParams.username}`}>
|
||||
My Articles
|
||||
</Link>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<Link className="nav-link" href={`/profile/${pathParams.username}/favorites`}>
|
||||
Favorited Articles
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<Suspense key={JSON.stringify(searchParams)} fallback={<p>⌛Loading...</p>}>
|
||||
<MyArticleList currentPage={searchParams.page} username={pathParams.username} />
|
||||
</Suspense>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfilePage;
|
||||
@@ -0,0 +1,43 @@
|
||||
"use server";
|
||||
|
||||
import { createApiClient } from "@/utils/api/apiClient";
|
||||
import { createSession } from "@/utils/auth/session";
|
||||
import { parseWithZod } from "@conform-to/zod";
|
||||
import { redirect } from "next/navigation";
|
||||
import { inputsSchema } from "./types";
|
||||
|
||||
export const signUpAction = async (_prevState: unknown, formData: FormData) => {
|
||||
const submission = parseWithZod(formData, {
|
||||
schema: inputsSchema,
|
||||
});
|
||||
|
||||
if (submission.status !== "success") {
|
||||
return submission.reply();
|
||||
}
|
||||
|
||||
const client = createApiClient({
|
||||
path: "/users",
|
||||
method: "post",
|
||||
params: {
|
||||
body: {
|
||||
user: submission.value,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const response = await client.sendRequest();
|
||||
|
||||
if (response.result === "success") {
|
||||
createSession(response.data.user.token);
|
||||
redirect("/");
|
||||
}
|
||||
|
||||
switch (response.statusCode) {
|
||||
case 422:
|
||||
return submission.reply({
|
||||
formErrors: Object.values(response.error.errors).flat(),
|
||||
});
|
||||
default:
|
||||
throw new Error("api error");
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState } from "react";
|
||||
import { signUpAction } from "./action";
|
||||
import { RegistrationForm as RegistrationFormPresentation } from "./presentation";
|
||||
|
||||
export const RegistrationForm = () => {
|
||||
const [state, dispatch, isPending] = useActionState(signUpAction, undefined);
|
||||
|
||||
return <RegistrationFormPresentation result={state} action={dispatch} isPending={isPending} />;
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./container";
|
||||
@@ -0,0 +1,102 @@
|
||||
import { Button, Form } from 'react-bootstrap';
|
||||
//import { Button } from "@/modules/common/components/button";
|
||||
import { ErrorMessage } from "@/modules/common/components/errorMessage";
|
||||
import { SubmissionResult, useForm } from "@conform-to/react";
|
||||
import { parseWithZod } from "@conform-to/zod";
|
||||
import { startTransition } from "react";
|
||||
import { inputsSchema } from "./types";
|
||||
|
||||
type Props = {
|
||||
action?: (formData: FormData) => void;
|
||||
isPending?: boolean;
|
||||
result?: SubmissionResult<string[]>;
|
||||
};
|
||||
|
||||
export const RegistrationForm = ({ result, action, isPending }: Props) => {
|
||||
const [form, fields] = useForm({
|
||||
lastResult: result,
|
||||
onValidate({ formData }) {
|
||||
return parseWithZod(formData, { schema: inputsSchema });
|
||||
},
|
||||
// onSubmit is defined to avoid resetting form after successful submission
|
||||
// see https://github.com/edmundhung/conform/discussions/606
|
||||
onSubmit(event, { formData }) {
|
||||
event.preventDefault();
|
||||
startTransition(() => {
|
||||
action?.(formData);
|
||||
});
|
||||
},
|
||||
shouldValidate: "onBlur",
|
||||
shouldRevalidate: "onBlur",
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<ErrorMessage messages={form.errors} />
|
||||
<Form id={form.id} action={action} onSubmit={form.onSubmit} noValidate={true}>
|
||||
<Form.Group className="mb-3" controlId="formFirstname">
|
||||
<Form.Label>Nom</Form.Label>
|
||||
<Form.Control
|
||||
type="text"
|
||||
key={fields.lastname.key}
|
||||
name={fields.lastname.name}
|
||||
defaultValue={fields.lastname.initialValue}
|
||||
placeholder="Votre nom"
|
||||
/>
|
||||
<ErrorMessage messages={fields.firstname.errors} />
|
||||
</Form.Group>
|
||||
<Form.Group className="mb-3" controlId="formLastname">
|
||||
<Form.Label>Prénom</Form.Label>
|
||||
<Form.Control
|
||||
type="text"
|
||||
key={fields.firstname.key}
|
||||
name={fields.firstname.name}
|
||||
defaultValue={fields.firstname.initialValue}
|
||||
placeholder="Votre prénom"
|
||||
/>
|
||||
<ErrorMessage messages={fields.lastname.errors} />
|
||||
</Form.Group>
|
||||
<Form.Group className="mb-3" controlId="formUsername">
|
||||
<Form.Label>Pseudo</Form.Label>
|
||||
<Form.Control
|
||||
type="text"
|
||||
key={fields.username.key}
|
||||
name={fields.username.name}
|
||||
defaultValue={fields.username.initialValue}
|
||||
placeholder="Votre pseudo"
|
||||
/>
|
||||
<ErrorMessage messages={fields.username.errors} />
|
||||
</Form.Group>
|
||||
<Form.Group className="mb-3" controlId="formEmail">
|
||||
<Form.Label>Adresse email</Form.Label>
|
||||
<Form.Control
|
||||
type="email"
|
||||
key={fields.email.key}
|
||||
name={fields.email.name}
|
||||
defaultValue={fields.email.initialValue}
|
||||
placeholder="Votre adresse email"
|
||||
/>
|
||||
<ErrorMessage messages={fields.email.errors} />
|
||||
<Form.Text className="text-muted">
|
||||
Nous ne partagerons jamais votre email avec qui que ce soit.
|
||||
</Form.Text>
|
||||
</Form.Group>
|
||||
<Form.Group className="mb-3" controlId="formPassword">
|
||||
<Form.Label>Mot de passe</Form.Label>
|
||||
<Form.Control
|
||||
type="password"
|
||||
key={fields.password.key}
|
||||
name={fields.password.name}
|
||||
defaultValue={fields.password.initialValue}
|
||||
placeholder="Mot de passe"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<ErrorMessage messages={fields.password.errors} />
|
||||
</Form.Group>
|
||||
<Button variant="primary" type="submit" disabled={isPending}>
|
||||
Submit
|
||||
</Button>
|
||||
</Form>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const inputsSchema = z.object({
|
||||
username: z.string().max(200),
|
||||
firstname: z.string().max(200),
|
||||
lastname: z.string().max(200),
|
||||
email: z.string().email(),
|
||||
password: z.string().min(4).max(200),
|
||||
});
|
||||
|
||||
export type Inputs = z.infer<typeof inputsSchema>;
|
||||
@@ -0,0 +1,20 @@
|
||||
import Link from "next/link";
|
||||
import { RegistrationForm } from "./_components/registrationForm";
|
||||
|
||||
const Page = () => (
|
||||
<div className="auth-page">
|
||||
<div className="container page">
|
||||
<div className="row">
|
||||
<div className="col-md-6 offset-md-3 col-xs-12">
|
||||
<h1 className="text-xs-center">Se connecter</h1>
|
||||
<p className="text-xs-center">
|
||||
<Link href="/login">Déjà un compte?</Link>
|
||||
</p>
|
||||
<RegistrationForm />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default Page;
|
||||
@@ -0,0 +1,45 @@
|
||||
"use server";
|
||||
|
||||
import { createApiClient } from "@/utils/api/apiClient";
|
||||
import { redirect } from "next/navigation";
|
||||
import { inputsSchema } from "./types";
|
||||
import { createSession } from "@/utils/auth/session";
|
||||
import { parseWithZod } from "@conform-to/zod";
|
||||
import { SubmissionResult } from "@conform-to/react";
|
||||
|
||||
export const updateSettingsAction = async (_prevState: SubmissionResult<string[]> | undefined, formData: FormData) => {
|
||||
const submission = parseWithZod(formData, {
|
||||
schema: inputsSchema,
|
||||
});
|
||||
|
||||
if (submission.status !== "success") {
|
||||
return submission.reply();
|
||||
}
|
||||
|
||||
const client = createApiClient({
|
||||
path: "/user",
|
||||
method: "put",
|
||||
params: {
|
||||
body: {
|
||||
user: submission.value,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const response = await client.sendRequest();
|
||||
|
||||
if (response.result === "success") {
|
||||
const user = response.data.user;
|
||||
await createSession(user.token);
|
||||
redirect(`/profile/${user.username}`);
|
||||
}
|
||||
|
||||
switch (response.statusCode) {
|
||||
case 422:
|
||||
return submission.reply({
|
||||
formErrors: Object.values(response.error.errors).flat(),
|
||||
});
|
||||
default:
|
||||
throw new Error("api error");
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { use, useActionState } from "react";
|
||||
import { SettingsForm as SettingsFormPresentation } from "./presentation";
|
||||
import { updateSettingsAction } from "./action";
|
||||
import { User } from "@/utils/types/models";
|
||||
|
||||
export const SettingsForm = ({ user }: { user: Promise<User> }) => {
|
||||
const [state, action, isPending] = useActionState(updateSettingsAction, undefined);
|
||||
|
||||
return <SettingsFormPresentation user={use(user)} result={state} action={action} isPending={isPending} />;
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./container";
|
||||
@@ -0,0 +1,111 @@
|
||||
import { Button } from "@/modules/common/components/button";
|
||||
import { ErrorMessage } from "@/modules/common/components/errorMessage";
|
||||
import { User } from "@/utils/types/models";
|
||||
import { SubmissionResult, useForm } from "@conform-to/react";
|
||||
import { parseWithZod } from "@conform-to/zod";
|
||||
import { startTransition } from "react";
|
||||
import { inputsSchema } from "./types";
|
||||
|
||||
type Props = {
|
||||
user?: User;
|
||||
action?: (formData: FormData) => void;
|
||||
isPending?: boolean;
|
||||
result?: SubmissionResult<string[]>;
|
||||
};
|
||||
|
||||
export const SettingsForm = ({ user, action, isPending, result }: Props) => {
|
||||
const [form, fields] = useForm({
|
||||
lastResult: result,
|
||||
onValidate({ formData }) {
|
||||
return parseWithZod(formData, { schema: inputsSchema });
|
||||
},
|
||||
// onSubmit is defined to avoid resetting form after successful submission
|
||||
// see https://github.com/edmundhung/conform/discussions/606
|
||||
onSubmit(event, { formData }) {
|
||||
event.preventDefault();
|
||||
startTransition(() => {
|
||||
action?.(formData);
|
||||
});
|
||||
},
|
||||
defaultValue: { ...user },
|
||||
shouldValidate: "onBlur",
|
||||
shouldRevalidate: "onBlur",
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<ErrorMessage messages={form.errors} />
|
||||
<form id={form.id} action={action} onSubmit={form.onSubmit} noValidate={true}>
|
||||
<fieldset>
|
||||
<fieldset className="form-group">
|
||||
<input
|
||||
type="text"
|
||||
key={fields.image.key}
|
||||
name={fields.image.name}
|
||||
defaultValue={fields.image.initialValue}
|
||||
placeholder="URL of profile picture"
|
||||
className="form-control"
|
||||
/>
|
||||
<ErrorMessage messages={fields.image.errors} />
|
||||
</fieldset>
|
||||
<fieldset className="form-group">
|
||||
<input
|
||||
type="text"
|
||||
key={fields.username.key}
|
||||
name={fields.username.name}
|
||||
defaultValue={fields.username.initialValue}
|
||||
placeholder="Your Name"
|
||||
className="form-control form-control-lg"
|
||||
/>
|
||||
<ErrorMessage messages={fields.username.errors} />
|
||||
</fieldset>
|
||||
<fieldset className="form-group">
|
||||
<textarea
|
||||
key={fields.bio.key}
|
||||
name={fields.bio.name}
|
||||
defaultValue={fields.bio.initialValue}
|
||||
placeholder="Short bio about you"
|
||||
className="form-control form-control-lg"
|
||||
rows={8}
|
||||
></textarea>
|
||||
<ErrorMessage messages={fields.bio.errors} />
|
||||
</fieldset>
|
||||
<fieldset className="form-group">
|
||||
<input
|
||||
type="email"
|
||||
key={fields.email.key}
|
||||
name={fields.email.name}
|
||||
defaultValue={fields.email.initialValue}
|
||||
placeholder="Email"
|
||||
className="form-control form-control-lg"
|
||||
/>
|
||||
<ErrorMessage messages={fields.email.errors} />
|
||||
</fieldset>
|
||||
<fieldset className="form-group">
|
||||
<input
|
||||
type="password"
|
||||
key={fields.password.key}
|
||||
name={fields.password.name}
|
||||
defaultValue={fields.password.initialValue}
|
||||
placeholder="New Password"
|
||||
autoComplete="new-password"
|
||||
className="form-control form-control-lg"
|
||||
/>
|
||||
<ErrorMessage messages={fields.password.errors} />
|
||||
</fieldset>
|
||||
<Button
|
||||
component="button"
|
||||
size="lg"
|
||||
color="primary"
|
||||
variant="filled"
|
||||
type="submit"
|
||||
className="pull-xs-right"
|
||||
disabled={isPending}
|
||||
>
|
||||
Update Settings
|
||||
</Button>
|
||||
</fieldset>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const inputsSchema = z.object({
|
||||
image: z.string().max(200).optional(),
|
||||
username: z.string().max(200),
|
||||
bio: z.string().max(200).optional(),
|
||||
email: z.string().max(200).email(),
|
||||
password: z.string().min(4).max(200).optional(),
|
||||
});
|
||||
|
||||
export type Inputs = z.infer<typeof inputsSchema>;
|
||||
@@ -0,0 +1,27 @@
|
||||
import { LogoutButton } from "@/modules/features/auth/components/logoutButton";
|
||||
import { fetchCurrentUser } from "@/modules/features/auth/fetch/fetchCurrentUser";
|
||||
import { Suspense } from "react";
|
||||
import { SettingsForm } from "./_components";
|
||||
|
||||
const Page = async () => {
|
||||
const user = fetchCurrentUser();
|
||||
|
||||
return (
|
||||
<div className="settings-page">
|
||||
<div className="container page">
|
||||
<div className="row">
|
||||
<div className="col-md-6 offset-md-3 col-xs-12">
|
||||
<h1 className="text-xs-center">Your Settings</h1>
|
||||
<Suspense fallback={<p>⌛Loading...</p>}>
|
||||
<SettingsForm user={user} />
|
||||
</Suspense>
|
||||
<hr />
|
||||
<LogoutButton />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
@@ -0,0 +1,3 @@
|
||||
export const API_BASE_URL = process.env.API_BASE_URL;
|
||||
|
||||
export const SECURE_COOKIE = process.env.NODE_ENV === "production";
|
||||
+840
@@ -0,0 +1,840 @@
|
||||
/**
|
||||
* This file was auto-generated by openapi-typescript.
|
||||
* Do not make direct changes to the file.
|
||||
*/
|
||||
|
||||
export interface paths {
|
||||
"/users/login": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Existing user login
|
||||
* @description Login for existing user
|
||||
*/
|
||||
post: operations["Login"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/users": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** @description Register a new user */
|
||||
post: operations["CreateUser"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/user": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Get current user
|
||||
* @description Gets the currently logged-in user
|
||||
*/
|
||||
get: operations["GetCurrentUser"];
|
||||
/**
|
||||
* Update current user
|
||||
* @description Updated user information for current user
|
||||
*/
|
||||
put: operations["UpdateCurrentUser"];
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/profiles/{username}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Get a profile
|
||||
* @description Get a profile of a user of the system. Auth is optional
|
||||
*/
|
||||
get: operations["GetProfileByUsername"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/profiles/{username}/follow": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Follow a user
|
||||
* @description Follow a user by username
|
||||
*/
|
||||
post: operations["FollowUserByUsername"];
|
||||
/**
|
||||
* Unfollow a user
|
||||
* @description Unfollow a user by username
|
||||
*/
|
||||
delete: operations["UnfollowUserByUsername"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/articles/feed": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Get recent articles from users you follow
|
||||
* @description Get most recent articles from users you follow. Use query parameters to limit. Auth is required
|
||||
*/
|
||||
get: operations["GetArticlesFeed"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/articles": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Get recent articles globally
|
||||
* @description Get most recent articles globally. Use query parameters to filter results. Auth is optional
|
||||
*/
|
||||
get: operations["GetArticles"];
|
||||
put?: never;
|
||||
/**
|
||||
* Create an article
|
||||
* @description Create an article. Auth is required
|
||||
*/
|
||||
post: operations["CreateArticle"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/articles/{slug}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Get an article
|
||||
* @description Get an article. Auth not required
|
||||
*/
|
||||
get: operations["GetArticle"];
|
||||
/**
|
||||
* Update an article
|
||||
* @description Update an article. Auth is required
|
||||
*/
|
||||
put: operations["UpdateArticle"];
|
||||
post?: never;
|
||||
/**
|
||||
* Delete an article
|
||||
* @description Delete an article. Auth is required
|
||||
*/
|
||||
delete: operations["DeleteArticle"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/articles/{slug}/comments": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Get comments for an article
|
||||
* @description Get the comments for an article. Auth is optional
|
||||
*/
|
||||
get: operations["GetArticleComments"];
|
||||
put?: never;
|
||||
/**
|
||||
* Create a comment for an article
|
||||
* @description Create a comment for an article. Auth is required
|
||||
*/
|
||||
post: operations["CreateArticleComment"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/articles/{slug}/comments/{id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post?: never;
|
||||
/**
|
||||
* Delete a comment for an article
|
||||
* @description Delete a comment for an article. Auth is required
|
||||
*/
|
||||
delete: operations["DeleteArticleComment"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/articles/{slug}/favorite": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Favorite an article
|
||||
* @description Favorite an article. Auth is required
|
||||
*/
|
||||
post: operations["CreateArticleFavorite"];
|
||||
/**
|
||||
* Unfavorite an article
|
||||
* @description Unfavorite an article. Auth is required
|
||||
*/
|
||||
delete: operations["DeleteArticleFavorite"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/tags": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Get tags
|
||||
* @description Get tags. Auth not required
|
||||
*/
|
||||
get: operations["GetTags"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
}
|
||||
export type webhooks = Record<string, never>;
|
||||
export interface components {
|
||||
schemas: {
|
||||
LoginUser: {
|
||||
email: string;
|
||||
/** Format: password */
|
||||
password: string;
|
||||
};
|
||||
NewUser: {
|
||||
username: string;
|
||||
email: string;
|
||||
/** Format: password */
|
||||
password: string;
|
||||
};
|
||||
User: {
|
||||
email: string;
|
||||
token: string;
|
||||
username: string;
|
||||
bio: string;
|
||||
image: string;
|
||||
};
|
||||
UpdateUser: {
|
||||
email?: string;
|
||||
password?: string;
|
||||
username?: string;
|
||||
bio?: string;
|
||||
image?: string;
|
||||
};
|
||||
Profile: {
|
||||
username: string;
|
||||
bio: string;
|
||||
image: string;
|
||||
following: boolean;
|
||||
};
|
||||
Article: {
|
||||
slug: string;
|
||||
title: string;
|
||||
description: string;
|
||||
body: string;
|
||||
tagList: string[];
|
||||
/** Format: date-time */
|
||||
createdAt: string;
|
||||
/** Format: date-time */
|
||||
updatedAt: string;
|
||||
favorited: boolean;
|
||||
favoritesCount: number;
|
||||
author: components["schemas"]["Profile"];
|
||||
};
|
||||
NewArticle: {
|
||||
title: string;
|
||||
description: string;
|
||||
body: string;
|
||||
tagList?: string[];
|
||||
};
|
||||
UpdateArticle: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
body?: string;
|
||||
};
|
||||
Comment: {
|
||||
id: string;
|
||||
/** Format: date-time */
|
||||
createdAt: string;
|
||||
/** Format: date-time */
|
||||
updatedAt: string;
|
||||
body: string;
|
||||
author: components["schemas"]["Profile"];
|
||||
};
|
||||
NewComment: {
|
||||
body: string;
|
||||
};
|
||||
GenericErrorModel: {
|
||||
errors: {
|
||||
body: string[];
|
||||
};
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Tags */
|
||||
TagsResponse: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
tags: string[];
|
||||
};
|
||||
};
|
||||
};
|
||||
/** @description Single comment */
|
||||
SingleCommentResponse: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
comment: components["schemas"]["Comment"];
|
||||
};
|
||||
};
|
||||
};
|
||||
/** @description Multiple comments */
|
||||
MultipleCommentsResponse: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
comments: components["schemas"]["Comment"][];
|
||||
};
|
||||
};
|
||||
};
|
||||
/** @description Single article */
|
||||
SingleArticleResponse: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
article: components["schemas"]["Article"];
|
||||
};
|
||||
};
|
||||
};
|
||||
/** @description Multiple articles */
|
||||
MultipleArticlesResponse: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
articles: {
|
||||
slug: string;
|
||||
title: string;
|
||||
description: string;
|
||||
tagList: string[];
|
||||
/** Format: date-time */
|
||||
createdAt: string;
|
||||
/** Format: date-time */
|
||||
updatedAt: string;
|
||||
favorited: boolean;
|
||||
favoritesCount: number;
|
||||
author: components["schemas"]["Profile"];
|
||||
}[];
|
||||
articlesCount: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
/** @description Profile */
|
||||
ProfileResponse: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
profile: components["schemas"]["Profile"];
|
||||
};
|
||||
};
|
||||
};
|
||||
/** @description User */
|
||||
UserResponse: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
user: components["schemas"]["User"];
|
||||
};
|
||||
};
|
||||
};
|
||||
/** @description No content */
|
||||
EmptyOkResponse: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
/** @description Unauthorized */
|
||||
Unauthorized: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
/** @description Unexpected error */
|
||||
GenericError: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["GenericErrorModel"];
|
||||
};
|
||||
};
|
||||
};
|
||||
parameters: {
|
||||
/** @description The number of items to skip before starting to collect the result set. */
|
||||
offsetParam: number;
|
||||
/** @description The numbers of items to return. */
|
||||
limitParam: number;
|
||||
};
|
||||
requestBodies: {
|
||||
/** @description Credentials to use */
|
||||
LoginUserRequest: {
|
||||
content: {
|
||||
"application/json": {
|
||||
user: components["schemas"]["LoginUser"];
|
||||
};
|
||||
};
|
||||
};
|
||||
/** @description Details of the new user to register */
|
||||
NewUserRequest: {
|
||||
content: {
|
||||
"application/json": {
|
||||
user: components["schemas"]["NewUser"];
|
||||
};
|
||||
};
|
||||
};
|
||||
/** @description User details to update. At least **one** field is required. */
|
||||
UpdateUserRequest: {
|
||||
content: {
|
||||
"application/json": {
|
||||
user: components["schemas"]["UpdateUser"];
|
||||
};
|
||||
};
|
||||
};
|
||||
/** @description Article to create */
|
||||
NewArticleRequest: {
|
||||
content: {
|
||||
"application/json": {
|
||||
article: components["schemas"]["NewArticle"];
|
||||
};
|
||||
};
|
||||
};
|
||||
/** @description Article to update */
|
||||
UpdateArticleRequest: {
|
||||
content: {
|
||||
"application/json": {
|
||||
article: components["schemas"]["UpdateArticle"];
|
||||
};
|
||||
};
|
||||
};
|
||||
/** @description Comment you want to create */
|
||||
NewCommentRequest: {
|
||||
content: {
|
||||
"application/json": {
|
||||
comment: components["schemas"]["NewComment"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
headers: never;
|
||||
pathItems: never;
|
||||
}
|
||||
export type $defs = Record<string, never>;
|
||||
export interface operations {
|
||||
Login: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: components["requestBodies"]["LoginUserRequest"];
|
||||
responses: {
|
||||
200: components["responses"]["UserResponse"];
|
||||
401: components["responses"]["Unauthorized"];
|
||||
422: components["responses"]["GenericError"];
|
||||
};
|
||||
};
|
||||
CreateUser: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: components["requestBodies"]["NewUserRequest"];
|
||||
responses: {
|
||||
201: components["responses"]["UserResponse"];
|
||||
422: components["responses"]["GenericError"];
|
||||
};
|
||||
};
|
||||
GetCurrentUser: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["UserResponse"];
|
||||
401: components["responses"]["Unauthorized"];
|
||||
422: components["responses"]["GenericError"];
|
||||
};
|
||||
};
|
||||
UpdateCurrentUser: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: components["requestBodies"]["UpdateUserRequest"];
|
||||
responses: {
|
||||
200: components["responses"]["UserResponse"];
|
||||
401: components["responses"]["Unauthorized"];
|
||||
422: components["responses"]["GenericError"];
|
||||
};
|
||||
};
|
||||
GetProfileByUsername: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description Username of the profile to get */
|
||||
username: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["ProfileResponse"];
|
||||
401: components["responses"]["Unauthorized"];
|
||||
422: components["responses"]["GenericError"];
|
||||
};
|
||||
};
|
||||
FollowUserByUsername: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description Username of the profile you want to follow */
|
||||
username: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["ProfileResponse"];
|
||||
401: components["responses"]["Unauthorized"];
|
||||
422: components["responses"]["GenericError"];
|
||||
};
|
||||
};
|
||||
UnfollowUserByUsername: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description Username of the profile you want to unfollow */
|
||||
username: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["ProfileResponse"];
|
||||
401: components["responses"]["Unauthorized"];
|
||||
422: components["responses"]["GenericError"];
|
||||
};
|
||||
};
|
||||
GetArticlesFeed: {
|
||||
parameters: {
|
||||
query?: {
|
||||
/** @description The number of items to skip before starting to collect the result set. */
|
||||
offset?: components["parameters"]["offsetParam"];
|
||||
/** @description The numbers of items to return. */
|
||||
limit?: components["parameters"]["limitParam"];
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["MultipleArticlesResponse"];
|
||||
401: components["responses"]["Unauthorized"];
|
||||
422: components["responses"]["GenericError"];
|
||||
};
|
||||
};
|
||||
GetArticles: {
|
||||
parameters: {
|
||||
query?: {
|
||||
/** @description Filter by tag */
|
||||
tag?: string;
|
||||
/** @description Filter by author (username) */
|
||||
author?: string;
|
||||
/** @description Filter by favorites of a user (username) */
|
||||
favorited?: string;
|
||||
/** @description The number of items to skip before starting to collect the result set. */
|
||||
offset?: components["parameters"]["offsetParam"];
|
||||
/** @description The numbers of items to return. */
|
||||
limit?: components["parameters"]["limitParam"];
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["MultipleArticlesResponse"];
|
||||
401: components["responses"]["Unauthorized"];
|
||||
422: components["responses"]["GenericError"];
|
||||
};
|
||||
};
|
||||
CreateArticle: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: components["requestBodies"]["NewArticleRequest"];
|
||||
responses: {
|
||||
201: components["responses"]["SingleArticleResponse"];
|
||||
401: components["responses"]["Unauthorized"];
|
||||
422: components["responses"]["GenericError"];
|
||||
};
|
||||
};
|
||||
GetArticle: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description Slug of the article to get */
|
||||
slug: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["SingleArticleResponse"];
|
||||
422: components["responses"]["GenericError"];
|
||||
};
|
||||
};
|
||||
UpdateArticle: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description Slug of the article to update */
|
||||
slug: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: components["requestBodies"]["UpdateArticleRequest"];
|
||||
responses: {
|
||||
200: components["responses"]["SingleArticleResponse"];
|
||||
401: components["responses"]["Unauthorized"];
|
||||
422: components["responses"]["GenericError"];
|
||||
};
|
||||
};
|
||||
DeleteArticle: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description Slug of the article to delete */
|
||||
slug: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["EmptyOkResponse"];
|
||||
401: components["responses"]["Unauthorized"];
|
||||
422: components["responses"]["GenericError"];
|
||||
};
|
||||
};
|
||||
GetArticleComments: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description Slug of the article that you want to get comments for */
|
||||
slug: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["MultipleCommentsResponse"];
|
||||
401: components["responses"]["Unauthorized"];
|
||||
422: components["responses"]["GenericError"];
|
||||
};
|
||||
};
|
||||
CreateArticleComment: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description Slug of the article that you want to create a comment for */
|
||||
slug: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: components["requestBodies"]["NewCommentRequest"];
|
||||
responses: {
|
||||
200: components["responses"]["SingleCommentResponse"];
|
||||
401: components["responses"]["Unauthorized"];
|
||||
422: components["responses"]["GenericError"];
|
||||
};
|
||||
};
|
||||
DeleteArticleComment: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description Slug of the article that you want to delete a comment for */
|
||||
slug: string;
|
||||
/** @description ID of the comment you want to delete */
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["EmptyOkResponse"];
|
||||
401: components["responses"]["Unauthorized"];
|
||||
422: components["responses"]["GenericError"];
|
||||
};
|
||||
};
|
||||
CreateArticleFavorite: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description Slug of the article that you want to favorite */
|
||||
slug: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["SingleArticleResponse"];
|
||||
401: components["responses"]["Unauthorized"];
|
||||
422: components["responses"]["GenericError"];
|
||||
};
|
||||
};
|
||||
DeleteArticleFavorite: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description Slug of the article that you want to unfavorite */
|
||||
slug: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["SingleArticleResponse"];
|
||||
401: components["responses"]["Unauthorized"];
|
||||
422: components["responses"]["GenericError"];
|
||||
};
|
||||
};
|
||||
GetTags: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["TagsResponse"];
|
||||
422: components["responses"]["GenericError"];
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getSession } from "./utils/auth/session";
|
||||
|
||||
export const config = {
|
||||
matcher: ["/((?!api|_next/static|_next/image|.*\\.png$).*)"],
|
||||
};
|
||||
|
||||
const publicRoutes = [
|
||||
/^\/article\/[^\/]+?\/?$/, // /article/sample-slug
|
||||
/^\/login\/?$/,
|
||||
/^\/profile\/[^\/]+?\/?$/, // /profile/sample-username
|
||||
/^\/profile\/[^\/]+?\/favorites\/?$/, // /profile/sample-username/favorites
|
||||
/^\/register\/?$/,
|
||||
/^\/$/,
|
||||
] as const;
|
||||
|
||||
const isPublicRoutes = (pathname: string) => {
|
||||
return publicRoutes.some((route) => route.test(pathname));
|
||||
};
|
||||
|
||||
const isPrivateRoutes = (pathname: string) => {
|
||||
return !isPublicRoutes(pathname);
|
||||
};
|
||||
|
||||
const middleware = async (req: NextRequest) => {
|
||||
const session = await getSession();
|
||||
if (isPrivateRoutes(req.nextUrl.pathname) && session == null) {
|
||||
return NextResponse.redirect(new URL("/login", req.nextUrl));
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
};
|
||||
|
||||
export default middleware;
|
||||
@@ -0,0 +1,137 @@
|
||||
import { ExhaustiveError } from "@/utils/errors";
|
||||
import clsx from "clsx";
|
||||
import Link from "next/link";
|
||||
import { ComponentPropsWithoutRef } from "react";
|
||||
|
||||
///////////////////
|
||||
// size
|
||||
|
||||
type Size = "sm" | "lg";
|
||||
|
||||
const DEFAULT_SIZE: Size = "sm";
|
||||
|
||||
const sizeToClassNameMaps: Record<Size, string> = {
|
||||
sm: "btn-sm",
|
||||
lg: "btn-lg",
|
||||
};
|
||||
|
||||
///////////////////
|
||||
// color
|
||||
|
||||
export type Color = "primary" | "secondary" | "info" | "success" | "warning" | "danger";
|
||||
|
||||
const DEFAULT_COLOR: Color = "primary";
|
||||
|
||||
///////////////////
|
||||
// variant
|
||||
|
||||
type Variant = "filled" | "outline";
|
||||
|
||||
const DEFAULT_VARIANT: Variant = "outline";
|
||||
|
||||
//////////////////////
|
||||
|
||||
const generateClassNameFromVariantAndColor = (variant: Variant, color: Color) => {
|
||||
const variantMaps: Record<Variant, string> = {
|
||||
filled: "btn-",
|
||||
outline: "btn-outline-",
|
||||
};
|
||||
|
||||
const colorMaps: Record<Color, string> = {
|
||||
primary: "primary",
|
||||
secondary: "secondary",
|
||||
info: "info",
|
||||
success: "success",
|
||||
warning: "warning",
|
||||
danger: "danger",
|
||||
};
|
||||
|
||||
return variantMaps[variant] + colorMaps[color];
|
||||
};
|
||||
|
||||
type CommonProps = {
|
||||
size?: Size;
|
||||
color?: Color;
|
||||
variant?: Variant;
|
||||
};
|
||||
|
||||
///////////////////
|
||||
// button pattern
|
||||
|
||||
type ButtonProps = ComponentPropsWithoutRef<"button"> &
|
||||
CommonProps & {
|
||||
component: "button";
|
||||
};
|
||||
|
||||
const ButtonAsButtonTag = ({
|
||||
className,
|
||||
children,
|
||||
size = DEFAULT_SIZE,
|
||||
color = DEFAULT_COLOR,
|
||||
variant = DEFAULT_VARIANT,
|
||||
...rest
|
||||
}: ButtonProps) => {
|
||||
return (
|
||||
<button
|
||||
className={clsx(
|
||||
"btn",
|
||||
sizeToClassNameMaps[size],
|
||||
generateClassNameFromVariantAndColor(variant, color),
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
///////////////////
|
||||
// anchor pattern
|
||||
|
||||
type AnchorProps = ComponentPropsWithoutRef<"a"> &
|
||||
CommonProps & {
|
||||
href: string;
|
||||
component: "a";
|
||||
};
|
||||
|
||||
const ButtonAsAnchorTag = ({
|
||||
className,
|
||||
children,
|
||||
size = DEFAULT_SIZE,
|
||||
color = DEFAULT_COLOR,
|
||||
variant = DEFAULT_VARIANT,
|
||||
...rest
|
||||
}: AnchorProps) => {
|
||||
return (
|
||||
<Link
|
||||
className={clsx(
|
||||
"btn",
|
||||
sizeToClassNameMaps[size],
|
||||
generateClassNameFromVariantAndColor(variant, color),
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
//////////////////////
|
||||
|
||||
type Props = ButtonProps | AnchorProps;
|
||||
|
||||
export const Button = (props: Props) => {
|
||||
const componentType = props.component;
|
||||
|
||||
switch (componentType) {
|
||||
case "button":
|
||||
return <ButtonAsButtonTag {...props} />;
|
||||
case "a":
|
||||
return <ButtonAsAnchorTag {...props} />;
|
||||
default:
|
||||
// compilation fails if all cases are not covered
|
||||
throw new ExhaustiveError(componentType);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Button from "react-bootstrap/Button";
|
||||
import Dropdown from "react-bootstrap/Dropdown";
|
||||
import DropdownButton from "react-bootstrap/DropdownButton";
|
||||
import Offcanvas from "react-bootstrap/Offcanvas";
|
||||
|
||||
export interface ExampleOffcanvasProps {
|
||||
className?: string | undefined;
|
||||
}
|
||||
|
||||
const ExampleOffcanvas: React.FC<ExampleOffcanvasProps> = ({ className }) => {
|
||||
const [show, setShow] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => setShow((s) => !s)} className={className}>
|
||||
<span className="icon-cart me-2"></span>Mon panier
|
||||
</Button>
|
||||
<Offcanvas placement="end" show={show} onHide={() => setShow(false)}>
|
||||
<Offcanvas.Header closeButton>
|
||||
<Offcanvas.Title as="h5">Offcanvas</Offcanvas.Title>
|
||||
</Offcanvas.Header>
|
||||
<Offcanvas.Body>
|
||||
<div>
|
||||
Some text as placeholder. In real life you can have the elements you
|
||||
have chosen. Like, text, images, lists, etc.
|
||||
</div>
|
||||
|
||||
<DropdownButton
|
||||
variant="secondary"
|
||||
title="Dropdown button"
|
||||
className="mt-3"
|
||||
>
|
||||
<Dropdown.Item href="#">Action</Dropdown.Item>
|
||||
<Dropdown.Item href="#">Another action</Dropdown.Item>
|
||||
<Dropdown.Item href="#">Something else here</Dropdown.Item>
|
||||
</DropdownButton>
|
||||
</Offcanvas.Body>
|
||||
</Offcanvas>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExampleOffcanvas;
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./button";
|
||||
export * from "./buttonOffcanvas";
|
||||
export * from "./linkPopover";
|
||||
@@ -0,0 +1,22 @@
|
||||
import Anchor from "react-bootstrap/Anchor";
|
||||
import OverlayTrigger from "react-bootstrap/OverlayTrigger";
|
||||
import Popover from "react-bootstrap/Popover";
|
||||
|
||||
const ExamplePopover: React.FC = () => {
|
||||
return (
|
||||
<OverlayTrigger
|
||||
trigger="click"
|
||||
placement="right"
|
||||
overlay={
|
||||
<Popover>
|
||||
<Popover.Header as="h3">Custom popover</Popover.Header>
|
||||
<Popover.Body>This is a Bootstrap popover.</Popover.Body>
|
||||
</Popover>
|
||||
}
|
||||
>
|
||||
<Anchor className="text-info">Example popover</Anchor>
|
||||
</OverlayTrigger>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExamplePopover;
|
||||
@@ -0,0 +1,16 @@
|
||||
type Props = {
|
||||
messages?: string[];
|
||||
};
|
||||
|
||||
export const ErrorMessage = ({ messages }: Props) => {
|
||||
return (
|
||||
messages &&
|
||||
messages.length > 0 && (
|
||||
<ul className="error-messages" aria-live="polite">
|
||||
{messages.map((message, index) => (
|
||||
<li key={index}>{message}</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./errorMessage";
|
||||
@@ -0,0 +1,68 @@
|
||||
import Link from "next/link";
|
||||
import LogoSmall from "@/app/_components/layoutElements/logoSmall";
|
||||
import { ComponentPropsWithoutRef } from "react";
|
||||
|
||||
type Props = ComponentPropsWithoutRef<"footer">;
|
||||
|
||||
export const Footer = (props: Props) => (
|
||||
<footer className="bd-footer pt-3 pb-2 pt-md-4 mt-5 bg-blue-gold">
|
||||
<div className="container py-4 py-md-5 px-4 px-md-3 text-body-secondary">
|
||||
<div className="row">
|
||||
<div className="col-lg-3 mb-3">
|
||||
<Link className="d-inline-flex align-items-center mb-2 text-body-emphasis text-decoration-none" href="/" aria-label="Bootstrap">
|
||||
<LogoSmall />
|
||||
</Link>
|
||||
<ul className="list-unstyled small">
|
||||
<li className="mb-2">
|
||||
L'équipe de passionnés Ad Astra est réactive et à l'écoute de vos demandes afin de vous accompagner au mieux et satisfaire vos besoins en fleurs et hashes CBD de qualité.
|
||||
</li>
|
||||
<li className="mb-2">
|
||||
Exigez la qualité Ad Astra, vous la méritez.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="col-6 col-lg-2 offset-lg-1 mb-3">
|
||||
<h5>Nos produits</h5>
|
||||
<ul className="list-unstyled">
|
||||
<li className="mb-2"><Link href="/">Accueil</Link></li>
|
||||
<li className="mb-2"><Link href="/produits/fleurs">Lien 2</Link></li>
|
||||
<li className="mb-2"><Link href="/produits/resines">Lien 3</Link></li>
|
||||
<li className="mb-2"><Link href="/produits/molecules">Lien 4</Link></li>
|
||||
<li className="mb-2"><Link href="/produits/concentres">Lien 5</Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="col-6 col-lg-2 mb-3">
|
||||
<h5>Documentation</h5>
|
||||
<ul className="list-unstyled">
|
||||
<li className="mb-2"><Link href="/fleurs">Les fleurs</Link></li>
|
||||
<li className="mb-2"><Link href="/resines">Les hashes</Link></li>
|
||||
<li className="mb-2"><Link href="/molecules">Les molécules</Link></li>
|
||||
<li className="mb-2"><Link href="/concentres">Les concentrés</Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="col-6 col-lg-2 mb-3">
|
||||
<h5>Professionels</h5>
|
||||
<ul className="list-unstyled">
|
||||
<li className="mb-2"><Link href="/catalogue_ad_astra_2025.pdf" target="_blank" rel="noopener">Catalogue B2B</Link> </li>
|
||||
<li className="mb-2"><Link href="/pro/lien2" target="_blank" rel="noopener">Lien 2</Link> </li>
|
||||
<li className="mb-2"><Link href="/pro/lien3" target="_blank" rel="noopener">Lien 3</Link></li>
|
||||
<li className="mb-2"><Link href="/pro/lien4" target="_blank" rel="noopener">Lien 4</Link></li>
|
||||
<li className="mb-2"> <Link href="/pro/lien5" target="_blank" rel="noopener">Lien 5</Link> </li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="col-6 col-lg-2 mb-3">
|
||||
<h5>Contact</h5>
|
||||
<ul className="list-unstyled">
|
||||
<li className="mb-2"><Link href="/contact" target="_blank" rel="noopener">Lien 1</Link> </li>
|
||||
<li className="mb-2"><Link href="/mentions-legales" target="_blank" rel="noopener">Lien 2</Link> </li>
|
||||
<li className="mb-2"><Link href="/produits" target="_blank" rel="noopener">Lien 3</Link> </li>
|
||||
<li className="mb-2"><Link href="/produits" target="_blank" rel="noopener">Lien 4</Link></li>
|
||||
<li className="mb-2"><Link href="/produits" target="_blank" rel="noopener">Lien 5</Link> </li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr className="mt-3 mb-4" />
|
||||
<p className="text-muted px-4 small">Copyright © 2025 Ad Astra</p>
|
||||
</footer>
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./footer";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./pagination";
|
||||
@@ -0,0 +1,24 @@
|
||||
import clsx from "clsx";
|
||||
import { ComponentPropsWithoutRef } from "react";
|
||||
|
||||
type PaginationItemProps = ComponentPropsWithoutRef<"li"> & {
|
||||
href?: string;
|
||||
active?: boolean;
|
||||
};
|
||||
|
||||
export const PaginationItem = ({ href, active, className, children, ...rest }: PaginationItemProps) => (
|
||||
<li className={clsx("page-item", className, active && "active")} {...rest}>
|
||||
<a className="page-link" href={href}>
|
||||
{children}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
|
||||
type PaginationProps = ComponentPropsWithoutRef<"ul">;
|
||||
|
||||
export const Pagination = ({ className, children, ...rest }: PaginationProps) =>
|
||||
children && (
|
||||
<ul className={clsx("pagination", className)} {...rest}>
|
||||
{children}
|
||||
</ul>
|
||||
);
|
||||
@@ -0,0 +1,13 @@
|
||||
export const calcTotalPageNumber = (totalItemsCount: number, itemsPerPage: number) => {
|
||||
const quotient = Math.floor(totalItemsCount / itemsPerPage);
|
||||
const remainder = totalItemsCount % itemsPerPage;
|
||||
|
||||
return remainder === 0 ? quotient : quotient + 1;
|
||||
};
|
||||
|
||||
export const calcOffsetLimitFromPageNumber = (pageNumber: number, articlesPerPage: number) => {
|
||||
return {
|
||||
offset: (pageNumber - 1) * articlesPerPage,
|
||||
limit: articlesPerPage,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
export const decodePathParams = <TObj extends Record<string, string>, TKey extends keyof TObj>(
|
||||
pathParams: TObj,
|
||||
): Record<TKey, string> => {
|
||||
const decodedKeyValues = Object.entries(pathParams).map(([key, value]) => [key, decodeURIComponent(value)]);
|
||||
return Object.fromEntries(decodedKeyValues);
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import { ArticlePreview } from "@/utils/types/models";
|
||||
import Link from "next/link";
|
||||
import { Tag } from "../tag";
|
||||
import { FavoriteButton } from "../favoriteButton";
|
||||
|
||||
type Props = {
|
||||
article: ArticlePreview;
|
||||
};
|
||||
|
||||
export const ArticleCard = ({ article }: Props) => {
|
||||
const author = article.author;
|
||||
|
||||
return (
|
||||
<div className="article-preview">
|
||||
<div className="article-meta">
|
||||
<Link href={`/profile/${author.username}`}>{author.image && <img src={author.image} alt="" />}</Link>
|
||||
<div className="info">
|
||||
<Link href={`/profile/${author.username}`} className="author">
|
||||
{author.username}
|
||||
</Link>
|
||||
<span className="date">{article.createdAt.toDateString()}</span>
|
||||
</div>
|
||||
<FavoriteButton {...article} showMessage={false} className="pull-xs-right" />
|
||||
</div>
|
||||
<Link href={`/article/${article.slug}`} className="preview-link">
|
||||
<h1>{article.title}</h1>
|
||||
<p>{article.description}</p>
|
||||
<span>Read more...</span>
|
||||
<ul className="tag-list">
|
||||
{article.tagList.map((tag, index) => (
|
||||
<Tag component="li" key={index} variant="outline">
|
||||
{tag}
|
||||
</Tag>
|
||||
))}
|
||||
</ul>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./articleCard";
|
||||
@@ -0,0 +1,91 @@
|
||||
"use server";
|
||||
|
||||
import { createApiClient } from "@/utils/api/apiClient";
|
||||
import { getSession } from "@/utils/auth/session";
|
||||
import { parseWithZod } from "@conform-to/zod";
|
||||
import { redirect } from "next/navigation";
|
||||
import { inputsSchema } from "./types";
|
||||
|
||||
export const createArticleAction = async (_prevState: unknown, formData: FormData) => {
|
||||
if ((await getSession()) == null) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const submission = parseWithZod(formData, { schema: inputsSchema });
|
||||
|
||||
if (submission.status !== "success") {
|
||||
return submission.reply();
|
||||
}
|
||||
|
||||
const { slug: _slug, tag: _tag, ...article } = submission.value;
|
||||
|
||||
const client = createApiClient({
|
||||
path: "/articles",
|
||||
method: "post",
|
||||
params: {
|
||||
body: {
|
||||
article,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const response = await client.sendRequest();
|
||||
|
||||
if (response.result === "success") {
|
||||
redirect(`/article/${response.data.article.slug}`);
|
||||
}
|
||||
|
||||
switch (response.statusCode) {
|
||||
case 422:
|
||||
return submission.reply({
|
||||
formErrors: Object.values(response.error.errors).flat(),
|
||||
});
|
||||
default:
|
||||
throw new Error("api error");
|
||||
}
|
||||
};
|
||||
|
||||
export const updateArticleAction = async (_prevState: unknown, formData: FormData) => {
|
||||
if ((await getSession()) == null) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const submission = parseWithZod(formData, { schema: inputsSchema });
|
||||
|
||||
if (submission.status !== "success") {
|
||||
return submission.reply();
|
||||
}
|
||||
|
||||
const { slug, tag: _tag, ...article } = submission.value;
|
||||
if (slug == null) {
|
||||
throw new Error("Slug is unexpectedly set to null or undefined.");
|
||||
}
|
||||
|
||||
const client = createApiClient({
|
||||
path: "/articles/{slug}",
|
||||
method: "put",
|
||||
params: {
|
||||
path: {
|
||||
slug,
|
||||
},
|
||||
body: {
|
||||
article,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const response = await client.sendRequest();
|
||||
|
||||
if (response.result === "success") {
|
||||
redirect(`/article/${response.data.article.slug}`);
|
||||
}
|
||||
|
||||
switch (response.statusCode) {
|
||||
case 422:
|
||||
return submission.reply({
|
||||
formErrors: Object.values(response.error.errors).flat(),
|
||||
});
|
||||
default:
|
||||
throw new Error("api error");
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { use, useActionState } from "react";
|
||||
import { createArticleAction, updateArticleAction } from "./action";
|
||||
import { ArticleEditor as ArticleEditorPresentation } from "./presentation";
|
||||
import { Article } from "@/utils/types/models";
|
||||
|
||||
export const CreateArticleEditor = () => {
|
||||
const [state, action, isPending] = useActionState(createArticleAction, undefined);
|
||||
|
||||
return <ArticleEditorPresentation result={state} action={action} isPending={isPending} />;
|
||||
};
|
||||
|
||||
export const UpdateArticleEditor = ({ defaultValues }: { defaultValues: Promise<Article> }) => {
|
||||
const [state, action, isPending] = useActionState(updateArticleAction, undefined);
|
||||
|
||||
return (
|
||||
<ArticleEditorPresentation
|
||||
defaultValues={use(defaultValues)}
|
||||
result={state}
|
||||
action={action}
|
||||
isPending={isPending}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./container";
|
||||
@@ -0,0 +1,11 @@
|
||||
.tag-form {
|
||||
& button {
|
||||
background: none;
|
||||
color: inherit;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
outline: inherit;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { Button } from "@/modules/common/components/button";
|
||||
import { ErrorMessage } from "@/modules/common/components/errorMessage";
|
||||
import { Article } from "@/utils/types/models";
|
||||
import { SubmissionResult, useForm } from "@conform-to/react";
|
||||
import { parseWithZod } from "@conform-to/zod";
|
||||
import { KeyboardEventHandler, startTransition } from "react";
|
||||
import { Tag } from "../tag";
|
||||
import styles from "./presentation.module.css";
|
||||
import { inputsSchema } from "./types";
|
||||
|
||||
type Props = {
|
||||
defaultValues?: Article;
|
||||
action?: (formData: FormData) => void;
|
||||
isPending?: boolean;
|
||||
result?: SubmissionResult<string[]>;
|
||||
};
|
||||
|
||||
export const ArticleEditor = ({ defaultValues, result, action, isPending }: Props) => {
|
||||
const [form, fields] = useForm({
|
||||
lastResult: result,
|
||||
onValidate({ formData }) {
|
||||
return parseWithZod(formData, { schema: inputsSchema });
|
||||
},
|
||||
// onSubmit is defined to avoid resetting form after successful submission
|
||||
// see https://github.com/edmundhung/conform/discussions/606
|
||||
onSubmit(event, { formData }) {
|
||||
event.preventDefault();
|
||||
startTransition(() => {
|
||||
action?.(formData);
|
||||
});
|
||||
},
|
||||
defaultValue: { ...defaultValues },
|
||||
shouldValidate: "onBlur",
|
||||
shouldRevalidate: "onBlur",
|
||||
});
|
||||
|
||||
const tagListErrors = Object.values(fields.tagList.allErrors).flat();
|
||||
|
||||
const onTagFormKeyDown: KeyboardEventHandler<HTMLInputElement> = (event) => {
|
||||
switch (event.key) {
|
||||
case "Enter":
|
||||
// prevent form submit
|
||||
event.preventDefault();
|
||||
|
||||
const tag = fields.tag.value;
|
||||
if (!tag) {
|
||||
return;
|
||||
}
|
||||
form.insert({ name: fields.tagList.name, defaultValue: tag });
|
||||
default:
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const onClickRemoveTag = (index: number) => {
|
||||
form.remove({ name: fields.tagList.name, index });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ErrorMessage messages={form.errors} />
|
||||
<form id={form.id} action={action} onSubmit={form.onSubmit} noValidate={true}>
|
||||
<input type="hidden" key={fields.slug.key} name={fields.slug.name} defaultValue={fields.slug.initialValue} />
|
||||
<fieldset>
|
||||
<fieldset className="form-group">
|
||||
<input
|
||||
type="text"
|
||||
key={fields.title.key}
|
||||
name={fields.title.name}
|
||||
defaultValue={fields.title.initialValue}
|
||||
placeholder="Article Title"
|
||||
className="form-control form-control-lg"
|
||||
/>
|
||||
<ErrorMessage messages={fields.title.errors} />
|
||||
</fieldset>
|
||||
<fieldset className="form-group">
|
||||
<input
|
||||
type="text"
|
||||
key={fields.description.key}
|
||||
name={fields.description.name}
|
||||
defaultValue={fields.description.initialValue}
|
||||
placeholder="What's this article about?"
|
||||
className="form-control"
|
||||
/>
|
||||
<ErrorMessage messages={fields.description.errors} />
|
||||
</fieldset>
|
||||
<fieldset className="form-group">
|
||||
<textarea
|
||||
key={fields.body.key}
|
||||
name={fields.body.name}
|
||||
defaultValue={fields.body.initialValue}
|
||||
rows={8}
|
||||
placeholder="Write your article (in markdown)"
|
||||
className="form-control"
|
||||
/>
|
||||
<ErrorMessage messages={fields.body.errors} />
|
||||
</fieldset>
|
||||
<fieldset className="form-group">
|
||||
<input
|
||||
type="text"
|
||||
key={fields.tag.key}
|
||||
name={fields.tag.name}
|
||||
defaultValue={fields.tag.initialValue}
|
||||
placeholder="Enter tags"
|
||||
onKeyDown={onTagFormKeyDown}
|
||||
className="form-control"
|
||||
/>
|
||||
<ul className="tag-list">
|
||||
{fields.tagList.getFieldList().map((tagField, index) => (
|
||||
<li key={tagField.key}>
|
||||
<Tag component="span" variant="filled" className={styles["tag-form"]}>
|
||||
<input type="hidden" name={tagField.name} value={tagField.value} />
|
||||
<button onClick={() => onClickRemoveTag(index)}>
|
||||
<i className="ion-close-round" />
|
||||
</button>
|
||||
{tagField.value}
|
||||
</Tag>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<ErrorMessage messages={tagListErrors} />
|
||||
</fieldset>
|
||||
<Button
|
||||
component="button"
|
||||
size="lg"
|
||||
color="primary"
|
||||
variant="filled"
|
||||
type="submit"
|
||||
className="pull-xs-right"
|
||||
disabled={isPending}
|
||||
>
|
||||
Publish Article
|
||||
</Button>
|
||||
</fieldset>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const inputsSchema = z.object({
|
||||
slug: z.string().optional(),
|
||||
title: z.string().max(200),
|
||||
description: z.string().max(500),
|
||||
body: z.string().max(2000),
|
||||
tagList: z.array(z.string().max(20)),
|
||||
tag: z.string().max(20).optional(),
|
||||
});
|
||||
|
||||
export type Inputs = z.infer<typeof inputsSchema>;
|
||||
@@ -0,0 +1,28 @@
|
||||
"use server";
|
||||
|
||||
import { createApiClient } from "@/utils/api/apiClient";
|
||||
import { getSession } from "@/utils/auth/session";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export const deleteArticleAction = async (_prevState: undefined, slug: string) => {
|
||||
if ((await getSession()) == null) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const apiClient = createApiClient({
|
||||
path: "/articles/{slug}",
|
||||
method: "delete",
|
||||
params: {
|
||||
path: {
|
||||
slug,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const response = await apiClient.sendRequest();
|
||||
if (response.result === "success") {
|
||||
redirect("/");
|
||||
}
|
||||
|
||||
throw new Error("api error");
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState } from "react";
|
||||
import { deleteArticleAction } from "./action";
|
||||
import { DeleteArticleButton as DeleteArticleButtonPresentation } from "./presentation";
|
||||
|
||||
type Props = {
|
||||
slug: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const DeleteArticleButton = ({ slug, className }: Props) => {
|
||||
const [_state, dispatch, isPending] = useActionState(deleteArticleAction, undefined);
|
||||
|
||||
const action = () => {
|
||||
if (!confirm("Delete this article?")) {
|
||||
return;
|
||||
}
|
||||
dispatch(slug);
|
||||
};
|
||||
|
||||
return <DeleteArticleButtonPresentation action={action} isPending={isPending} className={className} />;
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./container";
|
||||
@@ -0,0 +1,3 @@
|
||||
.form {
|
||||
display: inline;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user