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", "api",
"ux", "ux",
"route", "route",
"frontend", "frontend"
"components",
"island",
"backend"
], ],
"[ignore]": { "[ignore]": {
"editor.defaultFormatter": "foxundermoon.shell-format" "editor.defaultFormatter": "foxundermoon.shell-format"

View file

@ -3,19 +3,16 @@ import { JSX } from 'preact'
type Units = 'rem' | '%' | 'px' type Units = 'rem' | '%' | 'px'
export function AutoGrid( export function AutoGrid(
{ columnWidth, children, style }: { { columnWidth, children }: {
columnWidth: `${number}${Units}` columnWidth: `${number}${Units}`
children: JSX.Element | JSX.Element[] children: JSX.Element | JSX.Element[]
style?: JSX.CSSProperties
}, },
) { ) {
return ( return (
<div <div
class='components__auto_grid' class='components__auto_grid'
style={{ style={{
gridTemplateColumns: gridTemplateColumns: `repeat(auto-fit, minmax(${columnWidth}, 1fr));`,
`repeat(auto-fit, minmax(${columnWidth}, 1fr));`,
...style,
}} }}
> >
{children} {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 { asset } from '$fresh/runtime.ts'
import AiChatBox from ':islands/AiChatBox.tsx' import SearchBox from '../islands/SearchBox.tsx'
import MoreBox from ':islands/MoreBox.tsx' import ThemePicker from '../islands/ThemePicker.tsx'
import SearchBox from ':islands/SearchBox.tsx' import MoreBox from '../islands/MoreBox.tsx'
import ThemePicker from ':islands/ThemePicker.tsx' import AiChatBox from '../islands/AiChatBox.tsx'
export function Header() { export function Header() {
return ( 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() { export function ProgressiveWebApp() {
return <RegisterServiceWorker /> return <RegisterServiceWorker />

View file

@ -11,58 +11,34 @@
"serve": "deno task preview", "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" "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": { "fmt": { "singleQuote": true, "semiColons": false, "useTabs": true },
"singleQuote": true, "lint": { "rules": { "tags": ["fresh", "recommended"] } },
"semiColons": false, "exclude": ["**/_fresh/*", "packages/"],
"useTabs": true
},
"lint": {
"rules": {
"tags": [
"fresh",
"recommended"
]
}
},
"exclude": [
"**/_fresh/*",
"packages/"
],
"imports": { "imports": {
"$fresh/": "https://deno.land/x/fresh@1.6.8/", "$fresh/": "https://deno.land/x/fresh@1.6.8/",
"$std/": "https://deno.land/std@0.208.0/", "$std/": "https://deno.land/std@0.208.0/",
":components/": "./components/",
":islands/": "./islands/",
":src/": "./src/",
"@cohabit/cohamail/": "./packages/@cohabit__cohamail@0.2.1/", "@cohabit/cohamail/": "./packages/@cohabit__cohamail@0.2.1/",
"@cohabit/ressources_manager/": "./packages/@cohabit__ressources_manager@0.1.3/", "@cohabit/ressources_manager/": "./packages/@cohabit__ressources_manager@0.1.2/",
"@deno/gfm": "jsr:@deno/gfm@^0.8.2",
"@jotsr/delayed": "jsr:@jotsr/delayed@^2.1.1", "@jotsr/delayed": "jsr:@jotsr/delayed@^2.1.1",
"@jotsr/smart-css-bundler": "jsr:@jotsr/smart-css-bundler@^0.3.0", "@jotsr/smart-css-bundler": "jsr:@jotsr/smart-css-bundler@^0.3.0",
"@preact/signals": "npm:@preact/signals@^1.2.3", "@preact/signals": "https://esm.sh/*@preact/signals@1.2.2",
"@preact/signals-core": "npm:@preact/signals-core@^1.6.1", "@preact/signals-core": "https://esm.sh/*@preact/signals-core@1.5.1",
"@simplewebauthn/browser": "npm:@simplewebauthn/browser@^10.0.0", "@simplewebauthn/browser": "npm:@simplewebauthn/browser@^10.0.0",
"@simplewebauthn/server": "npm:@simplewebauthn/server@^10.0.0", "@simplewebauthn/server": "npm:@simplewebauthn/server@^10.0.0",
"@simplewebauthn/types": "npm:@simplewebauthn/types@^10.0.0", "@simplewebauthn/types": "npm:@simplewebauthn/types@^10.0.0",
"@std/encoding": "jsr:@std/encoding@^0.224.3", "@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/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/", "@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", "univoq": "https://deno.land/x/univoq@0.2.0/mod.ts",
"web-push": "npm:web-push@^3.6.7" "web-push": "npm:web-push@^3.6.7"
}, },
"compilerOptions": { "compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "preact" },
"jsx": "react-jsx",
"jsxImportSource": "preact"
},
"workspaces": [ "workspaces": [
"packages/@cohabit__cohamail@0.2.1", "packages/@cohabit__cohamail@0.2.1",
"packages/@cohabit__ressources_manager@0.1.3" "packages/@cohabit__ressources_manager@0.1.2"
], ],
"unstable": [ "unstable": ["kv"]
"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 { Signal, signal, useSignal } from '@preact/signals'
import { JSX } from 'preact'
import { useEffect, useRef } from 'preact/hooks' 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[]>([{ const systemHistory = signal<BotMessage[]>([{
role: 'system', 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 { startAuthentication } from '@simplewebauthn/browser'
import { PublicKeyCredentialRequestOptionsJSON } from '@simplewebauthn/types' import { PublicKeyCredentialRequestOptionsJSON } from '@simplewebauthn/types'
import { Button, Input } from 'univoq' import { Button, Input } from 'univoq'
@ -6,6 +5,7 @@ import type {
WebAuthnLoginFinishPayload, WebAuthnLoginFinishPayload,
WebAuthnLoginStartPayload, WebAuthnLoginStartPayload,
} from '../routes/api/webauthn/login/[step].ts' } from '../routes/api/webauthn/login/[step].ts'
import { requestApi } from '../src/utils.ts'
export default function LoginForm() { export default function LoginForm() {
return ( return (

View file

@ -1,4 +1,3 @@
import { requestApi } from ':src/utils.ts'
import { startRegistration } from '@simplewebauthn/browser' import { startRegistration } from '@simplewebauthn/browser'
import { PublicKeyCredentialCreationOptionsJSON } from '@simplewebauthn/types' import { PublicKeyCredentialCreationOptionsJSON } from '@simplewebauthn/types'
import { Button, Input } from 'univoq' import { Button, Input } from 'univoq'
@ -6,6 +5,7 @@ import type {
WebAuthnRegisterFinishPayload, WebAuthnRegisterFinishPayload,
WebAuthnRegisterStartPayload, WebAuthnRegisterStartPayload,
} from '../routes/api/webauthn/register/[step].ts' } from '../routes/api/webauthn/register/[step].ts'
import { requestApi } from '../src/utils.ts'
function isWebAuthnSupported(): boolean { function isWebAuthnSupported(): boolean {
return 'credentials' in navigator 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() { export default function RegisterServiceWorker() {
if ('serviceWorker' in navigator) { 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 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 { asset, Head, Partial } from '$fresh/runtime.ts'
import { type PageProps } from '$fresh/server.ts' import { type PageProps } from '$fresh/server.ts'
import { Footer } from ':components/Footer.tsx' import { Footer } from '../components/Footer.tsx'
import { Header } from ':components/Header.tsx' import { Header } from '../components/Header.tsx'
import { ProgressiveWebApp } from ':components/ProgressiveWebApp.tsx' import { ProgressiveWebApp } from '../components/ProgressiveWebApp.tsx'
export default function App({ Component }: PageProps) { export default function App({ Component }: PageProps) {
return ( return (
@ -38,7 +38,13 @@ export default function App({ Component }: PageProps) {
type='image/x-icon' type='image/x-icon'
/> />
<link rel='stylesheet' href={asset('/main.css')} /> <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> </Head>
<body> <body>
<Header /> <Header />

View file

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

View file

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

View file

@ -2,13 +2,13 @@ import 'npm:iterator-polyfill'
// Polyfill AsyncIterator // Polyfill AsyncIterator
import { FreshContext } from '$fresh/server.ts' 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 { Contact, type Mail, send } from '@cohabit/cohamail/mod.ts'
import { magicLinkTemplate } from '@cohabit/cohamail/templates/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 { sleep } from '@jotsr/delayed'
import { User } from '@cohabit/ressources_manager/src/models/mod.ts'
import { db } from '../../../src/db/mod.ts'
type MagicLinkInfos = { type MagicLinkInfos = {
remoteId: string 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 { import {
generateAuthenticationOptions, generateAuthenticationOptions,
verifyAuthenticationResponse, verifyAuthenticationResponse,
} from '@simplewebauthn/server' } from '@simplewebauthn/server'
import { getRelyingParty } from '../../../../src/webauthn/mod.ts'
import { import {
AuthenticationResponseJSON, AuthenticationResponseJSON,
PublicKeyCredentialRequestOptionsJSON, PublicKeyCredentialRequestOptionsJSON,
} from '@simplewebauthn/types' } 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' import { decodeBase64 } from '@std/encoding'
type Params = { step: 'start' | 'finish' } 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 { import {
generateRegistrationOptions, generateRegistrationOptions,
verifyRegistrationResponse, verifyRegistrationResponse,
@ -8,13 +6,15 @@ import type {
PublicKeyCredentialCreationOptionsJSON, PublicKeyCredentialCreationOptionsJSON,
RegistrationResponseJSON, RegistrationResponseJSON,
} from '@simplewebauthn/types' } from '@simplewebauthn/types'
import { respondApi } from '../../../../src/utils.ts'
import { SessionHandlers } from '../../../../src/session/mod.ts'
//TODO improve workspace imports //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 { 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 { encodeBase64 } from '@std/encoding'
import { db } from '../../../../src/db/mod.ts'
type Params = { step: 'start' | 'finish' } type Params = { step: 'start' | 'finish' }

View file

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

View file

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

View file

@ -1,5 +1,5 @@
import { PageProps } from '$fresh/server.ts' 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) { export default function Machine({ params }: PageProps) {
const machine = machineMock.at(Number(params.id)) const machine = machineMock.at(Number(params.id))

View file

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

View file

@ -1,6 +1,19 @@
import { PageProps } from '$fresh/server.ts' 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 = [ const db = [
'julien.oculi', 'julien.oculi',

View file

@ -1,5 +1,5 @@
import { AutoGrid } from ':components/AutoGrid.tsx' import { AutoGrid } from '../../components/AutoGrid.tsx'
import { MemberCard, memberMock } from ':components/MemberCard.tsx' import { MemberCard, memberMock } from '../../components/MemberCard.tsx'
export default function Membres() { export default function Membres() {
return ( 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 { 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) { export default function Profil({ state }: SessionPageProps) {
const user = state.session?.get<User>('user') const user = state.session?.get<User>('user')

View file

@ -1,5 +1,5 @@
import { PageProps } from '$fresh/server.ts' 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) { export default function Projets({ params }: PageProps) {
const Projets = projectMock.at(Number(params.id)) const Projets = projectMock.at(Number(params.id))

View file

@ -1,5 +1,5 @@
import { AutoGrid } from ':components/AutoGrid.tsx' import { AutoGrid } from '../../components/AutoGrid.tsx'
import { ProjectCard, projectMock } from ':components/ProjectCard.tsx' import { ProjectCard, projectMock } from '../../components/ProjectCard.tsx'
export default function Project() { export default function Project() {
return ( 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); --_gap-half: calc(var(--_gap) / 2);
--_wide-screen: 1400px; --_wide-screen: 1400px;
--_small-screen: 800px; --_small-screen: 800px;
--_readable-screen: max(80dvw, var(--_small-screen));
/* color */ /* color */
--_accent-color: var(--lime-6); --_accent-color: var(--lime-6);
@ -147,7 +146,6 @@ input[type='checkbox'] {
transition: all var(--_transition-delay) ease; transition: all var(--_transition-delay) ease;
display: inline-block; display: inline-block;
outline: none; outline: none;
text-align: center;
} }
.cta:hover, .cta:hover,
@ -168,7 +166,3 @@ input[type='checkbox'] {
font-size: 150%; font-size: 150%;
padding: 1rem 2rem; padding: 1rem 2rem;
} }
.markdown-body {
padding: var(--_gap);
}

View file

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

View file

@ -1,8 +1,4 @@
import { JsonValue } from '$std/json/common.ts' 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 export type JsonCompatible = JsonValue | { toJSON(): JsonValue } | unknown
@ -74,91 +70,6 @@ export type ApiPayload<ApiResponse extends JsonCompatible = never> = {
error: string 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 { function getCookie(name: string): string | undefined {
const cookiesEntries = document.cookie.split(';').map((cookie) => const cookiesEntries = document.cookie.split(';').map((cookie) =>
cookie.trim().split('=') cookie.trim().split('=')
@ -166,8 +77,3 @@ function getCookie(name: string): string | undefined {
const cookies = Object.fromEntries(cookiesEntries) const cookies = Object.fromEntries(cookiesEntries)
return cookies[name] return cookies[name]
} }
export function base64ToString(base64: string): string {
const bytes = decodeBase64(base64)
return new TextDecoder().decode(bytes)
}