FEAT: initial setup and servers page

This commit is contained in:
John Wesley 2024-07-02 16:35:31 -04:00
parent ca3e55be5d
commit d3e53e7c47
26 changed files with 7773 additions and 0 deletions

28
.gitignore vendored Normal file
View file

@ -0,0 +1,28 @@
dist
.solid
.output
.vercel
.netlify
.vinxi
# Environment
.env
.env*.local
# dependencies
/node_modules
# IDEs and editors
/.idea
.project
.classpath
*.launch
.settings/
# Temp
gitignore
# System Files
.DS_Store
Thumbs.db

32
README.md Normal file
View file

@ -0,0 +1,32 @@
# SolidStart
Everything you need to build a Solid project, powered by [`solid-start`](https://start.solidjs.com);
## Creating a project
```bash
# create a new project in the current directory
npm init solid@latest
# create a new project in my-app
npm init solid@latest my-app
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```bash
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
Solid apps are built with _presets_, which optimise your project for deployment to different environments.
By default, `npm run build` will generate a Node app that you can run with `npm start`. To use a different preset, add it to the `devDependencies` in `package.json` and specify in your `app.config.js`.
## This project was created with the [Solid CLI](https://solid-cli.netlify.app)

8
app.config.ts Normal file
View file

@ -0,0 +1,8 @@
import { defineConfig } from '@solidjs/start/config';
export default defineConfig({
ssr: false,
// server: {
// static: true,
// },
});

29
package.json Normal file
View file

@ -0,0 +1,29 @@
{
"name": "example-with-tailwindcss",
"type": "module",
"scripts": {
"dev": "vinxi dev",
"build": "vinxi build",
"start": "vinxi start"
},
"dependencies": {
"@iconify-icon/solid": "^2.1.1",
"@kobalte/core": "^0.13.3",
"@solidjs/router": "^0.13.6",
"@solidjs/start": "^1.0.2",
"autoprefixer": "^10.4.19",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"postcss": "^8.4.38",
"solid-js": "^1.8.17",
"solid-markdown": "^2.0.13",
"tailwind-merge": "^2.3.0",
"tailwindcss": "^3.4.4",
"tailwindcss-animate": "^1.0.7",
"vinxi": "^0.3.12"
},
"engines": {
"node": ">=18"
},
"packageManager": "pnpm@9.4.0+sha512.f549b8a52c9d2b8536762f99c0722205efc5af913e77835dbccc3b0b0b2ca9e7dc8022b78062c17291c48e88749c70ce88eb5a74f1fa8c4bf5e18bb46c8bd83a"
}

6695
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load diff

6
postcss.config.cjs Normal file
View file

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 664 B

98
src/app.css Normal file
View file

@ -0,0 +1,98 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--card: 240 10% 3.9%;
--card-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--info: 204 94% 94%;
--info-foreground: 199 89% 48%;
--success: 149 80% 90%;
--success-foreground: 160 84% 39%;
--warning: 48 96% 89%;
--warning-foreground: 25 95% 53%;
--error: 0 93% 94%;
--error-foreground: 0 84% 60%;
--ring: 240 4.9% 83.9%;
--radius: 0.5rem;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
font-feature-settings: 'rlig' 1, 'calt' 1;
}
}
@media (max-width: 640px) {
.container {
@apply px-4;
}
}
body {
background: #020617;
color: rgb(255, 255, 255);
}
.markdown h1 {
@apply mt-2 text-5xl;
}
.markdown h2 {
@apply mt-2 text-4xl;
}
.markdown h3 {
@apply mt-2 text-3xl;
}
.markdown h4 {
@apply mt-2 text-2xl;
}
.markdown h5 {
@apply mt-2 text-xl;
}
.markdown h6 {
@apply mt-2 text-lg;
}
.markdown a {
@apply text-sky-600;
}
.markdown p {
@apply my-2;
}

20
src/app.tsx Normal file
View file

@ -0,0 +1,20 @@
import { Router } from "@solidjs/router";
import { FileRoutes } from "@solidjs/start/router";
import { Suspense } from "solid-js";
import Nav from "~/components/Nav";
import "./app.css";
export default function App() {
return (
<Router
root={props => (
<>
<Nav />
<Suspense>{props.children}</Suspense>
</>
)}
>
<FileRoutes />
</Router>
);
}

18
src/components/Chip.tsx Normal file
View file

@ -0,0 +1,18 @@
import { ParentComponent, Show } from 'solid-js';
import { Icon } from '@iconify-icon/solid';
const Chip: ParentComponent<{ icon?: string; title?: string }> = (props) => {
return (
<span
class="p-1 m-1 border rounded-lg inline-flex items-center"
title={props.title}
>
<Show when={props.icon}>
<Icon icon={props.icon!} class="pr-1" />
</Show>
{props.children}
</span>
);
};
export default Chip;

View file

@ -0,0 +1,10 @@
import { clientOnly } from '@solidjs/start';
import { ParentComponent } from 'solid-js';
const ClientOnlyComp = clientOnly(() => import('./MarkdownInner'));
const Markdown: ParentComponent = (props) => {
return <ClientOnlyComp>{props.children}</ClientOnlyComp>;
};
export default Markdown;

View file

@ -0,0 +1,11 @@
import { clientOnly } from '@solidjs/start';
import { ParentComponent } from 'solid-js';
import { SolidMarkdown } from 'solid-markdown';
const MarkdownInner: ParentComponent = (props) => {
return (
<SolidMarkdown class="text-left markdown">{props.children}</SolidMarkdown>
);
};
export default MarkdownInner;

21
src/components/Nav.tsx Normal file
View file

@ -0,0 +1,21 @@
import { useLocation } from '@solidjs/router';
export default function Nav() {
const location = useLocation();
const active = (path: string) =>
path == location.pathname
? 'border-sky-600'
: 'border-transparent hover:border-sky-600';
return (
<nav class="bg-sky-800">
<ul class="container flex items-center p-3 text-gray-200">
<li class={`border-b-2 ${active('/')} mx-1.5 sm:mx-6`}>
<a href="/">Home</a>
</li>
<li class={`border-b-2 ${active('/servers')} mx-1.5 sm:mx-6`}>
<a href="/servers">Servers</a>
</li>
</ul>
</nav>
);
}

View file

@ -0,0 +1,82 @@
import { JSX, splitProps, ValidComponent } from "solid-js"
import * as AccordionPrimitive from "@kobalte/core/accordion"
import { PolymorphicProps } from "@kobalte/core/polymorphic"
import { cn } from "~/lib/utils"
const Accordion = AccordionPrimitive.Root
type AccordionItemProps<T extends ValidComponent = "div"> =
AccordionPrimitive.AccordionItemProps<T> & {
class?: string | undefined
}
const AccordionItem = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, AccordionItemProps<T>>
) => {
const [local, others] = splitProps(props as AccordionItemProps, ["class"])
return <AccordionPrimitive.Item class={cn("border-b", local.class)} {...others} />
}
type AccordionTriggerProps<T extends ValidComponent = "button"> =
AccordionPrimitive.AccordionTriggerProps<T> & {
class?: string | undefined
children?: JSX.Element
}
const AccordionTrigger = <T extends ValidComponent = "button">(
props: PolymorphicProps<T, AccordionTriggerProps<T>>
) => {
const [local, others] = splitProps(props as AccordionTriggerProps, ["class", "children"])
return (
<AccordionPrimitive.Header class="flex">
<AccordionPrimitive.Trigger
class={cn(
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-expanded]>svg]:rotate-180",
local.class
)}
{...others}
>
{local.children}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="size-4 shrink-0 transition-transform duration-200"
>
<path d="M6 9l6 6l6 -6" />
</svg>
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
)
}
type AccordionContentProps<T extends ValidComponent = "div"> =
AccordionPrimitive.AccordionContentProps<T> & {
class?: string | undefined
children?: JSX.Element
}
const AccordionContent = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, AccordionContentProps<T>>
) => {
const [local, others] = splitProps(props as AccordionContentProps, ["class", "children"])
return (
<AccordionPrimitive.Content
class={cn(
"animate-accordion-up overflow-hidden text-sm transition-all data-[expanded]:animate-accordion-down",
local.class
)}
{...others}
>
<div class="pb-4 pt-0">{local.children}</div>
</AccordionPrimitive.Content>
)
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

View file

@ -0,0 +1,52 @@
import { JSX, splitProps, ValidComponent } from "solid-js"
import * as ButtonPrimitive from "@kobalte/core/button"
import { PolymorphicProps } from "@kobalte/core/polymorphic"
import type { VariantProps } from "class-variance-authority"
import { cva } from "class-variance-authority"
import { cn } from "~/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: "border border-input hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline"
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "size-10"
}
},
defaultVariants: {
variant: "default",
size: "default"
}
}
)
type ButtonProps<T extends ValidComponent = "button"> = ButtonPrimitive.ButtonRootProps<T> &
VariantProps<typeof buttonVariants> & { class?: string | undefined; children?: JSX.Element }
const Button = <T extends ValidComponent = "button">(
props: PolymorphicProps<T, ButtonProps<T>>
) => {
const [local, others] = splitProps(props as ButtonProps, ["variant", "size", "class"])
return (
<ButtonPrimitive.Root
class={cn(buttonVariants({ variant: local.variant, size: local.size }), local.class)}
{...others}
/>
)
}
export type { ButtonProps }
export { Button, buttonVariants }

View file

@ -0,0 +1,141 @@
import type { Component, ComponentProps, JSX, ValidComponent } from "solid-js"
import { splitProps } from "solid-js"
import * as DialogPrimitive from "@kobalte/core/dialog"
import { PolymorphicProps } from "@kobalte/core/polymorphic"
import { cn } from "~/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal: Component<DialogPrimitive.DialogPortalProps> = (props) => {
const [, rest] = splitProps(props, ["children"])
return (
<DialogPrimitive.Portal {...rest}>
<div class="fixed inset-0 z-50 flex items-start justify-center sm:items-center">
{props.children}
</div>
</DialogPrimitive.Portal>
)
}
type DialogOverlayProps<T extends ValidComponent = "div"> =
DialogPrimitive.DialogOverlayProps<T> & { class?: string | undefined }
const DialogOverlay = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, DialogOverlayProps<T>>
) => {
const [, rest] = splitProps(props as DialogOverlayProps, ["class"])
return (
<DialogPrimitive.Overlay
class={cn(
"fixed inset-0 z-50 bg-background/80 backdrop-blur-sm data-[expanded]:animate-in data-[closed]:animate-out data-[closed]:fade-out-0 data-[expanded]:fade-in-0",
props.class
)}
{...rest}
/>
)
}
type DialogContentProps<T extends ValidComponent = "div"> =
DialogPrimitive.DialogContentProps<T> & {
class?: string | undefined
children?: JSX.Element
}
const DialogContent = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, DialogContentProps<T>>
) => {
const [, rest] = splitProps(props as DialogContentProps, ["class", "children"])
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
class={cn(
"fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-background p-6 shadow-lg duration-200 data-[expanded]:animate-in data-[closed]:animate-out data-[closed]:fade-out-0 data-[expanded]:fade-in-0 data-[closed]:zoom-out-95 data-[expanded]:zoom-in-95 data-[closed]:slide-out-to-left-1/2 data-[closed]:slide-out-to-top-[48%] data-[expanded]:slide-in-from-left-1/2 data-[expanded]:slide-in-from-top-[48%] sm:rounded-lg",
props.class
)}
{...rest}
>
{props.children}
<DialogPrimitive.CloseButton class="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[expanded]:bg-accent data-[expanded]:text-muted-foreground">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="size-4"
>
<path d="M18 6l-12 12" />
<path d="M6 6l12 12" />
</svg>
<span class="sr-only">Close</span>
</DialogPrimitive.CloseButton>
</DialogPrimitive.Content>
</DialogPortal>
)
}
const DialogHeader: Component<ComponentProps<"div">> = (props) => {
const [, rest] = splitProps(props, ["class"])
return (
<div class={cn("flex flex-col space-y-1.5 text-center sm:text-left", props.class)} {...rest} />
)
}
const DialogFooter: Component<ComponentProps<"div">> = (props) => {
const [, rest] = splitProps(props, ["class"])
return (
<div
class={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", props.class)}
{...rest}
/>
)
}
type DialogTitleProps<T extends ValidComponent = "h2"> = DialogPrimitive.DialogTitleProps<T> & {
class?: string | undefined
}
const DialogTitle = <T extends ValidComponent = "h2">(
props: PolymorphicProps<T, DialogTitleProps<T>>
) => {
const [, rest] = splitProps(props as DialogTitleProps, ["class"])
return (
<DialogPrimitive.Title
class={cn("text-lg font-semibold leading-none tracking-tight", props.class)}
{...rest}
/>
)
}
type DialogDescriptionProps<T extends ValidComponent = "p"> =
DialogPrimitive.DialogDescriptionProps<T> & {
class?: string | undefined
}
const DialogDescription = <T extends ValidComponent = "p">(
props: PolymorphicProps<T, DialogDescriptionProps<T>>
) => {
const [, rest] = splitProps(props as DialogDescriptionProps, ["class"])
return (
<DialogPrimitive.Description
class={cn("text-sm text-muted-foreground", props.class)}
{...rest}
/>
)
}
export {
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription
}

4
src/entry-client.tsx Normal file
View file

@ -0,0 +1,4 @@
// @refresh reload
import { mount, StartClient } from "@solidjs/start/client";
mount(() => <StartClient />, document.getElementById("app")!);

21
src/entry-server.tsx Normal file
View file

@ -0,0 +1,21 @@
// @refresh reload
import { createHandler, StartServer } from "@solidjs/start/server";
export default createHandler(() => (
<StartServer
document={({ assets, children, scripts }) => (
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
{assets}
</head>
<body>
<div id="app">{children}</div>
{scripts}
</body>
</html>
)}
/>
));

1
src/global.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="@solidjs/start/env" />

7
src/lib/utils.ts Normal file
View file

@ -0,0 +1,7 @@
import type { ClassValue } from "clsx"
import { clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

25
src/routes/[...404].tsx Normal file
View file

@ -0,0 +1,25 @@
import { A } from "@solidjs/router";
export default function NotFound() {
return (
<main class="text-center mx-auto text-gray-700 p-4">
<h1 class="max-6-xs text-6xl text-sky-700 font-thin uppercase my-16">Not Found</h1>
<p class="mt-8">
Visit{" "}
<a href="https://solidjs.com" target="_blank" class="text-sky-600 hover:underline">
solidjs.com
</a>{" "}
to learn how to build Solid apps.
</p>
<p class="my-4">
<A href="/" class="text-sky-600 hover:underline">
Home
</A>
{" - "}
<A href="/about" class="text-sky-600 hover:underline">
About Page
</A>
</p>
</main>
);
}

14
src/routes/index.tsx Normal file
View file

@ -0,0 +1,14 @@
import { Button } from '~/components/ui/button';
export default function Home() {
return (
<main class="text-center mx-auto text-gray-700 p-4">
<h1 class="max-6-xs text-6xl text-sky-700 font-thin uppercase my-16">
Mbin
</h1>
<Button href="/servers" as="a">
Join a Server
</Button>
</main>
);
}

319
src/routes/servers.tsx Normal file
View file

@ -0,0 +1,319 @@
import { For, Show, createSignal } from 'solid-js';
import { createAsync, cache } from '@solidjs/router';
import Markdown from '~/components/Markdown';
import Chip from '~/components/Chip';
import { Button } from '~/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '~/components/ui/dialog';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '~/components/ui/accordion';
type Server = {
domain: string;
version: string;
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;
};
defederated: string[];
};
const pageNames: Required<Server['pages']> = {
about: 'About',
contact: 'Contact',
faq: 'Frequently Asked Questions',
privacyPolicy: 'Privacy Policy',
terms: 'Terms of Service',
};
const getServers = cache(async () => {
'use server';
const fediverseObserver = await (
await fetch('https://api.fediverse.observer/', {
body: '{"query":"{nodes(softwarename:\\"mbin\\" status: \\"UP\\"){domain}}"}',
method: 'POST',
})
).json();
const servers: string[] = fediverseObserver.data.nodes.map((v) => v.domain);
return (
await Promise.allSettled(
servers.map(async (serverHost) => {
console.log('START:', serverHost);
const jsonNodeInfo = await (
await fetch(`https://${serverHost}/nodeinfo/2.1.json`)
).json();
if (jsonNodeInfo.software.name != 'mbin') {
throw new Error(
`${serverHost} software does not match mbin (skipped)`,
);
}
const jsonApiInfo = await (
await fetch(`https://${serverHost}/api/info`)
).json();
if (jsonApiInfo.websiteDomain != serverHost) {
throw new Error(`${serverHost} api not setup correctly (skipped)`);
}
const jsonApiInstance = await (
await fetch(`https://${serverHost}/api/instance`)
).json();
const jsonApiDefederated = await (
await fetch(`https://${serverHost}/api/defederated`)
).json();
console.log('FINISH:', serverHost);
const server: Server = {
domain: serverHost,
version: jsonNodeInfo.software.version,
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 ?? [],
};
return server;
}),
)
)
.filter((v) => v.status == 'fulfilled')
.map((v) => v.value as Server);
}, 'users');
export const route = {
load: () => getServers(),
};
const languageNames = new Intl.DisplayNames(['en'], {
type: 'language',
});
export default function ServersPage() {
const servers = createAsync(() => getServers());
const [filterRegistration, setFilterRegistration] = createSignal(true);
const [langFilter, setLangFilter] = createSignal<string>('');
const resultServers = () => {
if (!servers()) return undefined;
return servers()!.filter(
(server) =>
(!filterRegistration() || server.openRegistrations) &&
(!langFilter() || server.language == langFilter()),
);
};
return (
<main class="text-center mx-auto text-gray-700 p-4 max-w-screen-xl">
<h1 class="max-6-xs text-6xl text-sky-600 font-extralight uppercase my-16">
Mbin Servers
</h1>
Open Registration Only:{' '}
<input
type="checkbox"
checked={filterRegistration()}
onChange={(e) => setFilterRegistration(e.target.checked)}
/>
<br />
Language:
<select onChange={(e) => setLangFilter(e.target.value)}>
<option value="">All Languages</option>
<For each={[...new Set(servers()?.map((v) => v.language))]}>
{(lang) => <option value={lang}>{languageNames.of(lang)}</option>}
</For>
</select>
<div
class="grid"
style={{
'grid-template-columns': 'repeat(auto-fit, minmax(350px, 1fr))',
}}
>
<For each={resultServers()}>
{(server) => {
const StatChips = () => (
<>
<Chip>Version {server.version}</Chip>
<Chip title="Language" icon="material-symbols:language">
{languageNames.of(server.language)}
</Chip>
<Chip title="Total users" icon="material-symbols:person">
{server.totalUsers}
</Chip>
<Chip title="Active users" icon="material-symbols:person-check">
{server.activeMonthUsers}
<span class="opacity-75">/month</span>
</Chip>
</>
);
return (
<div class="border rounded-lg p-4 m-4 text-white block relative">
<a
class="text-3xl text-sky-600 block text-center"
href={`https://${server.domain}`}
>
<img
src={`https://${server.domain}/favicon.ico`}
class="inline-block size-16"
/>
<br />
{server.domain}
</a>
<Show when={server.name != '' && server.name != server.domain}>
<div class="text-2xl text-sky-600 font-light">
{server.name}
</div>
</Show>
<p>{server.description}</p>
<StatChips />
<Dialog>
<DialogTrigger
as={Button<'button'>}
variant="outline"
class="my-1 w-full"
>
More info
</DialogTrigger>
<DialogContent class="sm:max-w-screen-xl max-h-[95%] overflow-scroll">
<DialogHeader>
<DialogTitle>{server.domain}</DialogTitle>
<DialogDescription>
{server.description}
</DialogDescription>
</DialogHeader>
<div>
<StatChips />
<Chip
title="Active users"
icon="material-symbols:person-check"
>
{server.activeHalfyearUsers}
<span class="opacity-75">/halfyear</span>
</Chip>
<Chip title="Local posts" icon="material-symbols:news">
{server.localPosts}
</Chip>
<Chip
title="Local comments"
icon="material-symbols:comment"
>
{server.localComments}
</Chip>
</div>
<Accordion
multiple={false}
collapsible
defaultValue={['about']}
>
<For each={Object.entries(server.pages)}>
{([page, pageContent]) => (
<Show when={pageContent || page === 'contact'}>
<AccordionItem value={page}>
<AccordionTrigger>
{pageNames[page as keyof Server['pages']]}
</AccordionTrigger>
<AccordionContent>
<Show when={page == 'contact'}>
<Button
href={`mailto:${server.contactEmail}`}
as="a"
>
Email admin: {server.contactEmail}
</Button>
</Show>
<Markdown>{pageContent}</Markdown>
</AccordionContent>
</AccordionItem>
</Show>
)}
</For>
<Show when={server.defederated.length}>
<AccordionItem value="defederated">
<AccordionTrigger>
Defederated Servers ({server.defederated.length})
</AccordionTrigger>
<AccordionContent>
<For each={server.defederated}>
{(server) => <Chip>{server}</Chip>}
</For>
</AccordionContent>
</AccordionItem>
</Show>
</Accordion>
<Show when={server.openRegistrations}>
<DialogFooter>
<Button
href={`https://${server.domain}/register`}
as="a"
>
Join
</Button>
</DialogFooter>
</Show>
</DialogContent>
</Dialog>
<Show when={server.openRegistrations}>
<Button
href={`https://${server.domain}/register`}
as="a"
variant="default"
class="my-1 w-full"
>
Join
</Button>
</Show>
</div>
);
}}
</For>
</div>
</main>
);
}

97
tailwind.config.cjs Normal file
View file

@ -0,0 +1,97 @@
/**@type {import("tailwindcss").Config} */
module.exports = {
content: ['./src/**/*.{ts,tsx}'],
theme: {
container: {
center: true,
padding: '2rem',
screens: {
'2xl': '1400px',
},
},
extend: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
info: {
DEFAULT: 'hsl(var(--info))',
foreground: 'hsl(var(--info-foreground))',
},
success: {
DEFAULT: 'hsl(var(--success))',
foreground: 'hsl(var(--success-foreground))',
},
warning: {
DEFAULT: 'hsl(var(--warning))',
foreground: 'hsl(var(--warning-foreground))',
},
error: {
DEFAULT: 'hsl(var(--error))',
foreground: 'hsl(var(--error-foreground))',
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))',
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
},
borderRadius: {
xl: 'calc(var(--radius) + 4px)',
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
keyframes: {
'accordion-down': {
from: { height: 0 },
to: { height: 'var(--kb-accordion-content-height)' },
},
'accordion-up': {
from: { height: 'var(--kb-accordion-content-height)' },
to: { height: 0 },
},
'content-show': {
from: { opacity: 0, transform: 'scale(0.96)' },
to: { opacity: 1, transform: 'scale(1)' },
},
'content-hide': {
from: { opacity: 1, transform: 'scale(1)' },
to: { opacity: 0, transform: 'scale(0.96)' },
},
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out',
'content-show': 'content-show 0.2s ease-out',
'content-hide': 'content-hide 0.2s ease-out',
},
},
},
plugins: [require('tailwindcss-animate')],
};

23
tsconfig.json Normal file
View file

@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"jsx": "preserve",
"jsxImportSource": "solid-js",
"allowJs": true,
"noEmit": true,
"strict": true,
"types": [
"vinxi/types/client"
],
"isolatedModules": true,
"paths": {
"~/*": [
"./src/*"
]
}
}
}

11
ui.config.json Normal file
View file

@ -0,0 +1,11 @@
{
"tsx": true,
"componentDir": "./src/components/ui",
"tailwind": {
"config": "tailwind.config.cjs",
"css": "src/app.css"
},
"aliases": {
"path": "~/*"
}
}