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": {
"@iconify-json/material-symbols": "^1.1.82",
"@iconify-json/simple-icons": "^1.1.109",
"@kobalte/core": "^0.13.3",
"@solidjs/router": "^0.13.6",
"@solidjs/start": "^1.0.2",

10
pnpm-lock.yaml generated
View file

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

View file

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

View file

@ -6,23 +6,34 @@ export default function Nav() {
path == location.pathname
? 'border-sky-600'
: 'border-transparent hover:border-sky-600';
const navItemClass = 'border-b-2 mx-1.5 sm:mx-6 ';
return (
<nav class="flex px-4 py-2 items-center bg-[#29144a]">
<a href="/">
<img src="/logo.svg" class="size-8" />
</a>
<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>
</li>
<li class={`border-b-2 ${active('/servers')} mx-1.5 sm:mx-6`}>
<li class={navItemClass + active('/servers')}>
<a href="/servers">Servers</a>
</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>
<li class={`border-b-2 mx-1.5 sm:mx-6`}>
<li class={navItemClass}>
<a href="https://docs.joinmbin.org/">Docs</a>
</li>
<li class={`border-b-2 mx-1.5 sm:mx-6`}>
<li class={navItemClass}>
<a href="https://github.com/mbinOrg/mbin">GitHub</a>
</li>
</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.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>}
*/
@ -73,17 +99,29 @@ const fetchServerList = async () => {
const fetchServerInfo = async (domain) => {
console.log('START:', domain);
const jsonNodeInfo = await (
let jsonNodeInfo;
try {
jsonNodeInfo = await (
await fetch(`https://${domain}/nodeinfo/2.1.json`)
).json();
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']} */
let apiOutput;
try {
const jsonApiInfo = await (
await fetch(`https://${domain}/api/info`)
).json();
if (jsonApiInfo.websiteDomain != domain) {
throw new Error(`${domain} api not setup correctly (skipped)`);
throw new Error(`domain check failed`);
}
const jsonApiInstance = await (
await fetch(`https://${domain}/api/instance`)
@ -92,43 +130,60 @@ const fetchServerInfo = async (domain) => {
await fetch(`https://${domain}/api/defederated`)
).json();
console.log('FINISH:', domain);
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,
}),
);
}
return {
/** @type {import('./routes/servers').Server} */
const output = {
domain: domain,
version: jsonNodeInfo.software.version,
versionOutdated: releases.find(
(v) => v.version === jsonNodeInfo.software.version,
).outdated,
name: jsonNodeInfo.metadata.nodeName,
description: jsonNodeInfo.metadata.nodeDescription,
openRegistrations: jsonNodeInfo.openRegistrations,
federationEnabled: jsonApiInfo.websiteFederationEnabled,
language: jsonApiInfo.websiteDefaultLang ?? 'en',
contactEmail: jsonApiInfo.websiteContactEmail,
totalUsers: jsonNodeInfo.usage.users.total,
activeHalfyearUsers: jsonNodeInfo.usage.users.activeHalfyear,
activeMonthUsers: jsonNodeInfo.usage.users.activeMonth,
localPosts: jsonNodeInfo.usage.localPosts,
localComments: jsonNodeInfo.usage.localComments,
pages: jsonApiInstance,
defederated: jsonApiDefederated.instances ?? [],
api: apiOutput,
};
console.log('FINISH:', domain);
return output;
};
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) => {
const isOk = v.status == 'fulfilled';
if (!isOk) {
console.error(v.reason);
}
if (!isOk) console.error(v.reason);
return isOk;
})
.map((v) => v.value);
console.log('Successful Mbin servers found:', serversJson.length);
console.log('Mbin servers found:', serversJson.length);
fs.writeFile(
'./.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 MaterialSymbolsNews from '~icons/material-symbols/news';
import MaterialSymbolsComment from '~icons/material-symbols/comment';
import MaterialSymbolsWarning from '~icons/material-symbols/warning';
import {
Select,
SelectContent,
@ -32,23 +33,30 @@ import {
} from '~/components/ui/select';
import { Checkbox } from '~/components/ui/checkbox';
import { Label } from '~/components/ui/label';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '~/components/ui/tooltip';
const servers = serversJson as Server[];
export interface Server {
domain: string;
version: string;
versionOutdated: boolean;
name: string;
description: string;
openRegistrations: boolean;
federationEnabled: boolean;
language: string;
contactEmail: string;
totalUsers: number;
activeHalfyearUsers: number;
activeMonthUsers: number;
localPosts: number;
localComments: number;
api?: {
contactEmail: string;
federationEnabled: boolean;
defaultLang: string;
pages: {
about?: string;
contact?: string;
@ -57,9 +65,10 @@ export interface Server {
terms?: string;
};
defederated: string[];
};
}
const pageNames: Required<Server['pages']> = {
const pageNames: Required<NonNullable<Server['api']>['pages']> = {
about: 'About',
contact: 'Contact',
faq: 'Frequently Asked Questions',
@ -88,7 +97,7 @@ export default function ServersPage() {
.filter(
(server) =>
(!filterRegistration() || server.openRegistrations) &&
(!langFilter() || server.language == langFilter()),
(!langFilter() || server.api?.defaultLang == langFilter()),
)
.sort((a, b) => {
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">
Mbin Servers
</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">
<Checkbox
id="open-registration"
@ -123,7 +143,11 @@ export default function ServersPage() {
<Select
value={langFilter()}
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"
itemComponent={(props) => (
<SelectItem item={props.item}>
@ -131,7 +155,7 @@ export default function ServersPage() {
</SelectItem>
)}
>
<SelectTrigger aria-label="Fruit" class="w-[180px]">
<SelectTrigger aria-label="Language" class="w-[180px]">
<SelectValue<string>>
{(state) => languageNames.of(state.selectedOption())}
</SelectValue>
@ -151,7 +175,7 @@ export default function ServersPage() {
)}
>
<SelectTrigger aria-label="Fruit" class="w-[180px]">
<SelectValue<string>>
<SelectValue<keyof typeof sortNameMap>>
{(state) => sortNameMap[state.selectedOption()]}
</SelectValue>
</SelectTrigger>
@ -167,20 +191,50 @@ export default function ServersPage() {
users
</Chip>
</div>
<div
class="grid gap-4"
style={{
'grid-template-columns': 'repeat(auto-fit, minmax(350px, 1fr))',
}}
>
<div class="grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
<For each={resultServers()}>
{(server) => {
const StatChips = () => (
<>
<Chip title="Mbin Version">Mbin {server.version}</Chip>
<Chip title="Language" icon={MaterialSymbolsLanguage}>
{languageNames.of(server.language)}
<Chip
classList={{
'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>
<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}>
{server.totalUsers}
</Chip>
@ -199,7 +253,7 @@ export default function ServersPage() {
>
<img
src={`https://${server.domain}/favicon.ico`}
class="inline-block size-16"
class="inline-block size-16 rounded"
/>
<br />
<div class="text-3xl text-sky-600">{server.domain}</div>
@ -254,25 +308,41 @@ export default function ServersPage() {
</Chip>
</div>
<Show
when={server.api}
fallback={
<div>
No information available due to inaccessible api.
</div>
}
>
<Accordion
multiple={false}
collapsible
defaultValue={['about']}
>
<For each={Object.entries(server.pages)}>
<For each={Object.entries(server.api!.pages)}>
{([page, pageContent]) => (
<Show when={pageContent || page === 'contact'}>
<AccordionItem value={page}>
<AccordionTrigger>
{pageNames[page as keyof Server['pages']]}
{
pageNames[
page as keyof NonNullable<
Server['api']
>['pages']
]
}
</AccordionTrigger>
<AccordionContent>
<Show when={page == 'contact'}>
<Button
href={`mailto:${server.contactEmail}`}
href={`mailto:${
server.api!.contactEmail
}`}
as="a"
>
Email admin: {server.contactEmail}
Email admin: {server.api!.contactEmail}
</Button>
</Show>
<Markdown>{pageContent}</Markdown>
@ -281,14 +351,15 @@ export default function ServersPage() {
</Show>
)}
</For>
<Show when={server.defederated.length}>
<Show when={server.api!.defederated.length}>
<AccordionItem value="defederated">
<AccordionTrigger>
Defederated Servers ({server.defederated.length})
Defederated Servers (
{server.api!.defederated.length})
</AccordionTrigger>
<AccordionContent>
<div class="flex flex-wrap gap-1">
<For each={server.defederated}>
<For each={server.api!.defederated}>
{(server) => <Chip>{server}</Chip>}
</For>
</div>
@ -296,6 +367,7 @@ export default function ServersPage() {
</AccordionItem>
</Show>
</Accordion>
</Show>
<Show when={server.openRegistrations}>
<DialogFooter>