Compare commits
25 commits
5cb7142824
...
a75de86d68
Author | SHA1 | Date | |
---|---|---|---|
Julien Oculi | a75de86d68 | ||
Julien Oculi | 10f36ff4d3 | ||
Julien Oculi | 7cafcb5acd | ||
Julien Oculi | 48519b0c4c | ||
Julien Oculi | 640f144417 | ||
Julien Oculi | d62305ac1d | ||
Julien Oculi | c1eeb42f21 | ||
Julien Oculi | 72281ae551 | ||
Julien Oculi | f2c8b145e6 | ||
Julien Oculi | 5365f11ec6 | ||
Julien Oculi | 5593878c66 | ||
Julien Oculi | ec90d92f46 | ||
Julien Oculi | 84236d633f | ||
Julien Oculi | 4fcfd34bbb | ||
Julien Oculi | 21f5009b7a | ||
Julien Oculi | 945f1ff939 | ||
Julien Oculi | 67379d9468 | ||
Julien Oculi | e0bc4c290f | ||
Julien Oculi | 1abc0d82c4 | ||
Julien Oculi | b71d2c6aae | ||
Julien Oculi | 5e2acb0eb8 | ||
Julien Oculi | 01e007939d | ||
Julien Oculi | 27faac00e3 | ||
Julien Oculi | e91b1b7a19 | ||
Julien Oculi | 6add5972b0 |
5
.vscode/settings.json
vendored
5
.vscode/settings.json
vendored
|
@ -36,7 +36,10 @@
|
|||
"api",
|
||||
"ux",
|
||||
"route",
|
||||
"frontend"
|
||||
"frontend",
|
||||
"components",
|
||||
"island",
|
||||
"backend"
|
||||
],
|
||||
"[ignore]": {
|
||||
"editor.defaultFormatter": "foxundermoon.shell-format"
|
||||
|
|
|
@ -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
164
components/BlogBlocks.css
Normal 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
157
components/BlogBlocks.tsx
Normal 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>
|
||||
)
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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
29
components/Markdown.tsx
Normal 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>
|
||||
)
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
import RegisterServiceWorker from '../islands/RegisterServiceWorker.tsx'
|
||||
import RegisterServiceWorker from ':islands/RegisterServiceWorker.tsx'
|
||||
|
||||
export function ProgressiveWebApp() {
|
||||
return <RegisterServiceWorker />
|
||||
|
|
48
deno.json
48
deno.json
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
|
@ -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
89
islands/BlogCardList.tsx
Normal 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
|
||||
}
|
|
@ -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 (
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { requestApi } from '../src/utils.ts'
|
||||
import { requestApi } from ':src/utils.ts'
|
||||
|
||||
export default function RegisterServiceWorker() {
|
||||
if ('serviceWorker' in navigator) {
|
||||
|
|
|
@ -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
53
islands/Suspens.tsx
Normal 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}</>
|
||||
}
|
|
@ -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 />
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
14
routes/api/news/fetchAll.ts
Normal file
14
routes/api/news/fetchAll.ts
Normal 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)
|
||||
}
|
||||
},
|
||||
}
|
|
@ -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' }
|
||||
|
|
|
@ -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' }
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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
17
routes/blog/[name].tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
|
|
14
routes/imports/markdown_css.ts
Normal file
14
routes/imports/markdown_css.ts
Normal 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
|
||||
},
|
||||
})
|
||||
},
|
||||
}
|
|
@ -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 où 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 où 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>
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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
104
src/blog/mod.ts
Normal 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
12
src/blog/types.ts
Normal 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
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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');
|
||||
|
|
94
src/utils.ts
94
src/utils.ts
|
@ -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)
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue