Merge pull request #1 from MbinOrg/new-pages

Add an Apps and Releases page and numerous enhancements to the Servers page
This commit is contained in:
John Wesley 2024-07-16 15:40:51 -04:00 committed by GitHub
commit c5d404f57a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 511 additions and 101 deletions

View file

@ -9,6 +9,7 @@
}, },
"dependencies": { "dependencies": {
"@iconify-json/material-symbols": "^1.1.82", "@iconify-json/material-symbols": "^1.1.82",
"@iconify-json/simple-icons": "^1.1.109",
"@kobalte/core": "^0.13.3", "@kobalte/core": "^0.13.3",
"@solidjs/router": "^0.13.6", "@solidjs/router": "^0.13.6",
"@solidjs/start": "^1.0.2", "@solidjs/start": "^1.0.2",

10
pnpm-lock.yaml generated
View file

@ -11,6 +11,9 @@ importers:
'@iconify-json/material-symbols': '@iconify-json/material-symbols':
specifier: ^1.1.82 specifier: ^1.1.82
version: 1.1.82 version: 1.1.82
'@iconify-json/simple-icons':
specifier: ^1.1.109
version: 1.1.109
'@kobalte/core': '@kobalte/core':
specifier: ^0.13.3 specifier: ^0.13.3
version: 0.13.3(solid-js@1.8.18) version: 0.13.3(solid-js@1.8.18)
@ -500,6 +503,9 @@ packages:
'@iconify-json/material-symbols@1.1.82': '@iconify-json/material-symbols@1.1.82':
resolution: {integrity: sha512-E67LgMFiAbEVF7rE38ulZU6NeXcPvayFF4hUUqt3g33tWrLsDNqEFTSsPt03l34rH5uGGtHIakTqtBlZ+/hRkw==} resolution: {integrity: sha512-E67LgMFiAbEVF7rE38ulZU6NeXcPvayFF4hUUqt3g33tWrLsDNqEFTSsPt03l34rH5uGGtHIakTqtBlZ+/hRkw==}
'@iconify-json/simple-icons@1.1.109':
resolution: {integrity: sha512-vIhIJQDdbS5R6kSyIHVBRCaR2jiFjVlbVtB4PAoLjQL45vJRHMTwkrFa536XcX7yW69HbQkoanydcyDjknI6pw==}
'@iconify/types@2.0.0': '@iconify/types@2.0.0':
resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==}
@ -3634,6 +3640,10 @@ snapshots:
dependencies: dependencies:
'@iconify/types': 2.0.0 '@iconify/types': 2.0.0
'@iconify-json/simple-icons@1.1.109':
dependencies:
'@iconify/types': 2.0.0
'@iconify/types@2.0.0': {} '@iconify/types@2.0.0': {}
'@iconify/utils@2.1.25': '@iconify/utils@2.1.25':

View file

@ -4,17 +4,30 @@ import { Dynamic } from 'solid-js/web';
const Chip: ParentComponent<{ const Chip: ParentComponent<{
icon?: (props: ComponentProps<'svg'>) => JSX.Element; icon?: (props: ComponentProps<'svg'>) => JSX.Element;
title?: string; title?: string;
class?: string;
classList?: {
[k: string]: boolean | undefined;
};
href?: string;
}> = (props) => { }> = (props) => {
return ( return (
<span <Dynamic
class="px-2 py-1 border rounded-lg inline-flex items-center" component={props.href ? 'a' : 'span'}
class={
'px-2 py-1 border rounded-lg inline-flex items-center ' + props.class
}
classList={{
'hover:text-blue-500': !!props.href,
...props.classList,
}}
title={props.title} title={props.title}
href={props.href}
> >
<Show when={props.icon}> <Show when={props.icon}>
<Dynamic component={props.icon} class="pr-1" /> <Dynamic component={props.icon} class="pr-1" />
</Show> </Show>
{props.children} {props.children}
</span> </Dynamic>
); );
}; };

View file

@ -6,23 +6,34 @@ export default function Nav() {
path == location.pathname path == location.pathname
? 'border-sky-600' ? 'border-sky-600'
: 'border-transparent hover:border-sky-600'; : 'border-transparent hover:border-sky-600';
const navItemClass = 'border-b-2 mx-1.5 sm:mx-6 ';
return ( return (
<nav class="flex px-4 py-2 items-center bg-[#29144a]"> <nav class="flex px-4 py-2 items-center bg-[#29144a]">
<a href="/"> <a href="/">
<img src="/logo.svg" class="size-8" /> <img src="/logo.svg" class="size-8" />
</a> </a>
<ul class="container flex items-center p-3 text-gray-200"> <ul class="container flex items-center p-3 text-gray-200">
<li class={`border-b-2 ${active('/')} mx-1.5 sm:mx-6`}> <li class={navItemClass + active('/')}>
<a href="/">Home</a> <a href="/">Home</a>
</li> </li>
<li class={`border-b-2 ${active('/servers')} mx-1.5 sm:mx-6`}> <li class={navItemClass + active('/servers')}>
<a href="/servers">Servers</a> <a href="/servers">Servers</a>
</li> </li>
<li class={navItemClass + active('/apps')}>
<a href="/apps">Apps</a>
</li>
<li class={navItemClass + active('/releases')}>
<a href="/releases">Releases</a>
</li>
<span class="grow"></span> <span class="grow"></span>
<li class={`border-b-2 mx-1.5 sm:mx-6`}>
<li class={navItemClass}>
<a href="https://docs.joinmbin.org/">Docs</a> <a href="https://docs.joinmbin.org/">Docs</a>
</li> </li>
<li class={`border-b-2 mx-1.5 sm:mx-6`}> <li class={navItemClass}>
<a href="https://github.com/mbinOrg/mbin">GitHub</a> <a href="https://github.com/mbinOrg/mbin">GitHub</a>
</li> </li>
</ul> </ul>

View file

@ -0,0 +1,34 @@
import { splitProps, ValidComponent, type Component } from "solid-js"
import { PolymorphicProps } from "@kobalte/core/polymorphic"
import * as TooltipPrimitive from "@kobalte/core/tooltip"
import { cn } from "~/lib/utils"
const TooltipTrigger = TooltipPrimitive.Trigger
const Tooltip: Component<TooltipPrimitive.TooltipRootProps> = (props) => {
return <TooltipPrimitive.Root gutter={4} {...props} />
}
type TooltipContentProps<T extends ValidComponent = "div"> =
TooltipPrimitive.TooltipContentProps<T> & { class?: string | undefined }
const TooltipContent = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, TooltipContentProps<T>>
) => {
const [local, others] = splitProps(props as TooltipContentProps, ["class"])
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
class={cn(
"z-50 origin-[var(--kb-popover-content-transform-origin)] overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95",
local.class
)}
{...others}
/>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent }

31
src/data/apps.ts Normal file
View file

@ -0,0 +1,31 @@
export interface App {
name: string;
icon: string;
description: string;
links: {
appleStore?: string;
fdroid?: string;
flathub?: string;
github?: string;
googlePlay?: string;
microsoftStore?: string;
matrix?: string;
snapcraft?: string;
web?: string;
};
}
export const apps: App[] = [
{
name: 'Interstellar',
icon: 'https://raw.githubusercontent.com/jwr1/interstellar/6d8fce0972febccec91fa056746fdb9f4f960217/assets/icons/logo.png',
description: 'An app for Mbin and Lemmy, connecting you to the fediverse.',
links: {
flathub: 'https://flathub.org/apps/one.jwr.interstellar',
github: 'https://github.com/jwr1/interstellar',
googlePlay:
'https://play.google.com/store/apps/details?id=one.jwr.interstellar',
matrix: 'https://matrix.to/#/#interstellar-space:matrix.org',
},
},
];

View file

@ -3,6 +3,32 @@ import fs from 'node:fs/promises';
await fs.rm('./.output/data', { recursive: true, force: true }); await fs.rm('./.output/data', { recursive: true, force: true });
await fs.mkdir('./.output/data', { recursive: true }); await fs.mkdir('./.output/data', { recursive: true });
/** @returns {Promise<Array<import('./routes/releases').Release>>} */
const fetchReleases = async () => {
const releasesJson = await (
await fetch(
'https://api.github.com/repos/mbinOrg/mbin/releases?per_page=100',
)
).json();
/** @type {Array<import('./routes/releases').Release>} */
const output = releasesJson.map((v, i, a) => ({
version: v.tag_name.substring(1),
// A server is considered outdated if a newer version has been available for more than 30 days.
outdated:
i > 0 &&
Date.now() - Date.parse(a[i - 1].published_at) > 1000 * 60 * 60 * 24 * 30,
publishedAt: v.published_at,
githubUrl: v.html_url,
body: v.body,
}));
return output.sort((a, b) => a.publishedAt - b.publishedAt);
};
const releases = await fetchReleases();
fs.writeFile('./.output/data/releases.json', JSON.stringify(releases), 'utf8');
/** /**
* @returns {Promise<string>} * @returns {Promise<string>}
*/ */
@ -73,62 +99,91 @@ const fetchServerList = async () => {
const fetchServerInfo = async (domain) => { const fetchServerInfo = async (domain) => {
console.log('START:', domain); console.log('START:', domain);
const jsonNodeInfo = await ( let jsonNodeInfo;
await fetch(`https://${domain}/nodeinfo/2.1.json`) try {
).json(); jsonNodeInfo = await (
await fetch(`https://${domain}/nodeinfo/2.1.json`)
).json();
if (jsonNodeInfo.software.name != 'mbin') { if (jsonNodeInfo.software.name != 'mbin') {
throw new Error(`${domain} software does not match mbin (skipped)`); throw new Error(`software check failed`);
}
} catch (error) {
throw new Error(`${domain}: invalid nodeinfo response (skip)`, {
cause: error,
});
} }
const jsonApiInfo = await (await fetch(`https://${domain}/api/info`)).json(); /** @type {import('./routes/servers').Server['api']} */
if (jsonApiInfo.websiteDomain != domain) { let apiOutput;
throw new Error(`${domain} api not setup correctly (skipped)`); try {
const jsonApiInfo = await (
await fetch(`https://${domain}/api/info`)
).json();
if (jsonApiInfo.websiteDomain != domain) {
throw new Error(`domain check failed`);
}
const jsonApiInstance = await (
await fetch(`https://${domain}/api/instance`)
).json();
const jsonApiDefederated = await (
await fetch(`https://${domain}/api/defederated`)
).json();
apiOutput = {
defaultLang: jsonApiInfo.websiteDefaultLang ?? 'en',
federationEnabled: jsonApiInfo.websiteFederationEnabled,
contactEmail: jsonApiInfo.websiteContactEmail,
pages: jsonApiInstance,
defederated: jsonApiDefederated.instances ?? [],
};
} catch (error) {
console.error(
new Error(`${domain}: invalid api response (continue)`, {
cause: error,
}),
);
} }
const jsonApiInstance = await (
await fetch(`https://${domain}/api/instance`)
).json();
const jsonApiDefederated = await (
await fetch(`https://${domain}/api/defederated`)
).json();
console.log('FINISH:', domain); /** @type {import('./routes/servers').Server} */
const output = {
return {
domain: domain, domain: domain,
version: jsonNodeInfo.software.version, version: jsonNodeInfo.software.version,
versionOutdated: releases.find(
(v) => v.version === jsonNodeInfo.software.version,
).outdated,
name: jsonNodeInfo.metadata.nodeName, name: jsonNodeInfo.metadata.nodeName,
description: jsonNodeInfo.metadata.nodeDescription, description: jsonNodeInfo.metadata.nodeDescription,
openRegistrations: jsonNodeInfo.openRegistrations, openRegistrations: jsonNodeInfo.openRegistrations,
federationEnabled: jsonApiInfo.websiteFederationEnabled,
language: jsonApiInfo.websiteDefaultLang ?? 'en',
contactEmail: jsonApiInfo.websiteContactEmail,
totalUsers: jsonNodeInfo.usage.users.total, totalUsers: jsonNodeInfo.usage.users.total,
activeHalfyearUsers: jsonNodeInfo.usage.users.activeHalfyear, activeHalfyearUsers: jsonNodeInfo.usage.users.activeHalfyear,
activeMonthUsers: jsonNodeInfo.usage.users.activeMonth, activeMonthUsers: jsonNodeInfo.usage.users.activeMonth,
localPosts: jsonNodeInfo.usage.localPosts, localPosts: jsonNodeInfo.usage.localPosts,
localComments: jsonNodeInfo.usage.localComments, localComments: jsonNodeInfo.usage.localComments,
pages: jsonApiInstance, api: apiOutput,
defederated: jsonApiDefederated.instances ?? [],
}; };
console.log('FINISH:', domain);
return output;
}; };
const initServerData = async () => { const initServerData = async () => {
const servers = await fetchServerList(); const serverDomains = await fetchServerList();
const serversJson = (await Promise.allSettled(servers.map(fetchServerInfo))) const serversJson = (
await Promise.allSettled(serverDomains.map(fetchServerInfo))
)
.filter((v) => { .filter((v) => {
const isOk = v.status == 'fulfilled'; const isOk = v.status == 'fulfilled';
if (!isOk) { if (!isOk) console.error(v.reason);
console.error(v.reason);
}
return isOk; return isOk;
}) })
.map((v) => v.value); .map((v) => v.value);
console.log('Successful Mbin servers found:', serversJson.length); console.log('Mbin servers found:', serversJson.length);
fs.writeFile( fs.writeFile(
'./.output/data/servers.json', './.output/data/servers.json',

116
src/routes/apps.tsx Normal file
View file

@ -0,0 +1,116 @@
import { ComponentProps, For, JSX, Show } from 'solid-js';
import Chip from '~/components/Chip';
import SimpleIconsAppstore from '~icons/simple-icons/appstore';
import SimpleIconsFdroid from '~icons/simple-icons/fdroid';
import SimpleIconsFlathub from '~icons/simple-icons/flathub';
import SimpleIconsGithub from '~icons/simple-icons/github';
import SimpleIconsGoogleplay from '~icons/simple-icons/googleplay';
import SimpleIconsMatrix from '~icons/simple-icons/matrix';
import SimpleIconsSnapcraft from '~icons/simple-icons/snapcraft';
import MaterialSymbolsWeb from '~icons/material-symbols/web';
import { apps, App } from '~/data/apps';
const linkMap: Record<
keyof App['links'],
{ name: string; icon: (props: ComponentProps<'svg'>) => JSX.Element }
> = {
github: {
name: 'GitHub',
icon: SimpleIconsGithub,
},
matrix: {
name: 'Matrix',
icon: SimpleIconsMatrix,
},
appleStore: {
name: 'Apple App Store',
icon: SimpleIconsAppstore,
},
fdroid: {
name: 'F-Droid',
icon: SimpleIconsFdroid,
},
flathub: {
name: 'Flathub',
icon: SimpleIconsFlathub,
},
googlePlay: {
name: 'Google Play',
icon: SimpleIconsGoogleplay,
},
microsoftStore: {
name: 'Microsoft Store',
icon: () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M8 3.75V6H2.75a.75.75 0 0 0-.75.75v11.5A2.75 2.75 0 0 0 4.75 21h14.5A2.75 2.75 0 0 0 22 18.25V6.75a.75.75 0 0 0-.75-.75H16V3.75A1.75 1.75 0 0 0 14.25 2h-4.5A1.75 1.75 0 0 0 8 3.75m1.75-.25h4.5a.25.25 0 0 1 .25.25V6h-5V3.75a.25.25 0 0 1 .25-.25M8 13V9.5h3.5V13zm0 4.5V14h3.5v3.5zm8-4.5h-3.5V9.5H16zm-3.5 4.5V14H16v3.5z"
></path>
</svg>
),
},
snapcraft: {
name: 'Snapcraft',
icon: SimpleIconsSnapcraft,
},
web: {
name: 'Web',
icon: MaterialSymbolsWeb,
},
};
export default function ServersPage() {
return (
<main class="mx-auto p-4 max-w-screen-xl">
<h1 class="max-6-xs text-6xl text-sky-600 font-extralight uppercase my-16">
Mbin Apps
</h1>
<div class="grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
<For each={apps}>
{(app) => {
return (
<div class="border rounded-lg p-4 block relative text-center">
<img src={app.icon} class="inline-block size-16 rounded" />
<br />
<div class="text-3xl text-sky-600">{app.name}</div>
<p class="text-center">{app.description}</p>
<div class="my-1 flex flex-wrap gap-1">
<For each={Object.entries(linkMap)}>
{([key, value]) => (
<Show when={app.links[key]}>
<Chip href={app.links[key]} icon={value.icon}>
{value.name}
</Chip>
</Show>
)}
</For>
</div>
</div>
);
}}
</For>
</div>
<div class="my-8 text-center font-light">
Don't see your app here? Contribute to the list by making a pull request{' '}
<a
href="https://github.com/MbinOrg/mbin-website/blob/main/src/data/apps.ts"
class="text-sky-600"
>
here
</a>
.
</div>
</main>
);
}

67
src/routes/releases.tsx Normal file
View file

@ -0,0 +1,67 @@
import { For } from 'solid-js';
import releasesJson from '../../.output/data/releases.json';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '~/components/ui/accordion';
import Markdown from '~/components/Markdown';
import SimpleIconsGithub from '~icons/simple-icons/github';
import { useSearchParams } from '@solidjs/router';
const releases = releasesJson as Release[];
export interface Release {
version: string;
outdated: boolean;
publishedAt: string;
githubUrl: string;
body: string;
}
export default function ReleasesPage() {
const [searchParams] = useSearchParams();
return (
<main class="mx-auto p-4 max-w-screen-xl">
<h1 class="max-6-xs text-6xl text-sky-600 font-extralight uppercase my-16">
Mbin Releases
</h1>
<div class="mb-12 font-light">
Also view releases on{' '}
<a href="https://github.com/MbinOrg/mbin/releases" class="text-sky-600">
GitHub
</a>
.
</div>
<Accordion
multiple
defaultValue={[searchParams.version ?? releases[0].version]}
>
<For each={releases}>
{(release) => (
<AccordionItem value={release.version}>
<AccordionTrigger class="text-lg">
<span class="inline-flex items-center gap-3">
{release.version}
<span class="text-base font-light">
{new Date(release.publishedAt).toLocaleDateString()}
</span>
<a href={release.githubUrl} class="hover:text-blue-500">
<SimpleIconsGithub />
</a>
</span>
</AccordionTrigger>
<AccordionContent>
<Markdown>{release.body}</Markdown>
</AccordionContent>
</AccordionItem>
)}
</For>
</Accordion>
</main>
);
}

View file

@ -23,6 +23,7 @@ import MaterialSymbolsPerson from '~icons/material-symbols/person';
import MaterialSymbolsPersonCheck from '~icons/material-symbols/person-check'; import MaterialSymbolsPersonCheck from '~icons/material-symbols/person-check';
import MaterialSymbolsNews from '~icons/material-symbols/news'; import MaterialSymbolsNews from '~icons/material-symbols/news';
import MaterialSymbolsComment from '~icons/material-symbols/comment'; import MaterialSymbolsComment from '~icons/material-symbols/comment';
import MaterialSymbolsWarning from '~icons/material-symbols/warning';
import { import {
Select, Select,
SelectContent, SelectContent,
@ -32,34 +33,42 @@ import {
} from '~/components/ui/select'; } from '~/components/ui/select';
import { Checkbox } from '~/components/ui/checkbox'; import { Checkbox } from '~/components/ui/checkbox';
import { Label } from '~/components/ui/label'; import { Label } from '~/components/ui/label';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '~/components/ui/tooltip';
const servers = serversJson as Server[]; const servers = serversJson as Server[];
export interface Server { export interface Server {
domain: string; domain: string;
version: string; version: string;
versionOutdated: boolean;
name: string; name: string;
description: string; description: string;
openRegistrations: boolean; openRegistrations: boolean;
federationEnabled: boolean;
language: string;
contactEmail: string;
totalUsers: number; totalUsers: number;
activeHalfyearUsers: number; activeHalfyearUsers: number;
activeMonthUsers: number; activeMonthUsers: number;
localPosts: number; localPosts: number;
localComments: number; localComments: number;
pages: { api?: {
about?: string; contactEmail: string;
contact?: string; federationEnabled: boolean;
faq?: string; defaultLang: string;
privacyPolicy?: string; pages: {
terms?: string; about?: string;
contact?: string;
faq?: string;
privacyPolicy?: string;
terms?: string;
};
defederated: string[];
}; };
defederated: string[];
} }
const pageNames: Required<Server['pages']> = { const pageNames: Required<NonNullable<Server['api']>['pages']> = {
about: 'About', about: 'About',
contact: 'Contact', contact: 'Contact',
faq: 'Frequently Asked Questions', faq: 'Frequently Asked Questions',
@ -88,7 +97,7 @@ export default function ServersPage() {
.filter( .filter(
(server) => (server) =>
(!filterRegistration() || server.openRegistrations) && (!filterRegistration() || server.openRegistrations) &&
(!langFilter() || server.language == langFilter()), (!langFilter() || server.api?.defaultLang == langFilter()),
) )
.sort((a, b) => { .sort((a, b) => {
switch (sort()) { switch (sort()) {
@ -109,6 +118,17 @@ export default function ServersPage() {
<h1 class="max-6-xs text-6xl text-sky-600 font-extralight uppercase my-16"> <h1 class="max-6-xs text-6xl text-sky-600 font-extralight uppercase my-16">
Mbin Servers Mbin Servers
</h1> </h1>
<div class="mb-12 font-light">
Also view servers on{' '}
<a href="https://fedidb.org/software/mbin" class="text-sky-600">
FediDB
</a>{' '}
and{' '}
<a href="https://mbin.fediverse.observer/list" class="text-sky-600">
Fediverse Observer
</a>
.
</div>
<div class="flex"> <div class="flex">
<Checkbox <Checkbox
id="open-registration" id="open-registration"
@ -123,7 +143,11 @@ export default function ServersPage() {
<Select <Select
value={langFilter()} value={langFilter()}
onChange={setLangFilter} onChange={setLangFilter}
options={[...new Set(servers.map((v) => v.language))]} options={[
...new Set(
servers.filter((v) => v.api).map((v) => v.api?.defaultLang),
),
]}
placeholder="All Languages" placeholder="All Languages"
itemComponent={(props) => ( itemComponent={(props) => (
<SelectItem item={props.item}> <SelectItem item={props.item}>
@ -131,7 +155,7 @@ export default function ServersPage() {
</SelectItem> </SelectItem>
)} )}
> >
<SelectTrigger aria-label="Fruit" class="w-[180px]"> <SelectTrigger aria-label="Language" class="w-[180px]">
<SelectValue<string>> <SelectValue<string>>
{(state) => languageNames.of(state.selectedOption())} {(state) => languageNames.of(state.selectedOption())}
</SelectValue> </SelectValue>
@ -151,7 +175,7 @@ export default function ServersPage() {
)} )}
> >
<SelectTrigger aria-label="Fruit" class="w-[180px]"> <SelectTrigger aria-label="Fruit" class="w-[180px]">
<SelectValue<string>> <SelectValue<keyof typeof sortNameMap>>
{(state) => sortNameMap[state.selectedOption()]} {(state) => sortNameMap[state.selectedOption()]}
</SelectValue> </SelectValue>
</SelectTrigger> </SelectTrigger>
@ -167,20 +191,50 @@ export default function ServersPage() {
users users
</Chip> </Chip>
</div> </div>
<div <div class="grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
class="grid gap-4"
style={{
'grid-template-columns': 'repeat(auto-fit, minmax(350px, 1fr))',
}}
>
<For each={resultServers()}> <For each={resultServers()}>
{(server) => { {(server) => {
const StatChips = () => ( const StatChips = () => (
<> <>
<Chip title="Mbin Version">Mbin {server.version}</Chip> <Chip
<Chip title="Language" icon={MaterialSymbolsLanguage}> classList={{
{languageNames.of(server.language)} 'bg-red-900 bg-opacity-40': server.versionOutdated,
}}
href={`/releases?version=${server.version}`}
>
Mbin {server.version}
<Show when={server.versionOutdated}>
<Tooltip>
<TooltipTrigger>
<MaterialSymbolsWarning class="ml-1 text-red-500" />
</TooltipTrigger>
<TooltipContent>
This server is using an outdated version. Please ask the
server admin to upgrade or use a different server.
</TooltipContent>
</Tooltip>
</Show>
</Chip> </Chip>
<Show when={!server.api}>
<Tooltip>
<TooltipTrigger>
<Chip class="bg-red-900 bg-opacity-40">
No API
<MaterialSymbolsWarning class="ml-1 text-red-500" />
</Chip>
</TooltipTrigger>
<TooltipContent>
This server's api is inaccessible and will not work with
apps. Please ask the server admin to fix the issue or use
a different server.
</TooltipContent>
</Tooltip>
</Show>
<Show when={server.api}>
<Chip title="Language" icon={MaterialSymbolsLanguage}>
{languageNames.of(server.api!.defaultLang)}
</Chip>
</Show>
<Chip title="Total users" icon={MaterialSymbolsPerson}> <Chip title="Total users" icon={MaterialSymbolsPerson}>
{server.totalUsers} {server.totalUsers}
</Chip> </Chip>
@ -199,7 +253,7 @@ export default function ServersPage() {
> >
<img <img
src={`https://${server.domain}/favicon.ico`} src={`https://${server.domain}/favicon.ico`}
class="inline-block size-16" class="inline-block size-16 rounded"
/> />
<br /> <br />
<div class="text-3xl text-sky-600">{server.domain}</div> <div class="text-3xl text-sky-600">{server.domain}</div>
@ -254,48 +308,66 @@ export default function ServersPage() {
</Chip> </Chip>
</div> </div>
<Accordion <Show
multiple={false} when={server.api}
collapsible fallback={
defaultValue={['about']} <div>
No information available due to inaccessible api.
</div>
}
> >
<For each={Object.entries(server.pages)}> <Accordion
{([page, pageContent]) => ( multiple={false}
<Show when={pageContent || page === 'contact'}> collapsible
<AccordionItem value={page}> defaultValue={['about']}
<AccordionTrigger> >
{pageNames[page as keyof Server['pages']]} <For each={Object.entries(server.api!.pages)}>
</AccordionTrigger> {([page, pageContent]) => (
<AccordionContent> <Show when={pageContent || page === 'contact'}>
<Show when={page == 'contact'}> <AccordionItem value={page}>
<Button <AccordionTrigger>
href={`mailto:${server.contactEmail}`} {
as="a" pageNames[
> page as keyof NonNullable<
Email admin: {server.contactEmail} Server['api']
</Button> >['pages']
</Show> ]
<Markdown>{pageContent}</Markdown> }
</AccordionContent> </AccordionTrigger>
</AccordionItem> <AccordionContent>
</Show> <Show when={page == 'contact'}>
)} <Button
</For> href={`mailto:${
<Show when={server.defederated.length}> server.api!.contactEmail
<AccordionItem value="defederated"> }`}
<AccordionTrigger> as="a"
Defederated Servers ({server.defederated.length}) >
</AccordionTrigger> Email admin: {server.api!.contactEmail}
<AccordionContent> </Button>
<div class="flex flex-wrap gap-1"> </Show>
<For each={server.defederated}> <Markdown>{pageContent}</Markdown>
{(server) => <Chip>{server}</Chip>} </AccordionContent>
</For> </AccordionItem>
</div> </Show>
</AccordionContent> )}
</AccordionItem> </For>
</Show> <Show when={server.api!.defederated.length}>
</Accordion> <AccordionItem value="defederated">
<AccordionTrigger>
Defederated Servers (
{server.api!.defederated.length})
</AccordionTrigger>
<AccordionContent>
<div class="flex flex-wrap gap-1">
<For each={server.api!.defederated}>
{(server) => <Chip>{server}</Chip>}
</For>
</div>
</AccordionContent>
</AccordionItem>
</Show>
</Accordion>
</Show>
<Show when={server.openRegistrations}> <Show when={server.openRegistrations}>
<DialogFooter> <DialogFooter>