Compare commits

..

6 commits

10 changed files with 321 additions and 135 deletions

View file

@ -1,4 +1,4 @@
type MemberCardProps = {
export type MemberCardProps = {
id: string
icon: string
name: string
@ -22,26 +22,3 @@ export function MemberCard(
</div>
)
}
export const memberMock: MemberCardProps[] = Array(50).fill(undefined).map(
(_, index) => {
return {
name: `Michel ${randomLastName()}`,
groups: ['FabManager', 'Étudiant'],
icon: `url("https://thispersondoesnotexist.com/")`,
id: String(index),
}
},
)
function randomLastName() {
const randomArray = Math.round(Math.random() * 1e8).toString().split('').map(
Number,
)
const [first, ...tail] = randomArray.map((number) =>
String.fromCodePoint(number + 97)
)
return [first.toLocaleUpperCase(), ...tail].join('')
}

View file

@ -1,62 +1,46 @@
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()
})()
}
import CardList from ':islands/CardList.tsx'
import type { Ref } from 'preact'
export default function BlogCardList(
{ limit, usePlaceholder }: { usePlaceholder?: boolean; limit?: number },
{ limit, usePlaceholder, useObserver }: {
limit?: number
usePlaceholder?: boolean
useObserver?: boolean
},
) {
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}</>
if (usePlaceholder) {
return (
<CardList
apiRoute='news/fetchAll'
builder={builder}
limit={limit}
placeholder={Placeholder}
fallback={Fallback}
useObserver={useObserver}
/>
)
}
return <>{list}</>
return (
<CardList
apiRoute='news/fetchAll'
builder={builder}
limit={limit}
/>
)
}
function Placeholder() {
function builder(news: BlogProps) {
return BlogCard({ ...news, lastUpdate: new Date(news.lastUpdate) })
}
function Placeholder({ ref }: { ref?: Ref<HTMLDivElement> | undefined }) {
return (
<div class='components__blog_block components__blog_block--card components__blog_block--placeholder'>
<div
class='components__blog_block components__blog_block--card components__blog_block--placeholder'
ref={ref}
>
<h3>Chargement ...</h3>
</div>
)
@ -72,18 +56,3 @@ function Fallback() {
</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
}

103
islands/CardList.tsx Normal file
View file

@ -0,0 +1,103 @@
import Suspense, { Fallback } from ':islands/Suspens.tsx'
import { requestApiStream } from ':src/utils.ts'
import { Signal, useSignal } from '@preact/signals'
import type { JSX, Ref } from 'preact'
import { useEffect, useRef } from 'preact/hooks'
export type Builder<T> = (props: T) => JSX.Element
export type CardListProps<ApiResponse, RefType = null> = {
apiRoute: string
builder: Builder<ApiResponse>
limit?: number
} | {
apiRoute: string
builder: Builder<ApiResponse>
limit?: number
useObserver?: boolean
placeholder: ({ ref }: { ref: Ref<RefType> | undefined }) => JSX.Element
fallback: Fallback
}
export default function CardList<ApiResponse, RefType = null>(
{ limit, builder, apiRoute, ...props }: CardListProps<ApiResponse, RefType>,
) {
const list: Signal<JSX.Element[]> = useSignal<JSX.Element[]>([])
const ac = new AbortController()
const ref = useRef(null)
const useObserver = 'useObserver' in props ? props.useObserver : false
const placeholder = 'placeholder' in props ? props.placeholder : false
const fallback = 'fallback' in props ? props.fallback : false
useEffect(() => {
if (ref.current && useObserver) {
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) {
fillList(list, builder, apiRoute, { limit, ac })
observer.disconnect()
}
}, {
rootMargin: '300px',
})
observer.observe(ref.current)
} else {
fillList(list, builder, apiRoute, { limit, ac })
}
})
if (limit && placeholder && fallback) {
const placeholders = Array
.from({ length: limit })
.map((_, index) => (
<Suspense
loader={placeholder({ ref: index === 0 ? ref : undefined })}
fallback={fallback}
signal={ac.signal}
>
{updateFromList(list, index)}
</Suspense>
))
return <>{placeholders}</>
}
return <>{list}</>
}
function fillList<ApiResponse>(
list: Signal<JSX.Element[]>,
builder: Builder<ApiResponse>,
apiRoute: string,
{ limit, ac }: { limit?: number; ac?: AbortController },
) {
;(async () => {
const propsList = requestApiStream<void, ApiResponse>(
apiRoute,
'GET',
)
for await (const props of propsList) {
list.value = [
...list.value,
builder(props),
]
if (limit && list.value.length >= limit) break
}
ac?.abort()
})()
}
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

@ -0,0 +1,60 @@
import { MemberCard } from ':components/MemberCard.tsx'
import CardList from ':islands/CardList.tsx'
import type { Ref } from 'preact'
export default function MemberCardList(
{ limit, filters, usePlaceholder, useObserver }: {
filters?: [string, string][]
limit?: number
usePlaceholder?: boolean
useObserver?: boolean
},
) {
const query = new URL('members/fetchAll', 'https://null/')
filters?.forEach((filter) => query.searchParams.set(...filter))
const apiRoute = `${query.pathname}${query.search}`
if (usePlaceholder) {
return (
<CardList
apiRoute={apiRoute}
builder={MemberCard}
limit={limit}
placeholder={Placeholder}
fallback={Fallback}
useObserver={useObserver}
/>
)
}
return (
<CardList
apiRoute={apiRoute}
builder={MemberCard}
limit={limit}
/>
)
}
function Placeholder({ ref }: { ref?: Ref<HTMLDivElement> | undefined }) {
return (
<div
// class='components__blog_block components__blog_block--card components__blog_block--placeholder'
ref={ref}
>
<h3>Chargement ...</h3>
</div>
)
}
function Fallback() {
return (
<div
// class='components__blog_block components__blog_block--card components__blog_block--fallback'
inert
>
<h3>Pas d'utilisateur</h3>
</div>
)
}

View file

@ -16,7 +16,7 @@ function RenderError(
)
}
type Fallback = ({ error }: { error: Error }) => JSX.Element
export type Fallback = ({ error }: { error: Error }) => JSX.Element
export default function Suspense(
{ loader, fallback, signal, children }: {

View file

@ -0,0 +1,28 @@
import { MemberCardProps } from ':components/MemberCard.tsx'
import { db } from ':src/db/mod.ts'
import { dbToMemberCardProps } from ':src/members/mod.ts'
import { SessionHandlers } from ':src/session/mod.ts'
import { respondApi, respondApiStream } from ':src/utils.ts'
export const handler: SessionHandlers = {
GET(_req, ctx) {
try {
const memberList = dbToMemberCardProps(db)
const params = ctx.url.searchParams
const groupParam = params.get('group')
if (groupParam) {
const list = memberList.filter(
(member) => member.groups.includes(groupParam),
) as AsyncIterableIterator<MemberCardProps>
return respondApiStream(list)
}
return respondApiStream(memberList)
} catch (error) {
return respondApi('error', error)
}
},
}

View file

@ -3,10 +3,10 @@ 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 MemberCardList from ':islands/MemberCardList.tsx'
export default function Home() {
return (
@ -18,10 +18,12 @@ export default function Home() {
<section id='first-section'>
<h2>Nos actus</h2>
<AutoGrid columnWidth='15rem' style={{ alignItems: 'center' }}>
<>
<BlogCardList limit={4} usePlaceholder={true} />
<a href='/blog' class='cta'>Voir plus</a>
</>
<BlogCardList
limit={4}
usePlaceholder={true}
useObserver={true}
/>
<a href='/blog' class='cta'>Voir plus</a>
</AutoGrid>
</section>
<section>
@ -50,10 +52,12 @@ export default function Home() {
<section>
<h2>Nos membres</h2>
<AutoGrid columnWidth='15rem' style={{ alignItems: 'center' }}>
<>
{memberMock.slice(0, 4).map(MemberCard)}
<a href='/membres' class='cta'>Nous découvrir</a>
</>
<MemberCardList
limit={4}
usePlaceholder={true}
useObserver={true}
/>
<a href='/membres' class='cta'>Nous découvrir</a>
</AutoGrid>
</section>
<section>

View file

@ -1,31 +1,19 @@
import { PageProps } from '$fresh/server.ts'
import { Markdown } from ':components/Markdown.tsx'
import { MemberCard, memberMock } from ':components/MemberCard.tsx'
import { MemberCard } from ':components/MemberCard.tsx'
import { db } from ':src/db/mod.ts'
import { fetchCarnet, userToMemberCardProps } from ':src/members/mod.ts'
const db = [
'julien.oculi',
]
export default async function Member(_: Request, { params, url }: PageProps) {
const uuid = params.id as ReturnType<Crypto['randomUUID']>
const user = await db.ressource.user.get({ uuid }).catch(() => undefined)
async function getCarnet(user: string): Promise<string> {
try {
const response = await fetch(
`https://git.cohabit.fr/${user}/.carnet/raw/branch/main/index.md`,
)
return response.text()
} catch (error) {
return 'Carnet introuvable\n```js\nString(error)\n```'
}
}
export default async function Member(_: Request, { params }: PageProps) {
const id = Number(params.id)
const Member = memberMock.at(id)
if (!Member) {
if (!user) {
return <h3>Membre inconnu, peut être serez vous le prochain</h3>
}
const carnet = await getCarnet(db.at(id)!)
const memberCardProps = await userToMemberCardProps(user)
const carnet = await fetchCarnet(user.login)
return (
<div
@ -38,17 +26,27 @@ export default async function Member(_: Request, { params }: PageProps) {
>
<style>{'.markdown-body { max-width: 80dvw; }'}</style>
<div>
{MemberCard(Member)}
{MemberCard(memberCardProps)}
<br />
<a
href={`/membres/${id}/portfolio/index.html`}
href={`https://${
user.login.replace('.', '-')
}.portfolio.${url.hostname}`}
class='cta'
target='_blank'
>
Portfolio
</a>
</div>
<Markdown>{carnet}</Markdown>
<Markdown
options={{
baseUrl:
`https://git.cohabit.fr/${user.login}/.carnet/raw/branch/main/index.md`,
allowMath: true,
}}
>
{carnet}
</Markdown>
</div>
)
}

View file

@ -1,5 +1,5 @@
import { AutoGrid } from ':components/AutoGrid.tsx'
import { MemberCard, memberMock } from ':components/MemberCard.tsx'
import MemberCardList from ':islands/MemberCardList.tsx'
export default function Membres() {
return (
@ -8,32 +8,34 @@ export default function Membres() {
<section>
<h2>Permanents</h2>
<AutoGrid columnWidth='15rem'>
{memberMock.slice(0, 5).map(MemberCard)}
<MemberCardList
filters={[['group', 'user.admin']]}
usePlaceholder={true}
useObserver={true}
/>
</AutoGrid>
</section>
<section>
<h2>Bénévoles</h2>
<AutoGrid columnWidth='15rem'>
{memberMock.slice(5, 10).map(MemberCard)}
<MemberCardList
filters={[['group', 'user.member']]}
usePlaceholder={true}
useObserver={true}
/>
</AutoGrid>
</section>
<section>
<h2>Service civique</h2>
<AutoGrid columnWidth='15rem'>
{memberMock.slice(10, 15).map(MemberCard)}
</AutoGrid>
{/* <AutoGrid columnWidth='15rem'></AutoGrid> */}
</section>
<section>
<h2>Stage</h2>
<AutoGrid columnWidth='15rem'>
{memberMock.slice(15, 20).map(MemberCard)}
</AutoGrid>
{/* <AutoGrid columnWidth='15rem'></AutoGrid> */}
</section>
<section>
<h2>Étudiants</h2>
<AutoGrid columnWidth='15rem'>
{memberMock.slice(0, 5).map(MemberCard)}
</AutoGrid>
{/* <AutoGrid columnWidth='15rem'></AutoGrid> */}
</section>
</>
)

45
src/members/mod.ts Normal file
View file

@ -0,0 +1,45 @@
import { MemberCardProps } from ':components/MemberCard.tsx'
import { db } from ':src/db/mod.ts'
import { Db, Ref, User } from '@cohabit/ressources_manager/mod.ts'
export async function fetchCarnet(login: string): Promise<string> {
try {
const response = await fetch(
`https://git.cohabit.fr/${login}/.carnet/raw/branch/main/index.md`,
)
if (!response.ok) {
throw new Error(`[${response.status}] "${response.statusText}"`)
}
return response.text()
} catch (error) {
return `# Carnet introuvable\n\`\`\`js\n${String(error)}\n\`\`\``
}
}
const resolver = Ref.dbResolver(db)
export async function userToMemberCardProps(user: User) {
const groupNames = user.groups.map(async (group) => {
const resolved = await group.ref(resolver)
return resolved.name
})
return {
name: user.name,
groups: await Promise.all(groupNames),
icon: `url("${user.avatar}")`,
id: user.uuid,
}
}
export function dbToMemberCardProps(
db: Db,
): AsyncIterableIterator<MemberCardProps> {
const memberList = db.ressource.user
.list()
.map(userToMemberCardProps)
return memberList as AsyncIterableIterator<MemberCardProps>
}