Compare commits

..

No commits in common. "a75de86d68ace375843143acdf32da7d11010ad6" and "5cb71428245f8d945f334448e242d07304a64e99" have entirely different histories.

43 changed files with 222 additions and 885 deletions

View file

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

View file

@ -3,19 +3,16 @@ import { JSX } from 'preact'
type Units = 'rem' | '%' | 'px'
export function AutoGrid(
{ columnWidth, children, style }: {
{ columnWidth, children }: {
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));`,
...style,
gridTemplateColumns: `repeat(auto-fit, minmax(${columnWidth}, 1fr));`,
}}
>
{children}

View file

@ -1,164 +0,0 @@
.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;
}

View file

@ -1,157 +0,0 @@
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>
)
}

41
components/BlogCard.css Normal file
View file

@ -0,0 +1,41 @@
.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;
}

54
components/BlogCard.tsx Normal file
View file

@ -0,0 +1,54 @@
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 AiChatBox from ':islands/AiChatBox.tsx'
import MoreBox from ':islands/MoreBox.tsx'
import SearchBox from ':islands/SearchBox.tsx'
import ThemePicker from ':islands/ThemePicker.tsx'
import SearchBox from '../islands/SearchBox.tsx'
import ThemePicker from '../islands/ThemePicker.tsx'
import MoreBox from '../islands/MoreBox.tsx'
import AiChatBox from '../islands/AiChatBox.tsx'
export function Header() {
return (

View file

@ -1,29 +0,0 @@
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,58 +11,34 @@
"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.3/",
"@deno/gfm": "jsr:@deno/gfm@^0.8.2",
"@cohabit/ressources_manager/": "./packages/@cohabit__ressources_manager@0.1.2/",
"@jotsr/delayed": "jsr:@jotsr/delayed@^2.1.1",
"@jotsr/smart-css-bundler": "jsr:@jotsr/smart-css-bundler@^0.3.0",
"@preact/signals": "npm:@preact/signals@^1.2.3",
"@preact/signals-core": "npm:@preact/signals-core@^1.6.1",
"@preact/signals": "https://esm.sh/*@preact/signals@1.2.2",
"@preact/signals-core": "https://esm.sh/*@preact/signals-core@1.5.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/",
"preact": "npm:preact@^10.22.1",
"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/",
"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.3"
"packages/@cohabit__ressources_manager@0.1.2"
],
"unstable": [
"kv"
]
"unstable": ["kv"]
}

View file

@ -1,8 +1,8 @@
import { JsonParseStream } from '$std/json/mod.ts'
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'
import { JSX } from 'preact'
import { JsonParseStream } from '$std/json/mod.ts'
import { CSS, render as renderMd } from 'gfm'
const systemHistory = signal<BotMessage[]>([{
role: 'system',

View file

@ -1,89 +0,0 @@
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,4 +1,3 @@
import { requestApi } from ':src/utils.ts'
import { startAuthentication } from '@simplewebauthn/browser'
import { PublicKeyCredentialRequestOptionsJSON } from '@simplewebauthn/types'
import { Button, Input } from 'univoq'
@ -6,6 +5,7 @@ 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,4 +1,3 @@
import { requestApi } from ':src/utils.ts'
import { startRegistration } from '@simplewebauthn/browser'
import { PublicKeyCredentialCreationOptionsJSON } from '@simplewebauthn/types'
import { Button, Input } from 'univoq'
@ -6,6 +5,7 @@ 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

View file

@ -1,53 +0,0 @@
// 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,7 +38,13 @@ export default function App({ Component }: PageProps) {
type='image/x-icon'
/>
<link rel='stylesheet' href={asset('/main.css')} />
<link rel='stylesheet' href={asset('/imports/markdown_css')} />
{/* TODO remove google fonts link */}
<link rel='preconnect' href='https://fonts.googleapis.com' />
<link
rel='preconnect'
href='https://fonts.gstatic.com'
crossorigin={''}
/>
</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 { User } from '@cohabit/ressources_manager/src/models/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'
type MagicLinkInfos = {
remoteId: string

View file

@ -1,14 +0,0 @@
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,5 +1,3 @@
import { SessionHandlers } from ':src/session/mod.ts'
import { respondApi } from ':src/utils.ts'
import {
generateRegistrationOptions,
verifyRegistrationResponse,
@ -8,13 +6,15 @@ 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 { db } from ':src/db/mod.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 { Credential, Ref, User } from '@cohabit/ressources_manager/mod.ts'
import { getRelyingParty } from '../../../../src/webauthn/mod.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() {

10
routes/blog/[id].tsx Normal file
View file

@ -0,0 +1,10 @@
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>
)
}

View file

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

View file

@ -1,14 +0,0 @@
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 { 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'
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'
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' style={{ alignItems: 'center' }}>
<AutoGrid columnWidth='15rem'>
<>
<BlogCardList limit={4} usePlaceholder={true} />
{blogMock.slice(0, 4).map(BlogCard)}
<a href='/blog' class='cta'>Voir plus</a>
</>
</AutoGrid>
@ -27,11 +27,10 @@ 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' style={{ alignItems: 'center' }}>
<AutoGrid columnWidth='15rem'>
<>
{machineMock.slice(0, 4).map(MachineCard)}
<a href='/machines' class='cta'>Réserver</a>
@ -40,7 +39,7 @@ export default function Home() {
</section>
<section>
<h2>Nos projets</h2>
<AutoGrid columnWidth='30rem' style={{ alignItems: 'center' }}>
<AutoGrid columnWidth='30rem'>
<>
{projectMock.slice(0, 4).map(ProjectCard)}
<a href='/projets' class='cta'>Participer</a>
@ -49,7 +48,7 @@ export default function Home() {
</section>
<section>
<h2>Nos membres</h2>
<AutoGrid columnWidth='15rem' style={{ alignItems: 'center' }}>
<AutoGrid columnWidth='15rem'>
<>
{memberMock.slice(0, 4).map(MemberCard)}
<a href='/membres' class='cta'>Nous découvrir</a>
@ -59,29 +58,26 @@ 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,6 +1,19 @@
import { PageProps } from '$fresh/server.ts'
import { Markdown } from ':components/Markdown.tsx'
import { MemberCard, memberMock } from ':components/MemberCard.tsx'
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>
</>
)
}
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 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'
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'
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 (

View file

@ -1,104 +0,0 @@
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
}

View file

@ -1,12 +0,0 @@
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,7 +42,6 @@
--_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);
@ -147,7 +146,6 @@ input[type='checkbox'] {
transition: all var(--_transition-delay) ease;
display: inline-block;
outline: none;
text-align: center;
}
.cta:hover,
@ -168,7 +166,3 @@ 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/BlogBlocks.css');
@import url('../../components/BlogCard.css');
@import url('../../components/MachineCard.css');
@import url('../../components/ProjectCard.css');
@import url('../../components/AutoGrid.css');

View file

@ -1,8 +1,4 @@
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
@ -74,91 +70,6 @@ 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('=')
@ -166,8 +77,3 @@ 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)
}