Compare commits

...

25 commits

Author SHA1 Message Date
Julien Oculi a75de86d68 refactor: ♻️ replace http imports by jsr/npm imports 2024-07-02 17:17:28 +02:00
Julien Oculi 10f36ff4d3 feat(components): refactor and add BlogCard and BlogPost components 2024-07-02 17:10:07 +02:00
Julien Oculi 7cafcb5acd feat(ui): 💄 global add padding for markdown rendered blocks 2024-07-02 17:07:58 +02:00
Julien Oculi 48519b0c4c feat(ui): improve accessibility with new css var size readable-screen 2024-07-02 16:56:23 +02:00
Julien Oculi 640f144417 refactor: 🎨 deno fmt 2024-07-02 13:58:04 +02:00
Julien Oculi d62305ac1d refactor: ♻️ use import map path for imports 2024-07-02 13:54:48 +02:00
Julien Oculi c1eeb42f21 feat(island): add BlogCardList to fetch and display news progressively 2024-07-02 13:53:14 +02:00
Julien Oculi 72281ae551 fix(island): 🐛 prevent displaying fallback when Suspense is loaded 2024-07-02 13:06:56 +02:00
Julien Oculi f2c8b145e6 feat(api): add news fetching api handler 2024-07-02 13:03:09 +02:00
Julien Oculi 5365f11ec6 feat(api): add new request/respond json stream for API 2024-07-02 13:01:49 +02:00
Julien Oculi 5593878c66 feat(backend): implement news fetching from git.cohabit 2024-07-02 11:20:38 +02:00
Julien Oculi ec90d92f46 feat(ui): 💄 center cta buttons in home page cards lists 2024-07-02 11:18:56 +02:00
Julien Oculi 84236d633f feat(route): use true blog cards for site home page 2024-07-02 11:17:39 +02:00
Julien Oculi 4fcfd34bbb feat(components): allow custom styles for AutoGrid 2024-07-02 11:16:10 +02:00
Julien Oculi 21f5009b7a feat(css): 💄 center text in buttons 2024-07-02 11:05:01 +02:00
Julien Oculi 945f1ff939 chore: 🎨 deno fmt 2024-07-02 10:55:12 +02:00
Julien Oculi 67379d9468 feat(route): rewrite and fully implement blog post route 2024-07-02 10:54:13 +02:00
Julien Oculi e0bc4c290f feat(island): add Suspense component 2024-07-01 14:19:42 +02:00
Julien Oculi 1abc0d82c4 feat: add raw base64 decoder 2024-07-01 13:33:27 +02:00
Julien Oculi b71d2c6aae refactor(route): ♻️ renames route parameter to fit real behaviour 2024-07-01 13:18:54 +02:00
Julien Oculi 5e2acb0eb8 feat(css): add gfm css 2024-07-01 13:17:06 +02:00
Julien Oculi 01e007939d refactor: ♻️ update import map to simplify local imports paths 2024-07-01 13:11:20 +02:00
Julien Oculi 27faac00e3 feat(components): add new Markdown component 2024-07-01 12:48:08 +02:00
Julien Oculi e91b1b7a19 feat: remove google font dependencies 2024-06-27 11:49:49 +02:00
Julien Oculi 6add5972b0 feat(config): ⬆️ upgrade @cohabit/ressources_manager 2024-06-26 16:59:28 +02:00
43 changed files with 886 additions and 223 deletions

View file

@ -36,7 +36,10 @@
"api",
"ux",
"route",
"frontend"
"frontend",
"components",
"island",
"backend"
],
"[ignore]": {
"editor.defaultFormatter": "foxundermoon.shell-format"

View file

@ -3,16 +3,19 @@ import { JSX } from 'preact'
type Units = 'rem' | '%' | 'px'
export function AutoGrid(
{ columnWidth, children }: {
{ columnWidth, children, style }: {
columnWidth: `${number}${Units}`
children: JSX.Element | JSX.Element[]
style?: JSX.CSSProperties
},
) {
return (
<div
class='components__auto_grid'
style={{
gridTemplateColumns: `repeat(auto-fit, minmax(${columnWidth}, 1fr));`,
gridTemplateColumns:
`repeat(auto-fit, minmax(${columnWidth}, 1fr));`,
...style,
}}
>
{children}

164
components/BlogBlocks.css Normal file
View file

@ -0,0 +1,164 @@
.components__blog_block {
min-width: 10rem;
aspect-ratio: 3 / 4;
display: flex;
flex-direction: column;
box-shadow: 0 0 0.4rem 0.2rem var(--_translucent);
border: var(--_border-size) solid transparent;
background-repeat: no-repeat;
background-size: 80%;
background-position: center var(--_gap);
backdrop-filter: blur(var(--_blur));
background-color: var(--_background-color);
&:has(a:focus-visible),
&:hover {
border: var(--_border-size) solid var(--_accent-color);
}
& h3 {
margin: 0;
padding: var(--_gap) var(--_gap-half);
backdrop-filter: blur(var(--_blur));
background-color: var(--_translucent);
border-bottom: 1px solid currentColor;
}
& a {
outline: none;
}
}
.components__blog_block--card {
max-width: 20rem;
}
.components__blog_block--placeholder {
animation: var(--animation-blink);
}
.components__blog_block--fallback {
opacity: 0.5;
}
:is(.components__blog_block--placeholder, .components__blog_block--fallback) {
h3 {
flex-grow: 1;
border: none;
align-content: center;
text-align: center;
}
}
.components__blog_block__spacer {
height: 0;
}
.components__blog_block--card .components__blog_block__spacer {
height: 30%;
}
.components__blog_block__links {
height: fit-content;
display: flex;
gap: var(--_gap-half);
justify-content: start;
padding: var(--_gap-half);
backdrop-filter: blur(var(--_blur));
background-color: var(--_translucent);
border-bottom: 1px solid currentColor;
& > a::before {
content: '🔗';
}
}
.components__blog_block__tags {
height: fit-content;
display: flex;
gap: var(--_gap-half);
justify-content: start;
padding: var(--_gap-half);
backdrop-filter: blur(var(--_blur));
background-color: var(--_translucent);
border-bottom: 1px solid currentColor;
& > span::before {
content: '#';
}
}
.components__blog_block__status {
display: block;
padding: var(--_gap-half);
background-color: var(--_translucent);
backdrop-filter: blur(var(--_blur));
}
.components__blog_block--card .components__blog_block__status {
position: absolute;
top: var(--_gap-half);
left: var(--_gap-half);
font-size: larger;
}
.components__blog_block__publisher {
display: block;
padding: var(--_gap-half);
background-color: var(--_translucent);
backdrop-filter: blur(var(--_blur));
}
.components__blog_block--card .components__blog_block__publisher {
position: absolute;
top: var(--_gap-half);
right: var(--_gap-half);
}
.components__blog_block__description {
text-wrap: balance;
flex-grow: 1;
backdrop-filter: blur(var(--_blur));
padding: var(--_gap-half);
}
.components__blog_block--card .components__blog_block__description {
min-height: 25%;
}
.components__blog_block__body {
text-wrap: balance;
flex-grow: 1;
padding: var(--_gap-half);
}
.components__blog_block__footer {
height: fit-content;
display: flex;
gap: var(--_gap);
justify-content: space-between;
padding: var(--_gap-half);
background-color: var(--_background-color);
}
.components__blog_post__infos {
display: flex;
gap: var(--_gap-half);
margin-block: var(--_gap-half);
& > * {
border: none;
padding: var(--_gap-half);
background-color: var(--_translucent);
}
}
.components__blog_post__description {
margin-block: var(--_gap-half);
font-family: var(--_font-family-code);
}
.components__blog_block--post {
width: var(--_readable-screen);
margin-inline: auto;
}

157
components/BlogBlocks.tsx Normal file
View file

@ -0,0 +1,157 @@
import { Markdown } from ':components/Markdown.tsx'
import { NewsFrontMatter } from ':src/blog/types.ts'
export type BlogProps = {
title: string
description: string
body: string
author: string
publisher: string
lastUpdate: Date
name: string
url: string
hash: string
options: NewsFrontMatter['x-cohabit']
tags: NewsFrontMatter['tags']
}
export function BlogCard(
{ title, description, author, lastUpdate, name, options, tags, publisher }:
BlogProps,
) {
return (
<div
class='components__blog_block components__blog_block--card'
style={{ backgroundImage: `url(${options.thumbnail})` }}
>
<div class='components__blog_block__spacer'></div>
<h3>
<a href={`/blog/${name}`}>{title}</a>
</h3>
<NewsLinks links={options.links} />
<NewsTags tags={tags} />
<span class='components__blog_block__publisher'>
{`@${publisher}`}
</span>
<NewsStatus status={options.status} />
<div class='components__blog_block__description'>
{description}
</div>
<NewsFooter author={author} lastUpdate={lastUpdate} />
</div>
)
}
export function BlogPost(
{
title,
description,
author,
lastUpdate,
body,
url,
options,
tags,
publisher,
}: BlogProps,
) {
return (
<div class='components__blog_block--post'>
<h1>{title}</h1>
<div class='components__blog_post__infos'>
<span class='components__blog_block__publisher'>
{`@${publisher}`}
</span>
<NewsStatus status={options.status} long />
<NewsLinks links={options.links} />
<NewsTags tags={tags} />
</div>
<div class='components__blog_post__infos'>
<span>{`Visibilité : ${options.visibility}`}</span>
<span>
{`Date de délivrance : ${
new Date(options.dueDate).toLocaleString()
}`}
</span>
</div>
<div class='components__blog_post__description'>
{description}
</div>
<div class='components__blog_post__body'>
<Markdown options={{ allowMath: true, baseUrl: url }}>
{body}
</Markdown>
</div>
<NewsFooter author={author} lastUpdate={lastUpdate} />
</div>
)
}
function NewsTags({ tags }: Pick<BlogProps, 'tags'>) {
return (
<div class='components__blog_block__tags'>
{tags
? tags.map((tag) => <span>{tag}</span>)
: <span>Aucun tag</span>}
</div>
)
}
function NewsFooter(
{ author, lastUpdate }: Pick<BlogProps, 'author' | 'lastUpdate'>,
) {
return (
<div class='components__blog_block__footer'>
<div>
<i class='ri-quill-pen-line'></i>
<span>{author}</span>
</div>
<div>
<i class='ri-refresh-line'></i>
<span>{lastUpdate.toLocaleDateString()}</span>
</div>
</div>
)
}
function NewsLinks({ links }: Pick<BlogProps['options'], 'links'>) {
return (
<div class='components__blog_block__links'>
{links
? links.flatMap(Object.entries).map((
[name, link],
) => <a href={link} target='_blank' title={name}>{name}</a>)
: <span>Aucun lien rapide</span>}
</div>
)
}
function NewsStatus(
{ status, long = false }: Pick<BlogProps['options'], 'status'> & {
long?: boolean
},
) {
const title = status === 'canceled'
? 'Annulé'
: status === 'current'
? 'En cours'
: status === 'finished'
? 'Terminé'
: 'Prévu'
return (
<span
class='components__blog_block__status'
title={title}
>
{status === 'canceled'
? <i class='ri-calendar-close-line'></i>
: status === 'current'
? <i class='ri-calendar-2-line'></i>
: status === 'finished'
? <i class='ri-calendar-check-line'></i>
: <i class='ri-calendar-2-line'></i>}
{long ? ` ${title}` : ''}
</span>
)
}

View file

@ -1,41 +0,0 @@
.components__blog_card {
min-width: 10rem;
aspect-ratio: 3 / 4;
display: flex;
flex-direction: column;
padding: var(--_gap-half);
gap: var(--_gap);
box-shadow: 0 0 0.4rem 0.2rem var(--_translucent);
border: var(--_border-size) solid transparent;
background-repeat: no-repeat;
background-size: contain;
&:has(a:focus-visible),
&:hover {
border: var(--_border-size) solid var(--_accent-color);
}
& h3 {
margin: 0;
}
& a {
outline: none;
}
}
.components__blog_card__spacer {
height: 50%;
}
.components__blog_card__text {
text-wrap: balance;
flex-grow: 1;
}
.components__blog_card__footer {
height: fit-content;
display: flex;
gap: var(--_gap);
justify-content: space-between;
}

View file

@ -1,54 +0,0 @@
type BlogCardProps = {
img: string
title: string
text: string
author: string
lasUpdate: Date
id: string
}
export function BlogCard(
{ img, title, text, author, lasUpdate, id }: BlogCardProps,
) {
return (
<div class='components__blog_card' style={{ backgroundImage: img }}>
<div class='components__blog_card__spacer'></div>
<h3>
<a href={`/blog/${id}`}>{title}</a>
</h3>
<div class='components__blog_card__text'>
{`${text.slice(0, 150)} ...`}
</div>
<div class='components__blog_card__footer'>
<div>
<i class='ri-quill-pen-line'></i>
<span>{author}</span>
</div>
<div>
<i class='ri-refresh-line'></i>
<span>{lasUpdate.toLocaleDateString()}</span>
</div>
</div>
</div>
)
}
const text =
'Lorem ipsum dolor sit amet, consectetur adipisicing elit. Qui, perferendis enim blanditiis consequatur at porro quod, eligendi alias recusandae modi aliquam non? Quos voluptates quisquam provident animi nisi in ratione.'
export const blogMock: BlogCardProps[] = Array(50).fill(undefined).map(
(_, index) => {
return {
author: 'PGP',
lasUpdate: randomDate(),
title: `Some title here ${index}`,
text,
img: `url("https://picsum.photos/id/${index}/300/200")`,
id: String(index),
}
},
)
function randomDate() {
return new Date(Date.now() - Math.random() * 1e10)
}

View file

@ -1,8 +1,8 @@
import { asset } from '$fresh/runtime.ts'
import SearchBox from '../islands/SearchBox.tsx'
import ThemePicker from '../islands/ThemePicker.tsx'
import MoreBox from '../islands/MoreBox.tsx'
import AiChatBox from '../islands/AiChatBox.tsx'
import AiChatBox from ':islands/AiChatBox.tsx'
import MoreBox from ':islands/MoreBox.tsx'
import SearchBox from ':islands/SearchBox.tsx'
import ThemePicker from ':islands/ThemePicker.tsx'
export function Header() {
return (

29
components/Markdown.tsx Normal file
View file

@ -0,0 +1,29 @@
import { SignalLike } from '$fresh/src/types.ts'
import { render, RenderOptions } from '@deno/gfm'
export type MarkdownTheme = 'light' | 'dark' | 'auto'
export function Markdown(
{ children, theme, options }: {
children?: SignalLike<string> | string
theme?: SignalLike<MarkdownTheme> | MarkdownTheme
options?: RenderOptions
},
) {
return (
<div
class='markdown-body'
data-color-mode={typeof theme === 'string'
? theme
: theme?.value ?? 'auto'}
data-light-theme='light'
data-dark-theme='dark'
dangerouslySetInnerHTML={{
__html: render(
typeof children === 'string' ? children : children?.value ?? '',
options,
),
}}
>
</div>
)
}

View file

@ -1,4 +1,4 @@
import RegisterServiceWorker from '../islands/RegisterServiceWorker.tsx'
import RegisterServiceWorker from ':islands/RegisterServiceWorker.tsx'
export function ProgressiveWebApp() {
return <RegisterServiceWorker />

View file

@ -11,34 +11,58 @@
"serve": "deno task preview",
"dev:add_package": "deno run --allow-net=git.cohabit.fr --allow-read=. --allow-write=./deno.json,./packages --allow-run=git,deno ./scripts/add_package.ts"
},
"fmt": { "singleQuote": true, "semiColons": false, "useTabs": true },
"lint": { "rules": { "tags": ["fresh", "recommended"] } },
"exclude": ["**/_fresh/*", "packages/"],
"fmt": {
"singleQuote": true,
"semiColons": false,
"useTabs": true
},
"lint": {
"rules": {
"tags": [
"fresh",
"recommended"
]
}
},
"exclude": [
"**/_fresh/*",
"packages/"
],
"imports": {
"$fresh/": "https://deno.land/x/fresh@1.6.8/",
"$std/": "https://deno.land/std@0.208.0/",
":components/": "./components/",
":islands/": "./islands/",
":src/": "./src/",
"@cohabit/cohamail/": "./packages/@cohabit__cohamail@0.2.1/",
"@cohabit/ressources_manager/": "./packages/@cohabit__ressources_manager@0.1.2/",
"@cohabit/ressources_manager/": "./packages/@cohabit__ressources_manager@0.1.3/",
"@deno/gfm": "jsr:@deno/gfm@^0.8.2",
"@jotsr/delayed": "jsr:@jotsr/delayed@^2.1.1",
"@jotsr/smart-css-bundler": "jsr:@jotsr/smart-css-bundler@^0.3.0",
"@preact/signals": "https://esm.sh/*@preact/signals@1.2.2",
"@preact/signals-core": "https://esm.sh/*@preact/signals-core@1.5.1",
"@preact/signals": "npm:@preact/signals@^1.2.3",
"@preact/signals-core": "npm:@preact/signals-core@^1.6.1",
"@simplewebauthn/browser": "npm:@simplewebauthn/browser@^10.0.0",
"@simplewebauthn/server": "npm:@simplewebauthn/server@^10.0.0",
"@simplewebauthn/types": "npm:@simplewebauthn/types@^10.0.0",
"@std/encoding": "jsr:@std/encoding@^0.224.3",
"@std/front-matter": "jsr:@std/front-matter@^0.224.2",
"@std/http": "jsr:@std/http@^0.224.4",
"@std/json": "jsr:@std/json@^0.224.1",
"@std/streams": "jsr:@std/streams@^0.224.5",
"@univoq/": "https://deno.land/x/univoq@0.2.0/",
"gfm": "https://deno.land/x/gfm@0.6.0/mod.ts",
"preact": "https://esm.sh/preact@10.19.6",
"preact/": "https://esm.sh/preact@10.19.6/",
"preact": "npm:preact@^10.22.1",
"univoq": "https://deno.land/x/univoq@0.2.0/mod.ts",
"web-push": "npm:web-push@^3.6.7"
},
"compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "preact" },
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "preact"
},
"workspaces": [
"packages/@cohabit__cohamail@0.2.1",
"packages/@cohabit__ressources_manager@0.1.2"
"packages/@cohabit__ressources_manager@0.1.3"
],
"unstable": ["kv"]
"unstable": [
"kv"
]
}

View file

@ -1,8 +1,8 @@
import { Signal, signal, useSignal } from '@preact/signals'
import { useEffect, useRef } from 'preact/hooks'
import { JSX } from 'preact'
import { JsonParseStream } from '$std/json/mod.ts'
import { CSS, render as renderMd } from 'gfm'
import { CSS, render as renderMd } from '@deno/gfm'
import { Signal, signal, useSignal } from '@preact/signals'
import { JSX } from 'preact'
import { useEffect, useRef } from 'preact/hooks'
const systemHistory = signal<BotMessage[]>([{
role: 'system',

89
islands/BlogCardList.tsx Normal file
View file

@ -0,0 +1,89 @@
import { BlogCard, BlogProps } from ':components/BlogBlocks.tsx'
import Suspense from ':islands/Suspens.tsx'
import { requestApiStream } from ':src/utils.ts'
import { Signal, useSignal } from '@preact/signals'
import type { JSX } from 'preact'
import { useEffect } from 'preact/hooks'
function fillList(
list: Signal<JSX.Element[]>,
{ limit, ac }: { limit?: number; ac?: AbortController },
) {
;(async () => {
const newsList = requestApiStream<void, BlogProps>(
'news/fetchAll',
'GET',
)
for await (const news of newsList) {
list.value = [
...list.value,
BlogCard({ ...news, lastUpdate: new Date(news.lastUpdate) }),
]
if (limit && list.value.length >= limit) break
}
ac?.abort()
})()
}
export default function BlogCardList(
{ limit, usePlaceholder }: { usePlaceholder?: boolean; limit?: number },
) {
const list = useSignal<JSX.Element[]>([])
const ac = new AbortController()
useEffect(() => {
fillList(list, { limit, ac })
})
if (limit && usePlaceholder) {
const placeholders = Array
.from({ length: limit })
.map((_, index) => (
<Suspense
loader={<Placeholder />}
fallback={Fallback}
signal={ac.signal}
>
{updateFromList(list, index)}
</Suspense>
))
return <>{placeholders}</>
}
return <>{list}</>
}
function Placeholder() {
return (
<div class='components__blog_block components__blog_block--card components__blog_block--placeholder'>
<h3>Chargement ...</h3>
</div>
)
}
function Fallback() {
return (
<div
class='components__blog_block components__blog_block--card components__blog_block--fallback'
inert
>
<h3>Pas de news disponible</h3>
</div>
)
}
function updateFromList(
list: Signal<JSX.Element[]>,
index: number,
): Promise<JSX.Element> {
const { promise, resolve } = Promise.withResolvers<JSX.Element>()
list.subscribe((value: JSX.Element[]) => {
const selected = value.at(index)
if (selected) {
resolve(selected)
}
})
return promise
}

View file

@ -1,3 +1,4 @@
import { requestApi } from ':src/utils.ts'
import { startAuthentication } from '@simplewebauthn/browser'
import { PublicKeyCredentialRequestOptionsJSON } from '@simplewebauthn/types'
import { Button, Input } from 'univoq'
@ -5,7 +6,6 @@ import type {
WebAuthnLoginFinishPayload,
WebAuthnLoginStartPayload,
} from '../routes/api/webauthn/login/[step].ts'
import { requestApi } from '../src/utils.ts'
export default function LoginForm() {
return (

View file

@ -1,3 +1,4 @@
import { requestApi } from ':src/utils.ts'
import { startRegistration } from '@simplewebauthn/browser'
import { PublicKeyCredentialCreationOptionsJSON } from '@simplewebauthn/types'
import { Button, Input } from 'univoq'
@ -5,7 +6,6 @@ import type {
WebAuthnRegisterFinishPayload,
WebAuthnRegisterStartPayload,
} from '../routes/api/webauthn/register/[step].ts'
import { requestApi } from '../src/utils.ts'
function isWebAuthnSupported(): boolean {
return 'credentials' in navigator

View file

@ -1,4 +1,4 @@
import { requestApi } from '../src/utils.ts'
import { requestApi } from ':src/utils.ts'
export default function RegisterServiceWorker() {
if ('serviceWorker' in navigator) {

View file

@ -1,4 +1,4 @@
import { main } from '../src/serviceworker/mod.ts'
import { main } from ':src/serviceworker/mod.ts'
const IS_SW = 'onpushsubscriptionchange' in self

53
islands/Suspens.tsx Normal file
View file

@ -0,0 +1,53 @@
// import { Suspense } from '@univoq // Error - mismatch in imports version
import { JSX } from 'preact'
import { useSignal } from '@preact/signals'
function RenderError(
{ error, fallback }: { error: Error; fallback: Fallback | undefined },
) {
if (fallback) {
return fallback({ error })
}
return (
<output>
<pre>{String(error)}</pre>
</output>
)
}
type Fallback = ({ error }: { error: Error }) => JSX.Element
export default function Suspense(
{ loader, fallback, signal, children }: {
loader: JSX.Element
children: Promise<JSX.Element>
fallback?: Fallback
signal?: AbortSignal
},
) {
const displayed = useSignal(loader)
let loaded = false
signal?.addEventListener('abort', () => {
if (loaded) return
try {
signal.throwIfAborted()
} catch (error) {
displayed.value = RenderError({ error, fallback })
}
})
children
.then((element) => {
if (signal?.aborted) return
displayed.value = element
loaded = true
})
.catch((error) => {
if (signal?.aborted) return
displayed.value = RenderError({ error, fallback })
})
return <>{displayed}</>
}

View file

@ -1,8 +1,8 @@
import { asset, Head, Partial } from '$fresh/runtime.ts'
import { type PageProps } from '$fresh/server.ts'
import { Footer } from '../components/Footer.tsx'
import { Header } from '../components/Header.tsx'
import { ProgressiveWebApp } from '../components/ProgressiveWebApp.tsx'
import { Footer } from ':components/Footer.tsx'
import { Header } from ':components/Header.tsx'
import { ProgressiveWebApp } from ':components/ProgressiveWebApp.tsx'
export default function App({ Component }: PageProps) {
return (
@ -38,13 +38,7 @@ export default function App({ Component }: PageProps) {
type='image/x-icon'
/>
<link rel='stylesheet' href={asset('/main.css')} />
{/* TODO remove google fonts link */}
<link rel='preconnect' href='https://fonts.googleapis.com' />
<link
rel='preconnect'
href='https://fonts.gstatic.com'
crossorigin={''}
/>
<link rel='stylesheet' href={asset('/imports/markdown_css')} />
</Head>
<body>
<Header />

View file

@ -1,6 +1,6 @@
import { FreshContext } from '$fresh/server.ts'
import { SessionStore } from ':src/session/mod.ts'
import { getCookies, setCookie } from '@std/http/cookie'
import { SessionStore } from '../src/session/mod.ts'
export async function handler(request: Request, ctx: FreshContext) {
// Update fresh context state with session

View file

@ -1,6 +1,6 @@
import { FreshContext } from '$fresh/server.ts'
import { SessionStore } from '../../src/session/mod.ts'
import { respondApi } from '../../src/utils.ts'
import { SessionStore } from ':src/session/mod.ts'
import { respondApi } from ':src/utils.ts'
export function handler(request: Request, ctx: FreshContext) {
// Check CSRF token

View file

@ -2,13 +2,13 @@ import 'npm:iterator-polyfill'
// Polyfill AsyncIterator
import { FreshContext } from '$fresh/server.ts'
import { db } from ':src/db/mod.ts'
import { SessionHandlers, SessionStore } from ':src/session/mod.ts'
import { respondApi } from ':src/utils.ts'
import { Contact, type Mail, send } from '@cohabit/cohamail/mod.ts'
import { magicLinkTemplate } from '@cohabit/cohamail/templates/mod.ts'
import { SessionHandlers, SessionStore } from '../../../src/session/mod.ts'
import { respondApi } from '../../../src/utils.ts'
import { sleep } from '@jotsr/delayed'
import { User } from '@cohabit/ressources_manager/src/models/mod.ts'
import { db } from '../../../src/db/mod.ts'
import { sleep } from '@jotsr/delayed'
type MagicLinkInfos = {
remoteId: string

View file

@ -0,0 +1,14 @@
import { fetchNewsList } from ':src/blog/mod.ts'
import { SessionHandlers } from ':src/session/mod.ts'
import { respondApi, respondApiStream } from ':src/utils.ts'
export const handler: SessionHandlers = {
GET() {
try {
const newsList = fetchNewsList('cohabit')
return respondApiStream(newsList)
} catch (error) {
return respondApi('error', error)
}
},
}

View file

@ -1,17 +1,17 @@
import { db } from ':src/db/mod.ts'
import type { SessionHandlers } from ':src/session/mod.ts'
import { respondApi } from ':src/utils.ts'
import { getRelyingParty } from ':src/webauthn/mod.ts'
import { Credential, Ref, User } from '@cohabit/ressources_manager/mod.ts'
import { Passkey } from '@cohabit/ressources_manager/src/models/src/credential.ts'
import {
generateAuthenticationOptions,
verifyAuthenticationResponse,
} from '@simplewebauthn/server'
import { getRelyingParty } from '../../../../src/webauthn/mod.ts'
import {
AuthenticationResponseJSON,
PublicKeyCredentialRequestOptionsJSON,
} from '@simplewebauthn/types'
import { respondApi } from '../../../../src/utils.ts'
import type { SessionHandlers } from '../../../../src/session/mod.ts'
import { db } from '../../../../src/db/mod.ts'
import { Credential, Ref, User } from '@cohabit/ressources_manager/mod.ts'
import { Passkey } from '@cohabit/ressources_manager/src/models/src/credential.ts'
import { decodeBase64 } from '@std/encoding'
type Params = { step: 'start' | 'finish' }

View file

@ -1,3 +1,5 @@
import { SessionHandlers } from ':src/session/mod.ts'
import { respondApi } from ':src/utils.ts'
import {
generateRegistrationOptions,
verifyRegistrationResponse,
@ -6,15 +8,13 @@ import type {
PublicKeyCredentialCreationOptionsJSON,
RegistrationResponseJSON,
} from '@simplewebauthn/types'
import { respondApi } from '../../../../src/utils.ts'
import { SessionHandlers } from '../../../../src/session/mod.ts'
//TODO improve workspace imports
import { Passkey } from '@cohabit/ressources_manager/src/models/src/credential.ts'
import { db } from ':src/db/mod.ts'
import { getRelyingParty } from ':src/webauthn/mod.ts'
import { Credential, Ref, User } from '@cohabit/ressources_manager/mod.ts'
import { getRelyingParty } from '../../../../src/webauthn/mod.ts'
import { Passkey } from '@cohabit/ressources_manager/src/models/src/credential.ts'
import { encodeBase64 } from '@std/encoding'
import { db } from '../../../../src/db/mod.ts'
type Params = { step: 'start' | 'finish' }

View file

@ -1,5 +1,5 @@
import { Handlers } from '$fresh/server.ts'
import { respondApi } from '../../../src/utils.ts'
import { respondApi } from ':src/utils.ts'
export const handler: Handlers = {
async POST(request: Request) {

View file

@ -1,6 +1,6 @@
import { Handlers } from '$fresh/server.ts'
import { respondApi } from '../../../src/utils.ts'
import { publicKey } from '../../../src/webpush/mod.ts'
import { respondApi } from ':src/utils.ts'
import { publicKey } from ':src/webpush/mod.ts'
export const handler: Handlers = {
GET() {

View file

@ -1,10 +0,0 @@
import { PageProps } from '$fresh/server.ts'
import { BlogCard, blogMock } from '../../components/BlogCard.tsx'
export default function Projet({ params }: PageProps) {
const article = blogMock.at(Number(params.id))
return (
article ? BlogCard(article) : <h3>Article inconnu</h3>
)
}

17
routes/blog/[name].tsx Normal file
View file

@ -0,0 +1,17 @@
import { RouteContext } from '$fresh/server.ts'
import { BlogPost } from ':components/BlogBlocks.tsx'
import { fetchNews } from ':src/blog/mod.ts'
export default async function Blog(_req: Request, { params }: RouteContext) {
try {
const article = await fetchNews('cohabit', params.name)
return BlogPost(article)
} catch {
return (
<>
<h3>Une erreur est survenue</h3>
<p>{`Impossible de récupérer l'article "${params.name}"`}.</p>
</>
)
}
}

View file

@ -1,12 +1,12 @@
import { AutoGrid } from '../../components/AutoGrid.tsx'
import { BlogCard, blogMock } from '../../components/BlogCard.tsx'
import { AutoGrid } from ':components/AutoGrid.tsx'
import BlogCardList from ':islands/BlogCardList.tsx'
export default function Blog() {
return (
<>
<h1>Nos articles</h1>
<AutoGrid columnWidth='15rem'>
{blogMock.map(BlogCard)}
<BlogCardList />
</AutoGrid>
</>
)

View file

@ -0,0 +1,14 @@
import { Handlers } from '$fresh/server.ts'
import { CSS, KATEX_CSS } from '@deno/gfm'
export const handler: Handlers = {
GET() {
const styles = CSS + KATEX_CSS
return new Response(styles, {
headers: {
'Content-Type': 'text/css; charset=utf-8',
// TODO add cache headers and eTag
},
})
},
}

View file

@ -1,12 +1,12 @@
import { Head } from '$fresh/runtime.ts'
import { AutoGrid } from '../components/AutoGrid.tsx'
import { BlogCard, blogMock } from '../components/BlogCard.tsx'
import { CohabitInfoTable } from '../components/CohabitInfoTable.tsx'
import { Heros } from '../components/Heros.tsx'
import { MachineCard, machineMock } from '../components/MachineCard.tsx'
import { MemberCard, memberMock } from '../components/MemberCard.tsx'
import { ProjectCard, projectMock } from '../components/ProjectCard.tsx'
import { SponsorCards } from '../components/SponsorCards.tsx'
import { AutoGrid } from ':components/AutoGrid.tsx'
import { CohabitInfoTable } from ':components/CohabitInfoTable.tsx'
import { Heros } from ':components/Heros.tsx'
import { MachineCard, machineMock } from ':components/MachineCard.tsx'
import { MemberCard, memberMock } from ':components/MemberCard.tsx'
import { ProjectCard, projectMock } from ':components/ProjectCard.tsx'
import { SponsorCards } from ':components/SponsorCards.tsx'
import BlogCardList from ':islands/BlogCardList.tsx'
export default function Home() {
return (
@ -17,9 +17,9 @@ export default function Home() {
<Heros />
<section id='first-section'>
<h2>Nos actus</h2>
<AutoGrid columnWidth='15rem'>
<AutoGrid columnWidth='15rem' style={{ alignItems: 'center' }}>
<>
{blogMock.slice(0, 4).map(BlogCard)}
<BlogCardList limit={4} usePlaceholder={true} />
<a href='/blog' class='cta'>Voir plus</a>
</>
</AutoGrid>
@ -27,10 +27,11 @@ export default function Home() {
<section>
<h2>Nos machines</h2>
<p>
Vous avez besoin d'aide pour concrétiser votre projet ? Le Fablab vous
accompagnes dans vos projets, grâce à son parc de machine...
Vous avez besoin d'aide pour concrétiser votre projet ? Le
Fablab vous accompagnes dans vos projets, grâce à son parc
de machine...
</p>
<AutoGrid columnWidth='15rem'>
<AutoGrid columnWidth='15rem' style={{ alignItems: 'center' }}>
<>
{machineMock.slice(0, 4).map(MachineCard)}
<a href='/machines' class='cta'>Réserver</a>
@ -39,7 +40,7 @@ export default function Home() {
</section>
<section>
<h2>Nos projets</h2>
<AutoGrid columnWidth='30rem'>
<AutoGrid columnWidth='30rem' style={{ alignItems: 'center' }}>
<>
{projectMock.slice(0, 4).map(ProjectCard)}
<a href='/projets' class='cta'>Participer</a>
@ -48,7 +49,7 @@ export default function Home() {
</section>
<section>
<h2>Nos membres</h2>
<AutoGrid columnWidth='15rem'>
<AutoGrid columnWidth='15rem' style={{ alignItems: 'center' }}>
<>
{memberMock.slice(0, 4).map(MemberCard)}
<a href='/membres' class='cta'>Nous découvrir</a>
@ -58,26 +59,29 @@ export default function Home() {
<section>
<h2>Présentation</h2>
<p>
Coh@bit est un fablab de l'université de Bordeaux ouvert à tous les
publics depuis 2016. Du collégien à l'enseignant-chercheur, l'équipe
du fablab accompagne les adhérents dans la réalisation de leurs
projets de fabrication autour du numérique.
Coh@bit est un fablab de l'université de Bordeaux ouvert à
tous les publics depuis 2016. Du collégien à
l'enseignant-chercheur, l'équipe du fablab accompagne les
adhérents dans la réalisation de leurs projets de
fabrication autour du numérique.
</p>
<p>
Venez découvrir un tout nouvelle univers vous pouvez concrétiser
vos projet, découvrir des personnes avec les même affinités que vous,
cultiver votre savoir et savoir faire, dans l'entraide et le partage.
Venez découvrir un tout nouvelle univers vous pouvez
concrétiser vos projet, découvrir des personnes avec les
même affinités que vous, cultiver votre savoir et savoir
faire, dans l'entraide et le partage.
</p>
<p>
Créer par Frédéric Bos (Directeur de l'IUT de Bordeaux) en 2014,
Coh@bit (Creative Open House at Bordeaux Institut of Technology) est
une association réunissant deux entités : le Fablab et le Technoshop.
Créer par Frédéric Bos (Directeur de l'IUT de Bordeaux) en
2014, Coh@bit (Creative Open House at Bordeaux Institut of
Technology) est une association réunissant deux entités : le
Fablab et le Technoshop.
</p>
<p>
Ouvert à tous les publics depuis 2016, allant de
l'enseignant-chercheur au collégien, l'équipe du fablab accompagne les
adhérents dans la réalisation de leurs projets de fabrication
numérique.
l'enseignant-chercheur au collégien, l'équipe du fablab
accompagne les adhérents dans la réalisation de leurs
projets de fabrication numérique.
</p>
<CohabitInfoTable />
</section>

View file

@ -1,5 +1,5 @@
import { PageProps } from '$fresh/server.ts'
import { MachineCard, machineMock } from '../../components/MachineCard.tsx'
import { MachineCard, machineMock } from ':components/MachineCard.tsx'
export default function Machine({ params }: PageProps) {
const machine = machineMock.at(Number(params.id))

View file

@ -1,5 +1,5 @@
import { AutoGrid } from '../../components/AutoGrid.tsx'
import { MachineCard, machineMock } from '../../components/MachineCard.tsx'
import { AutoGrid } from ':components/AutoGrid.tsx'
import { MachineCard, machineMock } from ':components/MachineCard.tsx'
export default function Machine() {
return (

View file

@ -1,19 +1,6 @@
import { PageProps } from '$fresh/server.ts'
import { MemberCard, memberMock } from '../../../components/MemberCard.tsx'
import { CSS, render as renderMd } from 'gfm'
function Markdown({ children }: { children: string }) {
return (
<>
<style dangerouslySetInnerHTML={{ __html: CSS }}></style>
<div
class='markdown-body'
dangerouslySetInnerHTML={{ __html: renderMd(children) }}
>
</div>
</>
)
}
import { Markdown } from ':components/Markdown.tsx'
import { MemberCard, memberMock } from ':components/MemberCard.tsx'
const db = [
'julien.oculi',

View file

@ -1,5 +1,5 @@
import { AutoGrid } from '../../components/AutoGrid.tsx'
import { MemberCard, memberMock } from '../../components/MemberCard.tsx'
import { AutoGrid } from ':components/AutoGrid.tsx'
import { MemberCard, memberMock } from ':components/MemberCard.tsx'
export default function Membres() {
return (

View file

@ -1,8 +1,8 @@
import { Button } from 'univoq'
import LoginForm from '../../islands/LoginForm.tsx'
import PassKeyRegister from '../../islands/PassKeyRegister.tsx'
import type { SessionPageProps } from '../../src/session/mod.ts'
import LoginForm from ':islands/LoginForm.tsx'
import PassKeyRegister from ':islands/PassKeyRegister.tsx'
import type { SessionPageProps } from ':src/session/mod.ts'
import type { User } from '@cohabit/ressources_manager/mod.ts'
import { Button } from 'univoq'
export default function Profil({ state }: SessionPageProps) {
const user = state.session?.get<User>('user')

View file

@ -1,5 +1,5 @@
import { PageProps } from '$fresh/server.ts'
import { ProjectCard, projectMock } from '../../components/ProjectCard.tsx'
import { ProjectCard, projectMock } from ':components/ProjectCard.tsx'
export default function Projets({ params }: PageProps) {
const Projets = projectMock.at(Number(params.id))

View file

@ -1,5 +1,5 @@
import { AutoGrid } from '../../components/AutoGrid.tsx'
import { ProjectCard, projectMock } from '../../components/ProjectCard.tsx'
import { AutoGrid } from ':components/AutoGrid.tsx'
import { ProjectCard, projectMock } from ':components/ProjectCard.tsx'
export default function Project() {
return (

104
src/blog/mod.ts Normal file
View file

@ -0,0 +1,104 @@
import { BlogProps } from ':components/BlogBlocks.tsx'
import { NewsFrontMatter } from ':src/blog/types.ts'
import { base64ToString } from ':src/utils.ts'
import { extract } from '@std/front-matter/yaml'
export async function fetchNews(
publisher: string,
name: string,
): Promise<BlogProps> {
const apiUrl = 'https://git.cohabit.fr/api/v1/'
const baseEndpoint = new URL(`repos/${publisher}/.news/`, apiUrl)
const endpoint = new URL('contents/', baseEndpoint)
// Get readme content api url
const readmePath = encodeURIComponent(`${name}/README.md`)
const contentUrl = new URL(readmePath, endpoint)
// Fetch readme content, commit hash and raw url for relative links
const file = await getCommitAndContent(contentUrl)
// Get commit infos (author + date) and get readme content from base64 source
const { raw, url, lastUpdate, author } = await getAuthorAndParseContent(
file,
baseEndpoint,
)
// Extract frontmatter
const { attrs, body } = extract<NewsFrontMatter>(raw)
// Transform API responses into BlogProps for BlogCard and BlogPost components
return {
author,
publisher,
lastUpdate,
options: attrs['x-cohabit'],
title: attrs.title,
hash: file.sha,
description: attrs.description,
body,
name,
url,
tags: attrs.tags,
}
}
export async function* fetchNewsList(
publisher: string,
): AsyncGenerator<BlogProps, void, void> {
const apiUrl = 'https://git.cohabit.fr/api/v1/'
const baseEndpoint = new URL(`repos/${publisher}/.news/`, apiUrl)
const endpoint = new URL('contents/', baseEndpoint)
// Fetch repo content
const root = await fetch(endpoint).then((response) => response.json()) as {
name: string
type: string
}[]
// Fetch `README.md` in sub directories
const blogPropsList = root
// Remove file and dir starting with "."
.filter(isNewsDirectory)
// Fetch single news and return BlogProps
.map(({ name }) => fetchNews(publisher, name))
// Yield each news
for (const blogProps of blogPropsList) {
yield blogProps
}
}
async function getAuthorAndParseContent(
file: { download_url: string; content: string; last_commit_sha: string },
baseEndpoint: URL,
) {
const commitUrl = new URL(
`git/commits/${file.last_commit_sha}?stat=false&verification=false&files=false`,
baseEndpoint,
)
const infos = await fetch(commitUrl).then((response) =>
response.json()
) as {
created: string
author: { login: string }
}
return {
raw: base64ToString(file.content),
url: file.download_url,
lastUpdate: new Date(infos.created),
author: infos.author.login,
}
}
async function getCommitAndContent(contentUrl: URL) {
return await fetch(contentUrl).then((response) => response.json()) as {
download_url: string
content: string
sha: string
last_commit_sha: string
}
}
function isNewsDirectory(entry: { name: string; type: string }): boolean {
return entry.type === 'dir' && entry.name.startsWith('.') === false
}

12
src/blog/types.ts Normal file
View file

@ -0,0 +1,12 @@
export type NewsFrontMatter = {
title: string
description: string
tags?: string[]
'x-cohabit': {
links?: Record<string, string>[]
status: 'canceled' | 'futur' | 'current' | 'finished'
visibility?: 'public' | 'internal'
thumbnail: string
dueDate: string
}
}

View file

@ -42,6 +42,7 @@
--_gap-half: calc(var(--_gap) / 2);
--_wide-screen: 1400px;
--_small-screen: 800px;
--_readable-screen: max(80dvw, var(--_small-screen));
/* color */
--_accent-color: var(--lime-6);
@ -146,6 +147,7 @@ input[type='checkbox'] {
transition: all var(--_transition-delay) ease;
display: inline-block;
outline: none;
text-align: center;
}
.cta:hover,
@ -166,3 +168,7 @@ input[type='checkbox'] {
font-size: 150%;
padding: 1rem 2rem;
}
.markdown-body {
padding: var(--_gap);
}

View file

@ -3,7 +3,7 @@
@import url('../../components/Heros.css');
@import url('../../components/SponsorCards.css');
@import url('../../components/CohabitInfoTable.css');
@import url('../../components/BlogCard.css');
@import url('../../components/BlogBlocks.css');
@import url('../../components/MachineCard.css');
@import url('../../components/ProjectCard.css');
@import url('../../components/AutoGrid.css');

View file

@ -1,4 +1,8 @@
import { JsonValue } from '$std/json/common.ts'
import { decodeBase64 } from '@std/encoding/base64'
import { JsonStringifyStream } from '@std/json'
import { JsonParseStream } from '@std/json/json-parse-stream'
import { TextLineStream } from '@std/streams/text-line-stream'
export type JsonCompatible = JsonValue | { toJSON(): JsonValue } | unknown
@ -70,6 +74,91 @@ export type ApiPayload<ApiResponse extends JsonCompatible = never> = {
error: string
}
export async function respondApiStream<
Payload extends JsonCompatible,
>(
source:
| ReadableStream<Payload>
| Iterable<Payload>
| AsyncIterable<Payload>,
): Promise<Response> {
const stream = new TransformStream<
ApiPayload<Payload>,
ApiPayload<Payload>
>()
const writer = stream.writable.getWriter()
try {
await writer.ready
for await (const data of source) {
writer.write({ kind: 'success', data })
}
} catch (error) {
writer.write({ kind: 'error', error })
} finally {
writer.close()
}
const body = stream.readable
.pipeThrough(new JsonStringifyStream())
.pipeThrough(new TextEncoderStream())
return new Response(body)
}
export async function* requestApiStream<
Payload extends JsonCompatible | undefined,
ApiResponse extends JsonCompatible,
>(
route: string,
method: 'GET' | 'POST' | 'DELETE' | 'PATCH',
payload?: Payload | null,
): AsyncGenerator<ApiResponse, void, void> {
const csrf = getCookie('_CSRF') ?? ''
const base = new URL('/api/', location.origin)
const endpoint = new URL(
route.startsWith('/') ? `.${route}` : route,
base.href,
)
const response = await fetch(endpoint, {
method,
headers: {
'Content-Type': 'application/json; charset=utf-8',
'X-CSRF-TOKEN': csrf,
},
body: payload ? JSON.stringify(payload) : null,
})
const { body } = response
if (body === null) {
throw new TypeError(`api response stream is null`)
}
const stream = body
.pipeThrough(new TextDecoderStream()) // convert Uint8Array to string
.pipeThrough(new TextLineStream()) // transform into a stream where each chunk is divided by a newline
.pipeThrough(new JsonParseStream()) as unknown as ReadableStream<
ApiPayload<ApiResponse>
> // parse each chunk as JSON
for await (const payload of stream) {
if (payload.kind === 'error') {
throw new Error(
`api stream error while getting "${endpoint.href}"`,
{
cause: payload.error,
},
)
}
yield payload.data
}
}
function getCookie(name: string): string | undefined {
const cookiesEntries = document.cookie.split(';').map((cookie) =>
cookie.trim().split('=')
@ -77,3 +166,8 @@ function getCookie(name: string): string | undefined {
const cookies = Object.fromEntries(cookiesEntries)
return cookies[name]
}
export function base64ToString(base64: string): string {
const bytes = decodeBase64(base64)
return new TextDecoder().decode(bytes)
}