diff --git a/package.json b/package.json index 731b381..b6a7945 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 17fcfb1..9bca235 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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': diff --git a/src/components/Chip.tsx b/src/components/Chip.tsx index b7d1315..c6dee38 100644 --- a/src/components/Chip.tsx +++ b/src/components/Chip.tsx @@ -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 ( - {props.children} - + ); }; diff --git a/src/components/Nav.tsx b/src/components/Nav.tsx index fac4b64..7e7bd25 100644 --- a/src/components/Nav.tsx +++ b/src/components/Nav.tsx @@ -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 ( - + Home - + Servers + + Apps + + + Releases + + - + + Docs - + GitHub diff --git a/src/components/ui/tooltip.tsx b/src/components/ui/tooltip.tsx new file mode 100644 index 0000000..becfefa --- /dev/null +++ b/src/components/ui/tooltip.tsx @@ -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 = (props) => { + return +} + +type TooltipContentProps = + TooltipPrimitive.TooltipContentProps & { class?: string | undefined } + +const TooltipContent = ( + props: PolymorphicProps> +) => { + const [local, others] = splitProps(props as TooltipContentProps, ["class"]) + return ( + + + + ) +} + +export { Tooltip, TooltipTrigger, TooltipContent } diff --git a/src/data/apps.ts b/src/data/apps.ts new file mode 100644 index 0000000..5701876 --- /dev/null +++ b/src/data/apps.ts @@ -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', + }, + }, +]; diff --git a/src/initdata.js b/src/initdata.js index 6bd92e4..ed6c72a 100644 --- a/src/initdata.js +++ b/src/initdata.js @@ -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>} */ +const fetchReleases = async () => { + const releasesJson = await ( + await fetch( + 'https://api.github.com/repos/mbinOrg/mbin/releases?per_page=100', + ) + ).json(); + + /** @type {Array} */ + 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} */ @@ -73,62 +99,91 @@ const fetchServerList = async () => { const fetchServerInfo = async (domain) => { console.log('START:', domain); - const jsonNodeInfo = await ( - await fetch(`https://${domain}/nodeinfo/2.1.json`) - ).json(); + 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)`); + if (jsonNodeInfo.software.name != 'mbin') { + 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(); - if (jsonApiInfo.websiteDomain != domain) { - throw new Error(`${domain} api not setup correctly (skipped)`); + /** @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 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); - - 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', diff --git a/src/routes/apps.tsx b/src/routes/apps.tsx new file mode 100644 index 0000000..040ccdc --- /dev/null +++ b/src/routes/apps.tsx @@ -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: () => ( + + + + ), + }, + snapcraft: { + name: 'Snapcraft', + icon: SimpleIconsSnapcraft, + }, + web: { + name: 'Web', + icon: MaterialSymbolsWeb, + }, +}; + +export default function ServersPage() { + return ( + + + Mbin Apps + + + + + {(app) => { + return ( + + + + {app.name} + + {app.description} + + + + {([key, value]) => ( + + + {value.name} + + + )} + + + + ); + }} + + + + + Don't see your app here? Contribute to the list by making a pull request{' '} + + here + + . + + + ); +} diff --git a/src/routes/releases.tsx b/src/routes/releases.tsx new file mode 100644 index 0000000..37770fc --- /dev/null +++ b/src/routes/releases.tsx @@ -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 ( + + + Mbin Releases + + + + Also view releases on{' '} + + GitHub + + . + + + + + {(release) => ( + + + + {release.version} + + {new Date(release.publishedAt).toLocaleDateString()} + + + + + + + + {release.body} + + + )} + + + + ); +} diff --git a/src/routes/servers.tsx b/src/routes/servers.tsx index 1118d4f..f31bc96 100644 --- a/src/routes/servers.tsx +++ b/src/routes/servers.tsx @@ -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,34 +33,42 @@ 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; - pages: { - about?: string; - contact?: string; - faq?: string; - privacyPolicy?: string; - terms?: string; + api?: { + contactEmail: string; + federationEnabled: boolean; + defaultLang: string; + pages: { + about?: string; + contact?: string; + faq?: string; + privacyPolicy?: string; + terms?: string; + }; + defederated: string[]; }; - defederated: string[]; } -const pageNames: Required = { +const pageNames: Required['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() { Mbin Servers + + Also view servers on{' '} + + FediDB + {' '} + and{' '} + + Fediverse Observer + + . + v.language))]} + options={[ + ...new Set( + servers.filter((v) => v.api).map((v) => v.api?.defaultLang), + ), + ]} placeholder="All Languages" itemComponent={(props) => ( @@ -131,7 +155,7 @@ export default function ServersPage() { )} > - + > {(state) => languageNames.of(state.selectedOption())} @@ -151,7 +175,7 @@ export default function ServersPage() { )} > - > + > {(state) => sortNameMap[state.selectedOption()]} @@ -167,20 +191,50 @@ export default function ServersPage() { users - + {(server) => { const StatChips = () => ( <> - Mbin {server.version} - - {languageNames.of(server.language)} + + Mbin {server.version} + + + + + + + This server is using an outdated version. Please ask the + server admin to upgrade or use a different server. + + + + + + + + No API + + + + + 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. + + + + + + {languageNames.of(server.api!.defaultLang)} + + {server.totalUsers} @@ -199,7 +253,7 @@ export default function ServersPage() { > {server.domain} @@ -254,48 +308,66 @@ export default function ServersPage() { - + No information available due to inaccessible api. + + } > - - {([page, pageContent]) => ( - - - - {pageNames[page as keyof Server['pages']]} - - - - - Email admin: {server.contactEmail} - - - {pageContent} - - - - )} - - - - - Defederated Servers ({server.defederated.length}) - - - - - {(server) => {server}} - - - - - - + + + {([page, pageContent]) => ( + + + + { + pageNames[ + page as keyof NonNullable< + Server['api'] + >['pages'] + ] + } + + + + + Email admin: {server.api!.contactEmail} + + + {pageContent} + + + + )} + + + + + Defederated Servers ( + {server.api!.defederated.length}) + + + + + {(server) => {server}} + + + + + + +
{app.description}