🎉 Reinital Commit

This commit is contained in:
LittleSheep 2024-03-02 12:29:16 +08:00
parent 1e04f2029f
commit 178f80c707
91 changed files with 328 additions and 3447 deletions

View File

@ -5,7 +5,7 @@ RUN apk add nodejs npm
WORKDIR /source
COPY . .
WORKDIR /source/pkg/view
WORKDIR /source/pkg/views
RUN npm install
RUN npm run build
WORKDIR /source

View File

@ -5,7 +5,7 @@ import (
"strings"
"time"
"code.smartsheep.studio/hydrogen/interactive/pkg/view"
"code.smartsheep.studio/hydrogen/identity/pkg/views"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/cache"
"github.com/gofiber/fiber/v2/middleware/cors"
@ -99,7 +99,7 @@ func NewServer() {
Expiration: 24 * time.Hour,
CacheControl: true,
}), filesystem.New(filesystem.Config{
Root: http.FS(view.FS),
Root: http.FS(views.FS),
PathPrefix: "dist",
Index: "index.html",
NotFoundFile: "dist/index.html",

7
pkg/view/.gitignore vendored
View File

@ -1,7 +0,0 @@
/dist
/node_modules
.DS_Store
package-lock.json
yarn.lock

View File

@ -1,28 +0,0 @@
## Usage
```bash
$ npm install # or pnpm install or yarn install
```
### Learn more on the [Solid Website](https://solidjs.com) and come chat with us on our [Discord](https://discord.com/invite/solidjs)
## Available Scripts
In the project directory, you can run:
### `npm run dev`
Runs the app in the development mode.<br>
Open [http://localhost:5173](http://localhost:5173) to view it in the browser.
### `npm run build`
Builds the app for production to the `dist` folder.<br>
It correctly bundles Solid in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.<br>
Your app is ready to be deployed!
## Deployment
Learn more about deploying your application with the [documentations](https://vitejs.dev/guide/static-deploy.html)

View File

@ -1,6 +0,0 @@
package view
import "embed"
//go:embed all:dist
var FS embed.FS

View File

@ -1,27 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Embedded Interactive</title>
<style>
body, html {
padding: 0;
margin: 0;
}
iframe {
width: 100vw;
height: 100vh;
display: block;
border: 0;
}
</style>
</head>
<body>
<iframe src="http://localhost:8445/realms/1?noTitle=1"></iframe>
</body>
</html>

View File

@ -1,13 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Goatplaza</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>

View File

@ -1,36 +0,0 @@
{
"name": "@hydrogen/interactive-web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@fortawesome/fontawesome-free": "^6.5.1",
"@solidjs/router": "^0.10.10",
"artplayer": "^5.1.1",
"cherry-markdown": "^0.8.38",
"dompurify": "^3.0.8",
"flv.js": "^1.6.2",
"hls.js": "^1.5.3",
"marked": "^12.0.0",
"medium-zoom": "^1.1.0",
"solid-js": "^1.8.7",
"universal-cookie": "^7.0.2"
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.10",
"@types/dompurify": "^3.0.5",
"autoprefixer": "^10.4.17",
"daisyui": "^4.6.1",
"postcss": "^8.4.33",
"solid-devtools": "^0.29.3",
"tailwindcss": "^3.4.1",
"typescript": "^5.2.2",
"vite": "^5.0.8",
"vite-plugin-solid": "^2.8.0"
}
}

View File

@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 12 KiB

View File

@ -1,5 +0,0 @@
{
"printWidth": 120,
"tabWidth": 2,
"singleQuote": false
}

View File

@ -1,184 +0,0 @@
:root {
--bs-body-font-family: "IBM Plex Sans", "Noto Serif SC", sans-serif !important;
}
html,
body {
font-family: var(--bs-body-font-family);
}
/* ibm-plex-sans-100 - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'IBM Plex Sans';
font-style: normal;
font-weight: 100;
src: url('./ibm-plex-sans-v19-latin-100.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* ibm-plex-sans-100italic - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'IBM Plex Sans';
font-style: italic;
font-weight: 100;
src: url('./ibm-plex-sans-v19-latin-100italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* ibm-plex-sans-200 - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'IBM Plex Sans';
font-style: normal;
font-weight: 200;
src: url('./ibm-plex-sans-v19-latin-200.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* ibm-plex-sans-200italic - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'IBM Plex Sans';
font-style: italic;
font-weight: 200;
src: url('./ibm-plex-sans-v19-latin-200italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* ibm-plex-sans-300 - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'IBM Plex Sans';
font-style: normal;
font-weight: 300;
src: url('./ibm-plex-sans-v19-latin-300.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* ibm-plex-sans-300italic - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'IBM Plex Sans';
font-style: italic;
font-weight: 300;
src: url('./ibm-plex-sans-v19-latin-300italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* ibm-plex-sans-regular - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'IBM Plex Sans';
font-style: normal;
font-weight: 400;
src: url('./ibm-plex-sans-v19-latin-regular.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* ibm-plex-sans-italic - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'IBM Plex Sans';
font-style: italic;
font-weight: 400;
src: url('./ibm-plex-sans-v19-latin-italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* ibm-plex-sans-500 - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'IBM Plex Sans';
font-style: normal;
font-weight: 500;
src: url('./ibm-plex-sans-v19-latin-500.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* ibm-plex-sans-500italic - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'IBM Plex Sans';
font-style: italic;
font-weight: 500;
src: url('./ibm-plex-sans-v19-latin-500italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* ibm-plex-sans-600 - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'IBM Plex Sans';
font-style: normal;
font-weight: 600;
src: url('./ibm-plex-sans-v19-latin-600.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* ibm-plex-sans-600italic - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'IBM Plex Sans';
font-style: italic;
font-weight: 600;
src: url('./ibm-plex-sans-v19-latin-600italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* ibm-plex-sans-700 - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'IBM Plex Sans';
font-style: normal;
font-weight: 700;
src: url('./ibm-plex-sans-v19-latin-700.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* ibm-plex-sans-700italic - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'IBM Plex Sans';
font-style: italic;
font-weight: 700;
src: url('./ibm-plex-sans-v19-latin-700italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* noto-serif-sc-200 - chinese-simplified */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: "Noto Serif SC";
font-style: normal;
font-weight: 200;
src: url("./noto-serif-sc-v22-chinese-simplified-200.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* noto-serif-sc-300 - chinese-simplified */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: "Noto Serif SC";
font-style: normal;
font-weight: 300;
src: url("./noto-serif-sc-v22-chinese-simplified-300.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* noto-serif-sc-regular - chinese-simplified */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: "Noto Serif SC";
font-style: normal;
font-weight: 400;
src: url("./noto-serif-sc-v22-chinese-simplified-regular.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* noto-serif-sc-500 - chinese-simplified */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: "Noto Serif SC";
font-style: normal;
font-weight: 500;
src: url("./noto-serif-sc-v22-chinese-simplified-500.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* noto-serif-sc-600 - chinese-simplified */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: "Noto Serif SC";
font-style: normal;
font-weight: 600;
src: url("./noto-serif-sc-v22-chinese-simplified-600.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* noto-serif-sc-700 - chinese-simplified */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: "Noto Serif SC";
font-style: normal;
font-weight: 700;
src: url("./noto-serif-sc-v22-chinese-simplified-700.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* noto-serif-sc-900 - chinese-simplified */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: "Noto Serif SC";
font-style: normal;
font-weight: 900;
src: url("./noto-serif-sc-v22-chinese-simplified-900.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}

View File

@ -1,22 +0,0 @@
import { Show } from "solid-js";
export default function Avatar(props: { user: any }) {
return (
<Show
when={props.user?.avatar}
fallback={
<div class="avatar placeholder">
<div class="w-12 h-12 bg-neutral text-neutral-content">
<span class="text-xl uppercase">{props.user?.name?.substring(0, 1)}</span>
</div>
</div>
}
>
<div class="avatar">
<div class="w-12">
<img alt="avatar" src={props.user?.avatar} />
</div>
</div>
</Show>
);
}

View File

@ -1,8 +0,0 @@
export default function LoadingAnimation() {
return (
<div class="w-full border-b border-base-200 pt-5 pb-7 text-center">
<p class="loading loading-lg loading-infinity"></p>
<p>Listening to the latest news...</p>
</div>
)
}

View File

@ -1,3 +0,0 @@
.description {
color: var(--fallback-bc, oklch(var(--bc)/.8));
}

View File

@ -1,98 +0,0 @@
import { createSignal, Show } from "solid-js";
import styles from "./NameCard.module.css";
import { getAtk } from "../stores/userinfo.tsx";
import { request } from "../scripts/request.ts";
export default function NameCard(props: { accountId: string, onError: (messasge: string | null) => void }) {
const [info, setInfo] = createSignal<any>(null);
const [isFollowing, setIsFollowing] = createSignal(false);
const [_, setLoading] = createSignal(true);
const [submitting, setSubmitting] = createSignal(false);
async function readInfo() {
setLoading(true);
const res = await request(`/api/users/${props.accountId}`);
if (res.status !== 200) {
props.onError(await res.text());
} else {
setInfo(await res.json());
props.onError(null);
}
setLoading(false);
}
async function readIsFollowing() {
setLoading(true);
const res = await request(`/api/users/${props.accountId}/follow`, {
method: "GET",
headers: { Authorization: `Bearer ${getAtk()}` }
});
if (res.status === 200) {
const data = await res.json();
setIsFollowing(data["is_followed"]);
}
setLoading(false);
}
async function follow() {
setSubmitting(true);
const res = await request(`/api/users/${props.accountId}/follow`, {
method: "POST",
headers: { "Authorization": `Bearer ${getAtk()}` }
});
if (res.status !== 201 && res.status !== 204) {
props.onError(await res.text());
} else {
await readIsFollowing();
props.onError(null);
}
setSubmitting(false);
}
readInfo();
readIsFollowing();
return (
<div class="relative">
<figure id="banner">
<img class="object-cover w-full h-36" src="https://images.unsplash.com/photo-1464822759023-fed622ff2c3b"
alt="banner" />
</figure>
<div id="avatar" class="avatar absolute border-4 border-base-200 left-[20px] top-[4.5rem]">
<div class="w-24">
<img src={info()?.avatar} alt="avatar" />
</div>
</div>
<div id="actions" class="flex justify-end">
<div>
<Show when={isFollowing()} fallback={
<button type="button" class="btn btn-primary" disabled={submitting()} onClick={() => follow()}>
<i class="fa-solid fa-plus"></i>
Follow
</button>
}>
<button type="button" class="btn btn-accent" disabled={submitting()} onClick={() => follow()}>
<i class="fa-solid fa-check"></i>
Followed
</button>
</Show>
</div>
</div>
<div id="description" class="px-6 pb-7">
<h2 class="text-2xl font-bold">{info()?.name}</h2>
<p class="text-md">{info()?.description}</p>
<div class={`mt-2 ${styles.description}`}>
<p class="text-xs">
<i class="fa-solid fa-calendar-days me-2"></i>
Joined at {new Date(info()?.created_at).toLocaleString()}
</p>
</div>
</div>
</div>
);
}

View File

@ -1,3 +0,0 @@
.attachmentsControl {
background-color: transparent !important;
}

View File

@ -1,158 +0,0 @@
import { createEffect, createMemo, createSignal, Match, Switch } from "solid-js";
import mediumZoom from "medium-zoom";
import styles from "./PostAttachments.module.css";
import Artplayer from "artplayer";
import HlsJs from "hls.js";
import FlvJs from "flv.js";
function Video({ url, ...rest }: any) {
let container: any;
function playM3u8(video: HTMLVideoElement, url: string, art: Artplayer) {
if (HlsJs.isSupported()) {
if (art.hls) art.hls.destroy();
const hls = new HlsJs();
hls.loadSource(url);
hls.attachMedia(video);
art.hls = hls;
art.on("destroy", () => hls.destroy());
} else if (video.canPlayType("application/vnd.apple.mpegurl")) {
video.src = url;
} else {
art.notice.show = "Unsupported playback format: m3u8";
}
}
function playFlv(video: HTMLVideoElement, url: string, art: Artplayer) {
if (FlvJs.isSupported()) {
if (art.flv) art.flv.destroy();
const flv = FlvJs.createPlayer({ type: "flv", url });
flv.attachMediaElement(video);
flv.load();
art.flv = flv;
art.on("destroy", () => flv.destroy());
} else {
art.notice.show = "Unsupported playback format: flv";
}
}
createEffect(() => {
new Artplayer({
container: container as HTMLDivElement,
url: url,
setting: true,
flip: true,
loop: true,
playbackRate: true,
aspectRatio: true,
subtitleOffset: true,
fullscreen: true,
fullscreenWeb: true,
theme: "#49509e",
customType: {
m3u8: playM3u8,
flv: playFlv
}
});
});
return (
<div ref={container} {...rest}></div>
);
}
function Audio({ url, caption, ...rest }: any) {
return (
<figure {...rest}>
<figcaption>{caption}</figcaption>
<audio controls src={url} />
</figure>
);
}
export default function PostAttachments(props: { attachments: any[] }) {
if (props.attachments.length <= 0) return null;
const [focus, setFocus] = createSignal(0);
const item = createMemo(() => props.attachments[focus()]);
function getRenderType(item: any): string {
return item.mimetype.split("/")[0];
}
function getUrl(item: any): string {
return item.external_url ? item.external_url : `/api/attachments/o/${item.file_id}`;
}
createEffect(() => {
mediumZoom(document.querySelectorAll(".attachment-image img"), {
background: "var(--fallback-b1,oklch(var(--b1)/1))"
});
}, [focus()]);
return (
<>
<p class="text-xs mt-3 mb-2">
<i class="fa-solid fa-paperclip me-2"></i>
Attached {props.attachments.length} file{props.attachments.length > 1 ? "s" : null}
</p>
<div class="border border-base-200">
<Switch fallback={
<div class="py-16 flex justify-center items-center">
<div class="text-center">
<i class="fa-solid fa-circle-question text-3xl"></i>
<p class="mt-3">{item().filename}</p>
<div class="flex gap-2 w-full">
<p class="text-sm">{item().filesize <= 0 ? "Unknown" : item().filesize} Bytes</p>
<p class="text-sm">{item().mimetype}</p>
</div>
<div class="mt-5">
<a class="link" href={getUrl(item())} target="_blank">Open in browser</a>
</div>
</div>
</div>
}>
<Match when={getRenderType(item()) === "image"}>
<figure class="attachment-image">
<img class="object-cover" src={getUrl(item())} alt={item().filename} />
</figure>
</Match>
<Match when={getRenderType(item()) === "audio"}>
<Audio class="p-5 flex flex-col items-center justify-center gap-2 w-full" url={getUrl(item())}
caption={item().filename} />
</Match>
<Match when={getRenderType(item()) === "video"}>
<Video class="h-[360px] w-full" url={getUrl(item())} caption={item().filename} />
</Match>
</Switch>
<div id="attachments-control" class="flex justify-between border-t border-base-200">
<div class="flex">
<button class={`w-12 h-12 btn btn-ghost ${styles.attachmentsControl}`}
disabled={focus() - 1 < 0}
onClick={() => setFocus(focus() - 1)}>
<i class="fa-solid fa-caret-left"></i>
</button>
<button class={`w-12 h-12 btn btn-ghost ${styles.attachmentsControl}`}
disabled={focus() + 1 >= props.attachments.length}
onClick={() => setFocus(focus() + 1)}>
<i class="fa-solid fa-caret-right"></i>
</button>
</div>
<div>
<div class="h-12 px-5 py-3.5 text-sm">
File {focus() + 1}
</div>
</div>
</div>
</div>
</>
);
}

View File

@ -1,399 +0,0 @@
import { closeModel, openModel } from "../../scripts/modals.ts";
import { createSignal, For, Match, Show, Switch } from "solid-js";
import { getAtk, useUserinfo } from "../../stores/userinfo.tsx";
import { request } from "../../scripts/request.ts";
import styles from "./PostPublish.module.css";
export default function PostEditActions(props: {
editing?: any;
onInputAlias: (value: string) => void;
onInputPublish: (value: string) => void;
onInputAttachments: (value: any[]) => void;
onInputCategories: (categories: any[]) => void;
onInputTags: (tags: any[]) => void;
onError: (message: string | null) => void;
}) {
const userinfo = useUserinfo();
const [uploading, setUploading] = createSignal(false);
const [attachments, setAttachments] = createSignal<any[]>(props.editing?.attachments ?? []);
const [categories, setCategories] = createSignal<{ alias: string; name: string }[]>(props.editing?.categories ?? []);
const [tags, setTags] = createSignal<{ alias: string; name: string }[]>(props.editing?.tags ?? []);
const [availableCategories, setAvailableCategories] = createSignal<any[]>([]);
const [attachmentMode, setAttachmentMode] = createSignal(0);
async function readCategories() {
const res = await request("/api/categories");
if (res.status === 200) {
setAvailableCategories(await res.json());
}
}
readCategories();
async function uploadAttachment(evt: SubmitEvent) {
evt.preventDefault();
const form = evt.target as HTMLFormElement;
const data = new FormData(form);
if (!data.get("attachment")) return;
setUploading(true);
const res = await request("/api/attachments", {
method: "POST",
headers: { Authorization: `Bearer ${getAtk()}` },
body: data,
});
if (res.status !== 200) {
props.onError(await res.text());
} else {
const data = await res.json();
setAttachments(attachments().concat([data.info]));
props.onInputAttachments(attachments());
props.onError(null);
form.reset();
}
setUploading(false);
}
function addAttachment(evt: SubmitEvent) {
evt.preventDefault();
const form = evt.target as HTMLFormElement;
const data = Object.fromEntries(new FormData(form));
setAttachments(
attachments().concat([
{
...data,
author_id: userinfo?.profiles?.id,
},
]),
);
props.onInputAttachments(attachments());
form.reset();
}
function removeAttachment(idx: number) {
const data = attachments().slice();
data.splice(idx, 1);
setAttachments(data);
props.onInputAttachments(attachments());
}
function addCategory(evt: SubmitEvent) {
evt.preventDefault();
const form = evt.target as HTMLFormElement;
const data = Object.fromEntries(new FormData(form));
if (!data.category) return;
const item = availableCategories().find((item) => item.alias === data.category);
setCategories(categories().concat([item]));
props.onInputCategories(categories());
form.reset();
}
function removeCategory(idx: number) {
const data = categories().slice();
data.splice(idx, 1);
setCategories(data);
props.onInputCategories(categories());
}
function addTag(evt: SubmitEvent) {
evt.preventDefault();
const form = evt.target as HTMLFormElement;
const data = Object.fromEntries(new FormData(evt.target as HTMLFormElement));
if (!data.alias) data.alias = crypto.randomUUID().replace(/-/g, "");
if (!data.name) return;
setTags(tags().concat([data as any]));
props.onInputTags(tags());
form.reset();
}
function removeTag(idx: number) {
const data = tags().slice();
data.splice(idx, 1);
setTags(data);
props.onInputTags(tags());
}
return (
<>
<div class="flex pl-[20px]">
<button type="button" class="btn btn-ghost w-12" onClick={() => openModel("#alias")}>
<i class="fa-solid fa-link"></i>
</button>
<button type="button" class="btn btn-ghost w-12" onClick={() => openModel("#attachments")}>
<i class="fa-solid fa-paperclip"></i>
</button>
<button type="button" class="btn btn-ghost w-12" onClick={() => openModel("#planning-publish")}>
<i class="fa-solid fa-calendar-day"></i>
</button>
<button type="button" class="btn btn-ghost w-12" onClick={() => openModel("#categories-and-tags")}>
<i class="fa-solid fa-tag"></i>
</button>
</div>
<dialog id="alias" class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg mx-1">Permalink</h3>
<label class="form-control w-full mt-3">
<div class="label">
<span class="label-text">Alias</span>
</div>
<input
name="alias"
type="text"
placeholder="Type here"
class="input input-bordered w-full"
value={props.editing?.alias ?? ""}
onInput={(evt) => props.onInputAlias(evt.target.value)}
/>
<div class="label">
<span class="label-text-alt">Leave blank to generate a random string.</span>
</div>
</label>
<div class="modal-action">
<button type="button" class="btn" onClick={() => closeModel("#alias")}>
Close
</button>
</div>
</div>
</dialog>
<dialog id="planning-publish" class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg mx-1">Planning Publish</h3>
<label class="form-control w-full mt-3">
<div class="label">
<span class="label-text">Published At</span>
</div>
<input
name="published_at"
type="datetime-local"
placeholder="Pick a date"
class="input input-bordered w-full"
value={props.editing?.published_at ?? ""}
onInput={(evt) => props.onInputAlias(evt.target.value)}
/>
<div class="label">
<span class="label-text-alt">
Before this time, your post will not be visible for everyone. You can modify this plan on Creator Hub.
</span>
</div>
</label>
<div class="modal-action">
<button type="button" class="btn" onClick={() => closeModel("#planning-publish")}>
Close
</button>
</div>
</div>
</dialog>
<dialog id="attachments" class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg mx-1">Attachments</h3>
<div role="tablist" class="tabs tabs-boxed mt-3">
<input
type="radio"
name="attachment"
role="tab"
class="tab"
aria-label="File picker"
checked={attachmentMode() === 0}
onClick={() => setAttachmentMode(0)}
/>
<input
type="radio"
name="attachment"
role="tab"
class="tab"
aria-label="External link"
checked={attachmentMode() === 1}
onClick={() => setAttachmentMode(1)}
/>
</div>
<Switch>
<Match when={attachmentMode() === 0}>
<form class="w-full mt-2" onSubmit={uploadAttachment}>
<label class="form-control">
<div class="label">
<span class="label-text">Pick a file</span>
</div>
<div class="join">
<input
required
type="file"
name="attachment"
class="join-item file-input file-input-bordered w-full"
/>
<button type="submit" class="join-item btn btn-primary" disabled={uploading()}>
<i class="fa-solid fa-upload"></i>
</button>
</div>
<div class="label">
<span class="label-text-alt">Click upload to add this file into list</span>
</div>
</label>
</form>
</Match>
<Match when={attachmentMode() === 1}>
<form class="w-full mt-2" onSubmit={addAttachment}>
<label class="form-control">
<div class="label">
<span class="label-text">Attach an external file</span>
</div>
<div class="join">
<input
required
type="text"
name="mimetype"
class="join-item input input-bordered w-full"
placeholder="Mimetype"
/>
<input
required
type="text"
name="filename"
class="join-item input input-bordered w-full"
placeholder="Name"
/>
</div>
<div class="join">
<input
required
type="text"
name="external_url"
class="join-item input input-bordered w-full"
placeholder="External URL"
/>
<button type="submit" class="join-item btn btn-primary">
<i class="fa-solid fa-plus"></i>
</button>
</div>
<div class="label">
<span class="label-text-alt">Click add button to add it into list</span>
</div>
</label>
</form>
</Match>
</Switch>
<Show when={attachments().length > 0}>
<h3 class="font-bold mt-3 mx-1">Attachment list</h3>
<ol class="mt-2 mx-1 text-sm">
<For each={attachments()}>
{(item, idx) => (
<li>
<i class="fa-regular fa-file me-2"></i>
{item.filename}
<button class="ml-2" onClick={() => removeAttachment(idx())}>
<i class="fa-solid fa-delete-left"></i>
</button>
</li>
)}
</For>
</ol>
</Show>
<div class="modal-action">
<button type="button" class="btn" onClick={() => closeModel("#attachments")}>
Close
</button>
</div>
</div>
</dialog>
<dialog id="categories-and-tags" class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg mx-1">Categories & Tags</h3>
<form class="w-full mt-3" onSubmit={addCategory}>
<label class="form-control">
<div class="label">
<span class="label-text">Add a category</span>
</div>
<div class="join">
<select name="category" class="join-item select select-bordered w-full">
<For each={availableCategories()}>{(item) => <option value={item.alias}>{item.name}</option>}</For>
</select>
<button type="submit" class="join-item btn btn-primary">
<i class="fa-solid fa-plus"></i>
</button>
</div>
</label>
</form>
<Show when={categories().length > 0}>
<h3 class="font-bold mt-3 mx-1">Category list</h3>
<ol class="mt-2 mx-1 text-sm">
<For each={categories()}>
{(item, idx) => (
<li>
<i class="fa-solid fa-layer-group me-2"></i>
{item.name} <span class={styles.description}>#{item.alias}</span>
<button class="ml-2" onClick={() => removeCategory(idx())}>
<i class="fa-solid fa-delete-left"></i>
</button>
</li>
)}
</For>
</ol>
</Show>
<form class="w-full mt-3" onSubmit={addTag}>
<label class="form-control">
<div class="label">
<span class="label-text">Add a tag</span>
</div>
<div class="join">
<input type="text" name="alias" placeholder="Alias" class="join-item input input-bordered w-full" />
<input type="text" name="name" placeholder="Name" class="join-item input input-bordered w-full" />
<button type="submit" class="join-item btn btn-primary">
<i class="fa-solid fa-plus"></i>
</button>
</div>
<div class="label">
<span class="label-text-alt">
Alias is the url key of this tag. Lowercase only, required length 4-24. Leave blank for auto generate.
</span>
</div>
</label>
</form>
<Show when={tags().length > 0}>
<h3 class="font-bold mt-3 mx-1">Category list</h3>
<ol class="mt-2 mx-1 text-sm">
<For each={tags()}>
{(item, idx) => (
<li>
<i class="fa-solid fa-tag me-2"></i>
{item.name} <span class={styles.description}>#{item.alias}</span>
<button class="ml-2" onClick={() => removeTag(idx())}>
<i class="fa-solid fa-delete-left"></i>
</button>
</li>
)}
</For>
</ol>
</Show>
<div class="modal-action">
<button type="button" class="btn" onClick={() => closeModel("#categories-and-tags")}>
Close
</button>
</div>
</div>
</dialog>
</>
);
}

View File

@ -1,223 +0,0 @@
import { createEffect, createMemo, createSignal, For, onMount, Show } from "solid-js";
import Cherry from "cherry-markdown";
import "cherry-markdown/dist/cherry-markdown.min.css";
import { getAtk } from "../../stores/userinfo.tsx";
import { request } from "../../scripts/request.ts";
import PostEditActions from "./PostEditActions.tsx";
export default function PostEditor(props: {
editing?: any,
onError: (message: string | null) => void,
onPost: () => void
}) {
let editorContainer: any;
const [editor, setEditor] = createSignal<Cherry>();
const [realmList, setRealmList] = createSignal<any[]>([]);
const [submitting, setSubmitting] = createSignal(false);
const [alias, setAlias] = createSignal("");
const [publishedAt, setPublishedAt] = createSignal("");
const [attachments, setAttachments] = createSignal<any[]>([]);
const [categories, setCategories] = createSignal<{ alias: string, name: string }[]>([]);
const [tags, setTags] = createSignal<{ alias: string, name: string }[]>([]);
const theme = createMemo(() => {
if (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches) {
return "dark";
} else {
return "light";
}
});
createEffect(() => {
editor()?.setTheme(theme());
}, [editor(), theme()]);
onMount(() => {
if (editorContainer) {
setEditor(new Cherry({
el: editorContainer,
value: "Welcome to the creator hub! " +
"We provide a better editor than normal mode for you! " +
"So you can tell us your mind clearly. " +
"Delete this paragraph and getting start!"
}));
}
});
createEffect(() => {
setAttachments(props.editing?.attachments ?? []);
setCategories(props.editing?.categories ?? []);
setTags(props.editing?.tags ?? []);
editor()?.setValue(props.editing?.content);
}, [props.editing]);
async function listRealm() {
const res = await request("/api/realms/me/available", {
headers: { "Authorization": `Bearer ${getAtk()}` }
});
if (res.status === 200) {
setRealmList(await res.json());
}
}
listRealm();
async function doPost(evt: SubmitEvent) {
evt.preventDefault();
const form = evt.target as HTMLFormElement;
const data = Object.fromEntries(new FormData(form));
if (!editor()?.getValue()) return;
setSubmitting(true);
const res = await request("/api/posts", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${getAtk()}`
},
body: JSON.stringify({
alias: alias() ? alias() : crypto.randomUUID().replace(/-/g, ""),
title: data.title,
content: editor()?.getValue(),
attachments: attachments(),
categories: categories(),
tags: tags(),
realm_id: parseInt(data.realm as string) !== 0 ? parseInt(data.realm as string) : undefined,
published_at: publishedAt() ? new Date(publishedAt()) : new Date()
})
});
if (res.status !== 200) {
props.onError(await res.text());
} else {
form.reset();
props.onError(null);
props.onPost();
}
setSubmitting(false);
}
async function doEdit(evt: SubmitEvent) {
evt.preventDefault();
const form = evt.target as HTMLFormElement;
const data = Object.fromEntries(new FormData(form));
if (!editor()?.getValue()) return;
setSubmitting(true);
const res = await request(`/api/posts/${props.editing?.id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${getAtk()}`
},
body: JSON.stringify({
alias: alias() ? alias() : crypto.randomUUID().replace(/-/g, ""),
title: data.title,
content: editor()?.getValue(),
attachments: attachments(),
categories: categories(),
tags: tags(),
published_at: publishedAt() ? new Date(publishedAt()) : new Date()
})
});
if (res.status !== 200) {
props.onError(await res.text());
} else {
form.reset();
props.onError(null);
props.onPost();
}
setSubmitting(false);
}
function resetForm() {
setAttachments([]);
setCategories([]);
setTags([]);
}
return (
<form onReset={resetForm} onSubmit={(evt) => props.editing ? doEdit(evt) : doPost(evt)}>
<div>
<div ref={editorContainer}></div>
</div>
<div class="border-y border-base-200">
<PostEditActions
editing={props.editing}
onInputAlias={setAlias}
onInputPublish={setPublishedAt}
onInputAttachments={setAttachments}
onInputCategories={setCategories}
onInputTags={setTags}
onError={props.onError}
/>
</div>
<div class="pt-3 pb-7 px-7">
<Show when={!props.editing} fallback={
<label class="form-control w-full mb-3">
<div class="label">
<span class="label-text">Publish region</span>
</div>
<input readonly type="text" class="input input-bordered"
value={`You published this post in realm #${props.editing?.realm_id ?? "global"}`} />
</label>
}>
<label class="form-control w-full">
<div class="label">
<span class="label-text">Publish region</span>
</div>
<select name="realm" class="select select-bordered">
<option value={0} selected>Global</option>
<For each={realmList()}>
{item => <option value={item.id}>{item.name}</option>}
</For>
</select>
<div class="label">
<span class="label-text-alt">Will show realms you joined or created.</span>
</div>
</label>
</Show>
<label class="form-control w-full">
<div class="label">
<span class="label-text">Post title</span>
</div>
<input value={props.editing?.title ?? ""} name="title" type="text" placeholder="Type here"
class="input input-bordered w-full" />
</label>
<label class="form-control w-full">
<div class="label">
<span class="label-text">Post description</span>
</div>
<textarea value={props.editing?.description ?? ""} disabled name="description"
placeholder="Not available now"
class="textarea textarea-bordered w-full" />
<div class="label">
<span class="label-text-alt">Won't display in the post list when your post is too long.</span>
</div>
</label>
<label class="form-control w-full">
<div class="label">
<span class="label-text">Post thumbnail</span>
</div>
<input disabled name="thumbnail" type="file" placeholder="Not available now"
class="file-input file-input-bordered w-full" />
</label>
<button type="submit" class="btn btn-primary mt-7" disabled={submitting()}>
<Show when={submitting()} fallback={"Submit"}>
<span class="loading"></span>
</Show>
</button>
</div>
</form>
);
}

View File

@ -1,215 +0,0 @@
import { createSignal, For, Show } from "solid-js";
import { getAtk, useUserinfo } from "../../stores/userinfo.tsx";
import { request } from "../../scripts/request.ts";
import PostAttachments from "./PostAttachments.tsx";
import * as marked from "marked";
import DOMPurify from "dompurify";
import Avatar from "../Avatar.tsx";
export default function PostItem(props: {
post: any;
noClick?: boolean;
noAuthor?: boolean;
noControl?: boolean;
noRelated?: boolean;
noContent?: boolean;
onRepost?: (post: any) => void;
onReply?: (post: any) => void;
onEdit?: (post: any) => void;
onDelete?: (post: any) => void;
onSearch?: (filter: any) => void;
onError: (message: string | null) => void;
onReact: () => void;
}) {
const [reacting, setReacting] = createSignal(false);
const userinfo = useUserinfo();
async function reactPost(item: any, type: string) {
setReacting(true);
const res = await request(`/api/posts/${item.id}/react/${type}`, {
method: "POST",
headers: { Authorization: `Bearer ${getAtk()}` }
});
if (res.status !== 201 && res.status !== 204) {
props.onError(await res.text());
} else {
props.onReact();
props.onError(null);
}
setReacting(false);
}
const content = <article class="prose" innerHTML={DOMPurify.sanitize(marked.parse(props.post.content) as string)} />;
return (
<div class="post-item">
<Show when={!props.noAuthor}>
<a href={`/accounts/${props.post.author.name}`}>
<div class="flex bg-base-200">
<div class="pl-[20px]">
<Avatar user={props.post.author} />
</div>
<div class="flex items-center px-5">
<div>
<h3 class="font-bold text-sm">{props.post.author.nick}</h3>
<p class="text-xs">{props.post.author.description}</p>
</div>
</div>
</div>
</a>
</Show>
<Show when={!props.noContent}>
<div class="px-7 py-5">
<h2 class="card-title">{props.post.title}</h2>
<Show when={!props.noClick} fallback={content}>
<a href={`/posts/${props.post.alias}`}>{content}</a>
</Show>
<div class="mt-2 flex gap-2">
<For each={props.post.categories}>
{(item) => (
<a href={`/search?category=${item.alias}`} class="badge badge-primary">
<i class="fa-solid fa-layer-group me-1.5"></i>
{item.name}
</a>
)}
</For>
<For each={props.post.tags}>
{(item) => (
<a href={`/search?tag=${item.alias}`} class="badge badge-accent">
<i class="fa-solid fa-tag me-1.5"></i>
{item.name}
</a>
)}
</For>
</div>
<Show when={props.post.attachments?.length > 0}>
<div>
<PostAttachments attachments={props.post.attachments ?? []} />
</div>
</Show>
<Show when={!props.noRelated && props.post.repost_to}>
<p class="text-xs mt-3 mb-2">
<i class="fa-solid fa-retweet me-2"></i>
Reposted a post
</p>
<div class="border border-base-200 mb-5">
<PostItem noControl post={props.post.repost_to} onError={props.onError} onReact={props.onReact} />
</div>
</Show>
<Show when={!props.noRelated && props.post.reply_to}>
<p class="text-xs mt-3 mb-2">
<i class="fa-solid fa-reply me-2"></i>
Replied a post
</p>
<div class="border border-base-200 mb-5">
<PostItem noControl post={props.post.reply_to} onError={props.onError} onReact={props.onReact} />
</div>
</Show>
</div>
</Show>
<Show when={!props.noControl}>
<div class="relative">
<Show when={!userinfo?.isLoggedIn}>
<div
class="px-7 py-2.5 h-12 w-full opacity-0 transition-opacity hover:opacity-100 bg-base-100 border-t border-base-200 z-[1] absolute top-0 left-0">
<b>Login!</b> To access entire platform.
</div>
</Show>
<div class="grid grid-cols-3 border-y border-base-200">
<div class="max-md:col-span-2 md:col-span-1 grid grid-cols-2">
<div class="tooltip" data-tip="Daisuki">
<button
type="button"
class="btn btn-ghost btn-block"
disabled={reacting()}
onClick={() => reactPost(props.post, "like")}
>
<i class="fa-solid fa-thumbs-up"></i>
<code class="font-mono">{props.post.like_count}</code>
</button>
</div>
<div class="tooltip" data-tip="Daikirai">
<button
type="button"
class="btn btn-ghost btn-block"
disabled={reacting()}
onClick={() => reactPost(props.post, "dislike")}
>
<i class="fa-solid fa-thumbs-down"></i>
<code class="font-mono">{props.post.dislike_count}</code>
</button>
</div>
</div>
<div class="max-md:col-span-1 md:col-span-2 flex justify-end">
<section class="max-md:hidden">
<div class="tooltip" data-tip="Reply">
<button
type="button"
class="indicator btn btn-ghost btn-block"
onClick={() => props.onReply && props.onReply(props.post)}
>
<span class="indicator-item badge badge-sm badge-primary">{props.post.reply_count}</span>
<i class="fa-solid fa-reply"></i>
</button>
</div>
<div class="tooltip" data-tip="Repost">
<button
type="button"
class="indicator btn btn-ghost btn-block"
onClick={() => props.onRepost && props.onRepost(props.post)}
>
<span class="indicator-item badge badge-sm badge-secondary">{props.post.repost_count}</span>
<i class="fa-solid fa-retweet"></i>
</button>
</div>
</section>
<div class="dropdown dropdown-end">
<div tabIndex="0" role="button" class="btn btn-ghost w-12">
<i class="fa-solid fa-ellipsis-vertical"></i>
</div>
<ul tabIndex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52">
<li class="md:hidden">
<a class="flex justify-between" onClick={() => props.onReply && props.onReply(props.post)}>
<span>Reply</span>
<span class="badge badge-primary">{props.post.reply_count}</span>
</a>
</li>
<li class="md:hidden">
<a class="flex justify-between" onClick={() => props.onRepost && props.onRepost(props.post)}>
<span>Repost</span>
<span class="badge badge-secondary">{props.post.repost_count}</span>
</a>
</li>
<Show when={userinfo?.profiles?.id === props.post.author_id}>
<li>
<a onClick={() => props.onDelete && props.onDelete(props.post)}>Delete</a>
</li>
</Show>
<Show when={userinfo?.profiles?.id === props.post.author_id}>
<li>
<a onClick={() => props.onEdit && props.onEdit(props.post)}>Edit</a>
</li>
</Show>
<li>
<a>Report</a>
</li>
</ul>
</div>
</div>
</div>
</div>
</Show>
</div>
);
}

View File

@ -1,3 +0,0 @@
.paginationControl {
background-color: transparent !important;
}

View File

@ -1,96 +0,0 @@
import { createMemo, createSignal, For, Show } from "solid-js";
import styles from "./PostList.module.css";
import PostItem from "./PostItem.tsx";
import LoadingAnimation from "../LoadingAnimation.tsx";
import { getAtk } from "../../stores/userinfo.tsx";
import { request } from "../../scripts/request.ts";
export default function PostList(props: {
noRelated?: boolean,
info: { data: any[], count: number } | null,
onRepost?: (post: any) => void,
onReply?: (post: any) => void,
onEdit?: (post: any) => void,
onUpdate: (pn: number, filter?: any) => Promise<void>,
onError: (message: string | null) => void
}) {
const [loading, setLoading] = createSignal(true);
const posts = createMemo(() => props.info?.data);
const postCount = createMemo<number>(() => props.info?.count ?? 0);
const [page, setPage] = createSignal(1);
const pageCount = createMemo(() => Math.ceil(postCount() / 10));
async function readPosts(filter?: any) {
setLoading(true);
await props.onUpdate(page(), filter);
setLoading(false);
}
readPosts();
async function deletePost(item: any) {
if (!confirm(`Are you sure to delete post#${item.id}?`)) return;
setLoading(true);
const res = await request(`/api/posts/${item.id}`, {
method: "DELETE",
headers: { "Authorization": `Bearer ${getAtk()}` }
});
if (res.status !== 200) {
props.onError(await res.text());
} else {
await readPosts();
props.onError(null);
}
setLoading(false);
}
function changePage(pn: number) {
setPage(pn);
readPosts().then(() => {
setTimeout(() => window.scrollTo({ top: 0, behavior: "smooth" }), 16);
});
}
return (
<div id="post-list">
<div id="posts">
<For each={posts()}>
{item =>
<PostItem
post={item}
noRelated={props.noRelated}
onRepost={props.onRepost}
onReply={props.onReply}
onEdit={props.onEdit}
onDelete={deletePost}
onReact={() => readPosts()}
onError={props.onError}
/>
}
</For>
<div class="flex justify-center">
<div class="join">
<button class={`join-item btn btn-ghost ${styles.paginationControl}`} disabled={page() <= 1}
onClick={() => changePage(page() - 1)}>
<i class="fa-solid fa-caret-left"></i>
</button>
<button class="join-item btn btn-ghost">Page {page()}</button>
<button class={`join-item btn btn-ghost ${styles.paginationControl}`} disabled={page() >= pageCount()}
onClick={() => changePage(page() + 1)}>
<i class="fa-solid fa-caret-right"></i>
</button>
</div>
</div>
<Show when={loading()}>
<LoadingAnimation />
</Show>
</div>
</div>
);
}

View File

@ -1,8 +0,0 @@
.publishInput {
outline-style: none !important;
outline-width: 0 !important;
}
.description {
color: var(--fallback-bc, oklch(var(--bc)/.8));
}

View File

@ -1,210 +0,0 @@
import { createEffect, createSignal, Show } from "solid-js";
import { getAtk, useUserinfo } from "../../stores/userinfo.tsx";
import { request } from "../../scripts/request.ts";
import styles from "./PostPublish.module.css";
import PostEditActions from "./PostEditActions.tsx";
import Avatar from "../Avatar.tsx";
export default function PostPublish(props: {
replying?: any,
reposting?: any,
editing?: any,
realmId?: number,
onReset: () => void,
onError: (message: string | null) => void,
onPost: () => void
}) {
const userinfo = useUserinfo();
if (!userinfo?.isLoggedIn) {
return (
<div class="py-9 flex justify-center items-center">
<div class="text-center">
<h2 class="text-lg font-bold">Login!</h2>
<p>Or keep silent.</p>
</div>
</div>
);
}
const [submitting, setSubmitting] = createSignal(false);
const [alias, setAlias] = createSignal("");
const [publishedAt, setPublishedAt] = createSignal("");
const [attachments, setAttachments] = createSignal<any[]>([]);
const [categories, setCategories] = createSignal<{ alias: string, name: string }[]>([]);
const [tags, setTags] = createSignal<{ alias: string, name: string }[]>([]);
createEffect(() => {
setAttachments(props.editing?.attachments ?? []);
setCategories(props.editing?.categories ?? []);
setTags(props.editing?.tags ?? []);
}, [props.editing]);
async function doPost(evt: SubmitEvent) {
evt.preventDefault();
const form = evt.target as HTMLFormElement;
const data = Object.fromEntries(new FormData(form));
if (!data.content) return;
setSubmitting(true);
const res = await request("/api/posts", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${getAtk()}`
},
body: JSON.stringify({
alias: alias() ? alias() : crypto.randomUUID().replace(/-/g, ""),
title: data.title,
content: data.content,
attachments: attachments(),
categories: categories(),
tags: tags(),
realm_id: data.publish_in_realm ? props.realmId : undefined,
published_at: publishedAt() ? new Date(publishedAt()) : new Date(),
repost_to: props.reposting?.id,
reply_to: props.replying?.id
})
});
if (res.status !== 200) {
props.onError(await res.text());
} else {
form.reset();
props.onError(null);
props.onPost();
}
setSubmitting(false);
}
async function doEdit(evt: SubmitEvent) {
evt.preventDefault();
const form = evt.target as HTMLFormElement;
const data = Object.fromEntries(new FormData(form));
if (!data.content) return;
setSubmitting(true);
const res = await request(`/api/posts/${props.editing?.id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${getAtk()}`
},
body: JSON.stringify({
alias: alias() ? alias() : crypto.randomUUID().replace(/-/g, ""),
title: data.title,
content: data.content,
attachments: attachments(),
categories: categories(),
tags: tags(),
realm_id: props.realmId,
published_at: publishedAt() ? new Date(publishedAt()) : new Date()
})
});
if (res.status !== 200) {
props.onError(await res.text());
} else {
form.reset();
props.onError(null);
props.onPost();
}
setSubmitting(false);
}
function resetForm() {
setAttachments([]);
setCategories([]);
setTags([]);
props.onReset();
}
return (
<>
<form id="publish" onSubmit={(evt) => (props.editing ? doEdit : doPost)(evt)} onReset={() => resetForm()}>
<div id="publish-identity" class="flex border-y border-base-200">
<div class="pl-[20px]">
<Avatar user={userinfo?.profiles} />
</div>
<div class="flex flex-grow">
<input name="title" value={props.editing?.title ?? ""}
class={`${styles.publishInput} input w-full`}
placeholder="The describe for a long content" />
</div>
</div>
<Show when={props.reposting}>
<div role="alert" class="bg-base-200 flex justify-between">
<div class="px-5 py-3">
<i class="fa-solid fa-circle-info me-3"></i>
You are reposting a post from <b>{props.reposting?.author?.nick}</b>
</div>
<button type="reset" class="btn btn-ghost w-12" disabled={submitting()}>
<i class="fa-solid fa-xmark"></i>
</button>
</div>
</Show>
<Show when={props.replying}>
<div role="alert" class="bg-base-200 flex justify-between">
<div class="px-5 py-3">
<i class="fa-solid fa-circle-info me-3"></i>
You are replying a post from <b>{props.replying?.author?.nick}</b>
</div>
<button type="reset" class="btn btn-ghost w-12" disabled={submitting()}>
<i class="fa-solid fa-xmark"></i>
</button>
</div>
</Show>
<Show when={props.editing}>
<div role="alert" class="bg-base-200 flex justify-between">
<div class="px-5 py-3">
<i class="fa-solid fa-circle-info me-3"></i>
You are editing a post published at{" "}
<b>{new Date(props.editing?.created_at).toLocaleString()}</b>
</div>
<button type="reset" class="btn btn-ghost w-12" disabled={submitting()}>
<i class="fa-solid fa-xmark"></i>
</button>
</div>
</Show>
<Show when={props.realmId && !props.editing}>
<div class="border-b border-base-200 px-5 h-[48px] flex items-center">
<div class="form-control flex-grow">
<label class="label cursor-pointer">
<span class="label-text">Publish in this realm</span>
<input name="publish_in_realm" type="checkbox" checked class="checkbox checkbox-primary" />
</label>
</div>
</div>
</Show>
<textarea required name="content" value={props.editing?.content ?? ""}
class={`${styles.publishInput} textarea w-full`}
placeholder="What's happened?! (Support markdown)" />
<div id="publish-actions" class="flex justify-between border-y border-base-200">
<PostEditActions
editing={props.editing}
onInputAlias={setAlias}
onInputPublish={setPublishedAt}
onInputAttachments={setAttachments}
onInputCategories={setCategories}
onInputTags={setTags}
onError={props.onError}
/>
<div>
<button type="submit" class="btn btn-primary" disabled={submitting()}>
<Show when={submitting()} fallback={props.editing ? "Save changes" : "Post a post"}>
<span class="loading"></span>
</Show>
</button>
</div>
</div>
</form>
</>
);
}

View File

@ -1,39 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html, body {
padding: 0;
margin: 0;
}
.medium-zoom-image--opened {
z-index: 15;
}
.medium-zoom-overlay {
z-index: 10;
}
.scrollbar-hidden {
scrollbar-width: none;
}
.scrollbar-hidden::-webkit-scrollbar {
display: none;
width: 0;
}
.cherry, .cherry-toolbar, .cherry-editor, .cherry-previewer, .cherry-drag {
box-shadow: none !important;
}
.cherry-drag {
width: 2px !important;
}
@media (prefers-color-scheme: dark) {
.cherry-drag {
background: oklch(var(--b2)) !important;
}
}

View File

@ -1,79 +0,0 @@
import "solid-devtools";
/* @refresh reload */
import { render } from "solid-js/web";
import "./index.css";
import "./assets/fonts/fonts.css";
import { lazy } from "solid-js";
import { Route, Router } from "@solidjs/router";
import "@fortawesome/fontawesome-free/css/all.css";
import RootLayout from "./layouts/RootLayout.tsx";
import FeedView from "./pages/view.tsx";
import Global from "./pages/global.tsx";
import PostReference from "./pages/post.tsx";
import CreatorView from "./pages/creators/view.tsx";
import { UserinfoProvider } from "./stores/userinfo.tsx";
import { WellKnownProvider } from "./stores/wellKnown.tsx";
const root = document.getElementById("root");
const router = (basename?: string) => (
<WellKnownProvider>
<UserinfoProvider>
<Router root={RootLayout} base={basename}>
<Route path="/" component={FeedView}>
<Route path="/" component={Global} />
<Route path="/posts/:postId" component={PostReference} />
<Route path="/search" component={lazy(() => import("./pages/search.tsx"))} />
<Route path="/realms" component={lazy(() => import("./pages/realms"))} />
<Route path="/realms/:realmId" component={lazy(() => import("./pages/realms/realm.tsx"))} />
<Route path="/accounts/:accountId" component={lazy(() => import("./pages/account.tsx"))} />
</Route>
<Route path="/creators" component={CreatorView}>
<Route path="/" component={lazy(() => import("./pages/creators"))} />
<Route path="/publish" component={lazy(() => import("./pages/creators/publish.tsx"))} />
<Route path="/edit/:postId" component={lazy(() => import("./pages/creators/edit.tsx"))} />
</Route>
</Router>
</UserinfoProvider>
</WellKnownProvider>
);
declare const __GARFISH_EXPORTS__: {
provider: Object;
registerProvider?: (provider: any) => void;
};
declare global {
interface Window {
__GARFISH__: boolean;
__LAUNCHPAD_TARGET__?: string;
}
}
export const provider = () => ({
render: ({ dom, basename }: { dom: any, basename: string }) => {
render(
() => router(basename),
dom.querySelector("#root")
);
},
destroy: () => {
}
});
if (!window.__GARFISH__) {
console.log("Running directly!")
render(router, root!);
} else if (typeof __GARFISH_EXPORTS__ !== "undefined") {
console.log("Running in launchpad container!")
console.log("Launchpad target:", window.__LAUNCHPAD_TARGET__)
if (__GARFISH_EXPORTS__.registerProvider) {
__GARFISH_EXPORTS__.registerProvider(provider);
} else {
__GARFISH_EXPORTS__.provider = provider;
}
}

View File

@ -1,63 +0,0 @@
import Navigator from "./shared/Navigator.tsx";
import { readProfiles, useUserinfo } from "../stores/userinfo.tsx";
import { createEffect, createMemo, createSignal, Show } from "solid-js";
import { readWellKnown } from "../stores/wellKnown.tsx";
import { BeforeLeaveEventArgs, useLocation, useNavigate, useSearchParams } from "@solidjs/router";
export default function RootLayout(props: any) {
const [ready, setReady] = createSignal(false);
Promise.all([readWellKnown(), readProfiles()]).then(() => setReady(true));
const navigate = useNavigate();
const userinfo = useUserinfo();
const [searchParams] = useSearchParams();
const location = useLocation();
createEffect(() => {
if (ready()) {
keepGate(location.pathname + location.search, searchParams["embedded"] != null);
}
}, [ready, userinfo]);
function keepGate(path: string, embedded: boolean, e?: BeforeLeaveEventArgs) {
const blacklist = ["/creators"];
if (!userinfo?.isLoggedIn && blacklist.includes(path)) {
if (!e?.defaultPrevented) e?.preventDefault();
if (embedded) {
navigate(`/auth?redirect_uri=${path}&embedded=${location.query["embedded"]}`);
} else {
navigate(`/auth?redirect_uri=${path}`);
}
}
}
const mainContentStyles = createMemo(() => {
if (!searchParams["embedded"]) {
return "h-[calc(100vh-64px)] max-md:mb-[64px] md:mt-[64px]";
} else {
return "h-[100vh]";
}
});
return (
<Show
when={ready()}
fallback={
<div class="h-screen w-screen flex justify-center items-center">
<div>
<span class="loading loading-lg loading-infinity"></span>
</div>
</div>
}
>
<Show when={!searchParams["embedded"]}>
<Navigator />
</Show>
<main class={`${mainContentStyles()} scrollbar-hidden`}>{props.children}</main>
</Show>
);
}

View File

@ -1,80 +0,0 @@
import { createMemo, For, Match, Switch } from "solid-js";
import { clearUserinfo, useUserinfo } from "../../stores/userinfo.tsx";
import { useNavigate } from "@solidjs/router";
import { useWellKnown } from "../../stores/wellKnown.tsx";
interface MenuItem {
icon: string;
label: string;
href?: string;
}
export default function Navigator() {
const nav: MenuItem[] = [
{ icon: "fa-solid fa-pen-nib", label: "Creators", href: "/creators" },
{ icon: "fa-solid fa-newspaper", label: "Feed", href: "/" },
{ icon: "fa-solid fa-people-group", label: "Realms", href: "/realms" },
];
const wellKnown = useWellKnown();
const userinfo = useUserinfo();
const navigate = useNavigate();
const endpoint = createMemo(() => wellKnown?.components?.identity)
function logout() {
clearUserinfo();
navigate("/");
}
return (
<>
<div class="max-md:hidden navbar bg-base-100 shadow-md px-5 z-10 h-[64px] fixed top-0">
<div class="navbar-start">
<a href="/" class="btn btn-ghost text-xl">
{wellKnown?.name ?? "Interactive"}
</a>
</div>
<div class="navbar-center hidden md:flex">
<ul class="menu menu-horizontal px-1">
<For each={nav}>
{(item) => (
<li class="tooltip tooltip-bottom" data-tip={item.label}>
<a href={item.href}>
<i class={item.icon}></i>
</a>
</li>
)}
</For>
</ul>
</div>
<div class="navbar-end pe-5">
<Switch>
<Match when={userinfo?.isLoggedIn}>
<button type="button" class="btn btn-sm btn-ghost" onClick={() => logout()}>
Logout
</button>
</Match>
<Match when={!userinfo?.isLoggedIn}>
<a href={`${endpoint()}/auth/login?redirect_uri=${window.location}`} class="btn btn-sm btn-primary">
Login
</a>
</Match>
</Switch>
</div>
</div>
<div class="md:hidden btm-nav fixed bottom-0 bg-base-100 border-t border-base-200 z-10 h-[64px]">
<For each={nav}>
{(item) => (
<a href={item.href}>
<div class="tooltip" data-tip={item.label}>
<i class={item.icon}></i>
</div>
</a>
)}
</For>
</div>
</>
);
}

View File

@ -1,110 +0,0 @@
import { createEffect, createSignal, Show } from "solid-js";
import { useParams } from "@solidjs/router";
import { useSearchParams } from "@solidjs/router";
import { createStore } from "solid-js/store";
import { closeModel, openModel } from "../scripts/modals.ts";
import { request } from "../scripts/request.ts";
import PostList from "../components/posts/PostList.tsx";
import NameCard from "../components/NameCard.tsx";
import PostPublish from "../components/posts/PostPublish.tsx";
export default function AccountPage() {
const [error, setError] = createSignal<string | null>(null);
const [page, setPage] = createSignal(0);
const [info, setInfo] = createSignal<any>(null);
const [searchParams, setSearchParams] = useSearchParams();
const params = useParams();
createEffect(() => {
setPage(parseInt(searchParams["page"] ?? "1"));
}, [searchParams]);
async function readPosts(pn?: number) {
if (pn) setSearchParams({ page: pn });
const res = await request(
"/api/posts?" +
new URLSearchParams({
take: searchParams["take"] ? searchParams["take"] : (10).toString(),
offset: searchParams["offset"] ? searchParams["offset"] : ((page() - 1) * 10).toString(),
authorId: params["accountId"],
}),
);
if (res.status !== 200) {
setError(await res.text());
} else {
setError(null);
setInfo(await res.json());
}
}
function setMeta(data: any, field: string, open = true) {
const meta: { [id: string]: any } = {
reposting: null,
replying: null,
editing: null,
};
meta[field] = data;
setPublishMeta(meta);
if (open) openModel("#post-publish");
else closeModel("#post-publish");
}
const [publishMeta, setPublishMeta] = createStore<any>({
replying: null,
reposting: null,
editing: null,
});
return (
<>
<div id="alerts">
<Show when={error()}>
<div role="alert" class="alert alert-error">
<svg
xmlns="http://www.w3.org/2000/svg"
class="stroke-current shrink-0 h-6 w-6"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span class="capitalize">{error()}</span>
</div>
</Show>
</div>
<NameCard accountId={params["accountId"]} onError={setError} />
<dialog id="post-publish" class="modal">
<div class="modal-box p-0 w-[540px]">
<PostPublish
reposting={publishMeta.reposting}
replying={publishMeta.replying}
editing={publishMeta.editing}
onReset={() => setMeta(null, "none", false)}
onError={setError}
onPost={() => readPosts()}
/>
</div>
</dialog>
<PostList
info={info()}
onUpdate={readPosts}
onError={setError}
onRepost={(item) => setMeta(item, "reposting")}
onReply={(item) => setMeta(item, "replying")}
onEdit={(item) => setMeta(item, "editing")}
/>
</>
);
}

View File

@ -1,58 +0,0 @@
import PostEdit from "../../components/posts/PostEditor.tsx";
import { useNavigate, useParams } from "@solidjs/router";
import { createSignal, Show } from "solid-js";
import { getAtk } from "../../stores/userinfo.tsx";
import { request } from "../../scripts/request.ts";
export default function PublishPost() {
const navigate = useNavigate();
const params = useParams();
const [error, setError] = createSignal<string | null>(null);
const [post, setPost] = createSignal<any>();
async function readPost() {
const res = await request(`/api/creators/posts/${params["postId"]}`, {
headers: { "Authorization": `Bearer ${getAtk()}` }
});
if (res.status === 200) {
setPost((await res.json())["data"]);
} else {
setError(await res.text());
}
}
readPost();
return (
<>
<div class="flex pt-1 border-b border-base-200">
<a class="btn btn-ghost ml-[20px] w-12 h-12" href="/creators">
<i class="fa-solid fa-angle-left"></i>
</a>
<div class="px-5 flex items-center">
<p>Edit{post()?.title ? post()?.title : "Untitled"}</p>
</div>
</div>
<div id="alerts">
<Show when={error()}>
<div role="alert" class="alert alert-error">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="capitalize">{error()}</span>
</div>
</Show>
</div>
<PostEdit
editing={post()}
onError={setError}
onPost={() => navigate("/creators")}
/>
</>
);
}

View File

@ -1,121 +0,0 @@
import { createMemo, createSignal, For, Show } from "solid-js";
import { getAtk } from "../../stores/userinfo.tsx";
import LoadingAnimation from "../../components/LoadingAnimation.tsx";
import styles from "../../components/posts/PostList.module.css";
import { request } from "../../scripts/request.ts";
export default function CreatorHub() {
const [error, setError] = createSignal<string | null>(null);
const [posts, setPosts] = createSignal<any[]>([]);
const [postCount, setPostCount] = createSignal(0);
const [page, setPage] = createSignal(1);
const [loading, setLoading] = createSignal(false);
const pageCount = createMemo(() => Math.ceil(postCount() / 10));
async function readPosts(pn?: number) {
if (pn) setPage(pn);
setLoading(true);
const res = await request("/api/creators/posts?" + new URLSearchParams({
take: (10).toString(),
offset: ((page() - 1) * 10).toString()
}), { headers: { "Authorization": `Bearer ${getAtk()}` } });
if (res.status !== 200) {
setError(await res.text());
} else {
const data = await res.json();
setError(null);
setPosts(data["data"]);
setPostCount(data["count"]);
}
setLoading(false);
}
readPosts();
function changePage(pn: number) {
readPosts(pn).then(() => {
setTimeout(() => window.scrollTo({ top: 0, behavior: "smooth" }), 16);
});
}
return (
<>
<div id="alerts">
<Show when={error()}>
<div role="alert" class="alert alert-error">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="capitalize">{error()}</span>
</div>
</Show>
</div>
<div class="mt-1 px-7 flex items-center justify-between border-b border-base-200">
<h3 class="py-3 font-bold">Your posts</h3>
<a class="btn btn-primary" href="/creators/publish">
<i class="fa-solid fa-plus"></i>
</a>
</div>
<div class="grid justify-items-strench">
<For each={posts()}>
{item =>
<a href={`/creators/edit/${item.alias}`}>
<div class="card sm:card-side hover:bg-base-200 transition-colors sm:max-w-none">
<div class="card-body">
<Show when={item?.title} fallback={
<div class="line-clamp-3">
{item?.content?.replaceAll("#", "").replaceAll("*", "").trim()}
</div>
}>
<h2 class="text-xl">{item?.title}</h2>
<div class="mx-[-2px] mt-[-4px]">
{item?.categories?.map((category: any) => (
<span class="badge badge-primary">{category.name}</span>
))}
{item?.tags?.map((tag: any) => (
<span class="badge badge-secondary">{tag.name}</span>
))}
</div>
<div class="text-sm opacity-80 line-clamp-3">
{item?.content?.substring(0, 160).replaceAll("#", "").replaceAll("*", "").trim() + "……"}
</div>
</Show>
<div class="text-xs opacity-70 flex gap-2">
<span>Post #{item?.id}</span>
<span>Published at {new Date(item?.published_at).toLocaleString()}</span>
</div>
</div>
</div>
</a>
}
</For>
</div>
<div class="flex justify-center">
<div class="join">
<button class={`join-item btn btn-ghost ${styles.paginationControl}`} disabled={page() <= 1}
onClick={() => changePage(page() - 1)}>
<i class="fa-solid fa-caret-left"></i>
</button>
<button class="join-item btn btn-ghost">Page {page()}</button>
<button class={`join-item btn btn-ghost ${styles.paginationControl}`} disabled={page() >= pageCount()}
onClick={() => changePage(page() + 1)}>
<i class="fa-solid fa-caret-right"></i>
</button>
</div>
</div>
<Show when={loading()}>
<LoadingAnimation />
</Show>
</>
);
}

View File

@ -1,40 +0,0 @@
import PostEdit from "../../components/posts/PostEditor.tsx";
import { useNavigate } from "@solidjs/router";
import { createSignal, Show } from "solid-js";
export default function PublishPost() {
const navigate = useNavigate();
const [error, setError] = createSignal<string | null>(null);
return (
<>
<div class="flex pt-1 border-b border-base-200">
<a class="btn btn-ghost ml-[20px] w-12 h-12" href="/creators">
<i class="fa-solid fa-angle-left"></i>
</a>
<div class="px-5 flex items-center">
<p>Publish a new post</p>
</div>
</div>
<div id="alerts">
<Show when={error()}>
<div role="alert" class="alert alert-error">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="capitalize">{error()}</span>
</div>
</Show>
</div>
<PostEdit
onError={setError}
onPost={() => navigate("/creators")}
/>
</>
);
}

View File

@ -1,13 +0,0 @@
.wrapper {
display: grid;
grid-template-columns: 1fr;
column-gap: 20px;
max-height: calc(100vh - 64px);
}
@media (min-width: 1024px) {
.wrapper {
grid-template-columns: 1fr 2fr;
}
}

View File

@ -1,28 +0,0 @@
import { createMemo } from "solid-js";
import { useSearchParams } from "@solidjs/router";
import styles from "./view.module.css";
export default function CreatorView(props: any) {
const [searchParams] = useSearchParams();
const scrollContentStyles = createMemo(() => {
if (!searchParams["embedded"]) {
return "max-md:mb-[64px]";
} else {
return "h-[100vh]";
}
});
return (
<div class={`${styles.wrapper} container mx-auto`}>
<div id="nav" class="card shadow-xl h-fit">
<h2 class="text-xl font-bold mt-1 py-5 px-7">Creator Hub</h2>
</div>
<div id="content" class={`${scrollContentStyles()} card shadow-xl`}>
{props.children}
</div>
</div>
);
}

View File

@ -1,99 +0,0 @@
import { createEffect, createSignal, Show } from "solid-js";
import { createStore } from "solid-js/store";
import { useSearchParams } from "@solidjs/router";
import { request } from "../scripts/request.ts";
import PostList from "../components/posts/PostList.tsx";
import PostPublish from "../components/posts/PostPublish.tsx";
export default function DashboardPage() {
const [error, setError] = createSignal<string | null>(null);
const [page, setPage] = createSignal(0);
const [info, setInfo] = createSignal<any>(null);
const [searchParams, setSearchParams] = useSearchParams();
createEffect(() => {
setPage(parseInt(searchParams["page"] ?? "1"));
}, [searchParams]);
async function readPosts(pn?: number) {
if (pn) setSearchParams({ page: pn });
const res = await request(
"/api/posts?" +
new URLSearchParams({
take: searchParams["take"] ? searchParams["take"] : (10).toString(),
offset: searchParams["offset"] ? searchParams["offset"] : ((page() - 1) * 10).toString(),
reply: false.toString(),
}),
);
if (res.status !== 200) {
setError(await res.text());
} else {
setError(null);
setInfo(await res.json());
}
}
function setMeta(data: any, field: string, scroll = true) {
const meta: { [id: string]: any } = {
reposting: null,
replying: null,
editing: null,
};
meta[field] = data;
setPublishMeta(meta);
if (scroll) window.scroll({ top: 0, behavior: "smooth" });
}
const [publishMeta, setPublishMeta] = createStore<any>({
replying: null,
reposting: null,
editing: null,
});
return (
<>
<div id="alerts">
<Show when={error()}>
<div role="alert" class="alert alert-error">
<svg
xmlns="http://www.w3.org/2000/svg"
class="stroke-current shrink-0 h-6 w-6"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span class="capitalize">{error()}</span>
</div>
</Show>
</div>
<PostPublish
replying={publishMeta.replying}
reposting={publishMeta.reposting}
editing={publishMeta.editing}
onReset={() => setMeta(null, "none", false)}
onPost={() => readPosts()}
onError={setError}
/>
<PostList
info={info()}
onUpdate={readPosts}
onError={setError}
onRepost={(item) => setMeta(item, "reposting")}
onReply={(item) => setMeta(item, "replying")}
onEdit={(item) => setMeta(item, "editing")}
/>
</>
);
}

View File

@ -1,161 +0,0 @@
import { createSignal, Show } from "solid-js";
import { useNavigate, useParams, useSearchParams } from "@solidjs/router";
import { createStore } from "solid-js/store";
import { closeModel, openModel } from "../scripts/modals.ts";
import { getAtk } from "../stores/userinfo.tsx";
import { request } from "../scripts/request.ts";
import PostPublish from "../components/posts/PostPublish.tsx";
import PostList from "../components/posts/PostList.tsx";
import PostItem from "../components/posts/PostItem.tsx";
export default function PostPage() {
const [error, setError] = createSignal<string | null>(null);
const [page, setPage] = createSignal(0);
const [related, setRelated] = createSignal<any>(null);
const [info, setInfo] = createSignal<any>(null);
const params = useParams();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
async function readPost(pn?: number) {
if (pn) setPage(pn);
const res = await request(`/api/posts/${params["postId"]}?` + new URLSearchParams({
take: (10).toString(),
offset: ((page() - 1) * 10).toString()
}));
if (res.status !== 200) {
setError(await res.text());
} else {
setError(null);
const data = await res.json();
setInfo(data["data"]);
setRelated({
count: data["count"],
data: data["related"]
});
}
}
readPost();
async function deletePost(item: any) {
if (!confirm(`Are you sure to delete post#${item.id}?`)) return;
const res = await request(`/api/posts/${item.id}`, {
method: "DELETE",
headers: { "Authorization": `Bearer ${getAtk()}` }
});
if (res.status !== 200) {
setError(await res.text());
} else {
back();
setError(null);
}
}
function setMeta(data: any, field: string, open = true) {
const meta: { [id: string]: any } = {
reposting: null,
replying: null,
editing: null
};
meta[field] = data;
setPublishMeta(meta);
if (open) openModel("#post-publish");
else closeModel("#post-publish");
}
const [publishMeta, setPublishMeta] = createStore<any>({
replying: null,
reposting: null,
editing: null
});
function back() {
if (window.history.length > 0) {
window.history.back();
} else {
navigate("/");
}
}
return (
<>
<div id="alerts">
<Show when={error()}>
<div role="alert" class="alert alert-error">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="capitalize">{error()}</span>
</div>
</Show>
</div>
<div class="flex pt-1">
<Show when={searchParams["embedded"]} fallback={
<button class="btn btn-ghost ml-[20px] w-12 h-12" onClick={() => back()}>
<i class="fa-solid fa-angle-left"></i>
</button>
}>
<div class="w-12 h-12 ml-[20px] flex justify-center items-center">
<i class="fa-solid fa-comments mb-1"></i>
</div>
</Show>
<div class="px-5 flex items-center">
<p>{searchParams["title"] ?? "Post details"}</p>
</div>
</div>
<dialog id="post-publish" class="modal">
<div class="modal-box p-0 w-[540px]">
<PostPublish
reposting={publishMeta.reposting}
replying={publishMeta.replying}
editing={publishMeta.editing}
onReset={() => setMeta(null, "none", false)}
onError={setError}
onPost={() => readPost()}
/>
</div>
</dialog>
<Show when={info()} fallback={
<div class="w-full border-b border-base-200 pt-5 pb-7 text-center">
<p class="loading loading-lg loading-infinity"></p>
<p>Creating fake news...</p>
</div>
}>
<PostItem
noClick
post={info()}
onError={setError}
onReact={readPost}
onDelete={deletePost}
noAuthor={searchParams["noAuthor"] != null}
noContent={searchParams["noContent"] != null}
noControl={searchParams["noControl"] != null}
onRepost={(item) => setMeta(item, "reposting")}
onReply={(item) => setMeta(item, "replying")}
onEdit={(item) => setMeta(item, "editing")}
/>
<PostList
noRelated
info={related()}
onUpdate={readPost}
onError={setError}
onRepost={(item) => setMeta(item, "reposting")}
onReply={(item) => setMeta(item, "replying")}
onEdit={(item) => setMeta(item, "editing")}
/>
</Show>
</>
);
}

View File

@ -1,115 +0,0 @@
import { createSignal, For, Show } from "solid-js";
import { closeModel, openModel } from "../../scripts/modals.ts";
import { getAtk } from "../../stores/userinfo.tsx";
import { request } from "../../scripts/request.ts";
export default function RealmDirectoryPage() {
const [error, setError] = createSignal<string | null>(null);
const [submitting, setSubmitting] = createSignal(false);
const [realms, setRealms] = createSignal<any>(null);
async function readRealms() {
const res = await request(`/api/realms`);
if (res.status !== 200) {
setError(await res.text());
} else {
setRealms(await res.json());
}
}
readRealms();
async function createRealm(evt: SubmitEvent) {
evt.preventDefault();
const form = evt.target as HTMLFormElement;
const data = Object.fromEntries(new FormData(form));
setSubmitting(true);
const res = await request("/api/realms", {
method: "POST",
headers: { "Authorization": `Bearer ${getAtk()}`, "Content-Type": "application/json" },
body: JSON.stringify({
name: data.name,
description: data.description,
is_public: data.is_public != null
})
});
if (res.status !== 200) {
setError(await res.text());
} else {
await readRealms();
closeModel("#create-realm");
form.reset();
}
setSubmitting(false);
}
return (
<>
<div id="alerts">
<Show when={error()}>
<div role="alert" class="alert alert-error">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="capitalize">{error()}</span>
</div>
</Show>
</div>
<div class="mt-1 px-7 flex items-center justify-between">
<h3 class="py-3 font-bold">Realms directory</h3>
<button type="button" class="btn btn-primary" onClick={() => openModel("#create-realm")}>
<i class="fa-solid fa-plus"></i>
</button>
</div>
<For each={realms()}>
{item => <div class="px-7 pt-7 pb-5 border-t border-base-200">
<h2 class="text-xl font-bold">{item.name}</h2>
<p>{item.description}</p>
<div class="mt-2">
<a href={`/realms/${item.id}`} class="link">Jump in</a>
</div>
</div>}
</For>
<dialog id="create-realm" class="modal">
<div class="modal-box">
<h2 class="card-title px-1">Create a realm</h2>
<form class="mt-2" onSubmit={createRealm}>
<label class="form-control w-full">
<div class="label">
<span class="label-text">Realm name</span>
</div>
<input name="name" type="text" placeholder="Type here" class="input input-bordered w-full" />
</label>
<label class="form-control w-full">
<div class="label">
<span class="label-text">Realm description</span>
</div>
<textarea name="description" placeholder="Type here" class="textarea textarea-bordered w-full" />
</label>
<div class="form-control mt-2">
<label class="label cursor-pointer">
<span class="label-text">Make it public</span>
<input type="checkbox" name="is_public" class="checkbox checkbox-primary" />
</label>
</div>
<button type="submit" class="btn btn-primary mt-2" disabled={submitting()}>
<Show when={submitting()} fallback={"Submit"}>
<span class="loading"></span>
</Show>
</button>
</form>
</div>
</dialog>
</>
);
}

View File

@ -1,3 +0,0 @@
.description {
color: var(--fallback-bc, oklch(var(--bc)/.8));
}

View File

@ -1,291 +0,0 @@
import { createSignal, Show } from "solid-js";
import { createStore } from "solid-js/store";
import { useNavigate, useParams } from "@solidjs/router";
import { request } from "../../scripts/request.ts";
import PostList from "../../components/posts/PostList.tsx";
import PostPublish from "../../components/posts/PostPublish.tsx";
import styles from "./realm.module.css";
import { getAtk, useUserinfo } from "../../stores/userinfo.tsx";
import { closeModel, openModel } from "../../scripts/modals.ts";
export default function RealmPage() {
const userinfo = useUserinfo();
const [error, setError] = createSignal<string | null>(null);
const [submitting, setSubmitting] = createSignal(false);
const [realm, setRealm] = createSignal<any>(null);
const [page, setPage] = createSignal(0);
const [info, setInfo] = createSignal<any>(null);
const params = useParams();
const navigate = useNavigate();
async function readRealm() {
const res = await request(`/api/realms/${params["realmId"]}`);
if (res.status !== 200) {
setError(await res.text());
} else {
setRealm(await res.json());
}
}
readRealm();
async function readPosts(pn?: number) {
if (pn) setPage(pn);
const res = await request(`/api/posts?` + new URLSearchParams({
take: (10).toString(),
offset: ((page() - 1) * 10).toString(),
realmId: params["realmId"]
}));
if (res.status !== 200) {
setError(await res.text());
} else {
setError(null);
setInfo(await res.json());
}
}
async function editRealm(evt: SubmitEvent) {
evt.preventDefault();
const form = evt.target as HTMLFormElement;
const data = Object.fromEntries(new FormData(form));
setSubmitting(true);
const res = await request(`/api/realms/${params["realmId"]}`, {
method: "PUT",
headers: { "Authorization": `Bearer ${getAtk()}`, "Content-Type": "application/json" },
body: JSON.stringify({
name: data.name,
description: data.description,
is_public: data.is_public != null
})
});
if (res.status !== 200) {
setError(await res.text());
} else {
await readRealm();
closeModel("#edit-realm");
form.reset();
}
setSubmitting(false);
}
async function inviteMember(evt: SubmitEvent) {
evt.preventDefault();
const form = evt.target as HTMLFormElement;
const data = Object.fromEntries(new FormData(form));
setSubmitting(true);
const res = await request(`/api/realms/${params["realmId"]}/invite`, {
method: "POST",
headers: { "Authorization": `Bearer ${getAtk()}`, "Content-Type": "application/json" },
body: JSON.stringify(data)
});
if (res.status !== 200) {
setError(await res.text());
} else {
await readRealm();
closeModel("#invite-member");
form.reset();
}
setSubmitting(false);
}
async function kickMember(evt: SubmitEvent) {
evt.preventDefault();
const form = evt.target as HTMLFormElement;
const data = Object.fromEntries(new FormData(form));
setSubmitting(true);
const res = await request(`/api/realms/${params["realmId"]}/kick`, {
method: "POST",
headers: { "Authorization": `Bearer ${getAtk()}`, "Content-Type": "application/json" },
body: JSON.stringify(data)
});
if (res.status !== 200) {
setError(await res.text());
} else {
await readRealm();
closeModel("#kick-member");
form.reset();
}
setSubmitting(false);
}
async function breakRealm() {
if (!confirm("Are you sure about that? All posts in this realm will disappear forever.")) return;
const res = await request(`/api/realms/${params["realmId"]}`, {
method: "DELETE",
headers: { "Authorization": `Bearer ${getAtk()}` }
});
if (res.status !== 200) {
setError(await res.text());
} else {
navigate("/realms");
}
}
function setMeta(data: any, field: string, scroll = true) {
const meta: { [id: string]: any } = {
reposting: null,
replying: null,
editing: null
};
meta[field] = data;
setPublishMeta(meta);
if (scroll) window.scroll({ top: 0, behavior: "smooth" });
}
const [publishMeta, setPublishMeta] = createStore<any>({
replying: null,
reposting: null,
editing: null
});
return (
<>
<div id="alerts">
<Show when={error()}>
<div role="alert" class="alert alert-error">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="capitalize">{error()}</span>
</div>
</Show>
</div>
<div class="px-7 pt-7 pb-5">
<h2 class="text-2xl font-bold">{realm()?.name}</h2>
<p>{realm()?.description}</p>
<div class={`${styles.description} text-sm mt-3`}>
<p>Realm #{realm()?.id}</p>
<Show when={realm()?.account_id === userinfo?.profiles?.id}>
<div class="flex gap-2">
<button class="link" onClick={() => openModel("#edit-realm")}>Edit</button>
<button class="link" onClick={() => openModel("#invite-member")}>Invite</button>
<button class="link" onClick={() => openModel("#kick-member")}>Kick</button>
<button class="link" onClick={() => breakRealm()}>Break-up</button>
</div>
</Show>
</div>
</div>
<PostPublish
realmId={parseInt(params["realmId"])}
replying={publishMeta.replying}
reposting={publishMeta.reposting}
editing={publishMeta.editing}
onReset={() => setMeta(null, "none", false)}
onPost={() => readPosts()}
onError={setError}
/>
<PostList
info={info()}
onUpdate={readPosts}
onError={setError}
onRepost={(item) => setMeta(item, "reposting")}
onReply={(item) => setMeta(item, "replying")}
onEdit={(item) => setMeta(item, "editing")}
/>
<dialog id="edit-realm" class="modal">
<div class="modal-box">
<h2 class="card-title px-1">Edit your realm</h2>
<form class="mt-2" onSubmit={editRealm}>
<label class="form-control w-full">
<div class="label">
<span class="label-text">Realm name</span>
</div>
<input value={realm()?.name} name="name" type="text" placeholder="Type here"
class="input input-bordered w-full" />
</label>
<label class="form-control w-full">
<div class="label">
<span class="label-text">Realm description</span>
</div>
<textarea value={realm()?.description} name="description" placeholder="Type here"
class="textarea textarea-bordered w-full" />
</label>
<div class="form-control mt-2">
<label class="label cursor-pointer">
<span class="label-text">Make it public</span>
<input checked={realm()?.is_public} type="checkbox" name="is_public"
class="checkbox checkbox-primary" />
</label>
</div>
<button type="submit" class="btn btn-primary mt-2" disabled={submitting()}>
<Show when={submitting()} fallback={"Submit"}>
<span class="loading"></span>
</Show>
</button>
</form>
</div>
</dialog>
<dialog id="invite-member" class="modal">
<div class="modal-box">
<h2 class="card-title px-1">Invite someone as a member</h2>
<form class="mt-2" onSubmit={inviteMember}>
<label class="form-control w-full">
<div class="label">
<span class="label-text">Username</span>
</div>
<input name="account_name" type="text" placeholder="Type here" class="input input-bordered w-full" />
<div class="label">
<span class="label-text-alt">
Invite someone via their username so that they can publish content in non-public realm.
</span>
</div>
</label>
<button type="submit" class="btn btn-primary mt-2" disabled={submitting()}>
<Show when={submitting()} fallback={"Submit"}>
<span class="loading"></span>
</Show>
</button>
</form>
</div>
</dialog>
<dialog id="kick-member" class="modal">
<div class="modal-box">
<h2 class="card-title px-1">Kick someone out of your realm</h2>
<form class="mt-2" onSubmit={kickMember}>
<label class="form-control w-full">
<div class="label">
<span class="label-text">Username</span>
</div>
<input name="account_name" type="text" placeholder="Type here" class="input input-bordered w-full" />
<div class="label">
<span class="label-text-alt">
Remove someone out of your realm.
</span>
</div>
</label>
<button type="submit" class="btn btn-primary mt-2" disabled={submitting()}>
<Show when={submitting()} fallback={"Submit"}>
<span class="loading"></span>
</Show>
</button>
</form>
</div>
</dialog>
</>
);
}

View File

@ -1,124 +0,0 @@
import { useNavigate, useSearchParams } from "@solidjs/router";
import { createSignal, Show } from "solid-js";
import { createStore } from "solid-js/store";
import { closeModel, openModel } from "../scripts/modals.ts";
import { request } from "../scripts/request.ts";
import PostPublish from "../components/posts/PostPublish.tsx";
import PostList from "../components/posts/PostList.tsx";
export default function SearchPage() {
const [searchParams] = useSearchParams();
const [error, setError] = createSignal<string | null>(null);
const [page, setPage] = createSignal(0);
const [info, setInfo] = createSignal<any>(null);
const navigate = useNavigate();
async function readPosts(pn?: number) {
if (pn) setPage(pn);
const res = await request("/api/posts?" + new URLSearchParams({
take: (10).toString(),
offset: ((page() - 1) * 10).toString(),
...searchParams
}));
if (res.status !== 200) {
setError(await res.text());
} else {
setError(null);
setInfo(await res.json());
}
}
function setMeta(data: any, field: string, open = true) {
const meta: { [id: string]: any } = {
reposting: null,
replying: null,
editing: null
};
meta[field] = data;
setPublishMeta(meta);
if (open) openModel("#post-publish");
else closeModel("#post-publish");
}
const [publishMeta, setPublishMeta] = createStore<any>({
replying: null,
reposting: null,
editing: null
});
function getDescribe() {
let builder = [];
if (searchParams["category"]) {
builder.push("category is #" + searchParams["category"]);
} else if (searchParams["tag"]) {
builder.push("tag is #" + searchParams["tag"]);
}
return builder.join(" and ");
}
function back() {
if (window.history.length > 0) {
window.history.back();
} else {
navigate("/");
}
}
return (
<>
<div class="flex pt-1">
<button class="btn btn-ghost ml-[20px] w-12 h-12" onClick={() => back()}>
<i class="fa-solid fa-angle-left"></i>
</button>
<div class="px-5 flex items-center">
<p>Search</p>
</div>
</div>
<div id="alerts">
<Show when={error()}>
<div role="alert" class="alert alert-error">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="capitalize">{error()}</span>
</div>
</Show>
</div>
<div role="alert" class="alert alert-info px-[20px]">
<i class="fa-solid fa-magnifying-glass pl-[13px]"></i>
<span>You will only see posts with <b>{getDescribe()}</b></span>
</div>
<dialog id="post-publish" class="modal">
<div class="modal-box p-0 w-[540px]">
<PostPublish
reposting={publishMeta.reposting}
replying={publishMeta.replying}
editing={publishMeta.editing}
onReset={() => setMeta(null, "none", false)}
onError={setError}
onPost={() => readPosts()}
/>
</div>
</dialog>
<PostList
info={info()}
onUpdate={readPosts}
onError={setError}
onRepost={(item) => setMeta(item, "reposting")}
onReply={(item) => setMeta(item, "replying")}
onEdit={(item) => setMeta(item, "editing")}
/>
</>
);
}

View File

@ -1,11 +0,0 @@
.wrapper {
display: grid;
grid-template-columns: 1fr;
column-gap: 20px;
}
@media (min-width: 1024px) {
.wrapper {
grid-template-columns: 1fr 2fr 1fr;
}
}

View File

@ -1,28 +0,0 @@
import { createMemo } from "solid-js";
import { useSearchParams } from "@solidjs/router";
import styles from "./view.module.css";
export default function FeedView(props: any) {
const [searchParams] = useSearchParams();
const scrollContentStyles = createMemo(() => {
if (!searchParams["embedded"]) {
return "max-md:mb-[64px]";
} else {
return "h-[100vh]";
}
});
return (
<div class={`${styles.wrapper} container mx-auto`}>
<div id="trending" class="card shadow-xl h-fit"></div>
<div id="content" class={`${scrollContentStyles()} card shadow-xl`}>
{props.children}
</div>
<div id="well-known" class="card shadow-xl h-fit"></div>
</div>
);
}

View File

@ -1,7 +0,0 @@
export function openModel(selector: string) {
document.querySelector<HTMLDialogElement>(selector)?.showModal()
}
export function closeModel(selector: string) {
document.querySelector<HTMLDialogElement>(selector)?.close()
}

View File

@ -1,4 +0,0 @@
export async function request(input: string, init?: RequestInit) {
const prefix = window.__LAUNCHPAD_TARGET__ ?? "";
return await fetch(prefix + input, init)
}

View File

@ -1,73 +0,0 @@
import Cookie from "universal-cookie";
import { createContext, useContext } from "solid-js";
import { createStore } from "solid-js/store";
import { request } from "../scripts/request.ts";
export interface Userinfo {
isLoggedIn: boolean,
displayName: string,
profiles: any,
}
const UserinfoContext = createContext<Userinfo>();
const defaultUserinfo: Userinfo = {
isLoggedIn: false,
displayName: "Citizen",
profiles: null
};
const [userinfo, setUserinfo] = createStore<Userinfo>(structuredClone(defaultUserinfo));
export function getAtk(): string {
return new Cookie().get("identity_auth_key");
}
function checkLoggedIn(): boolean {
return new Cookie().get("identity_auth_key");
}
export async function readProfiles() {
if (!checkLoggedIn()) return;
const res = await request("/api/users/me", {
headers: { "Authorization": `Bearer ${getAtk()}` }
});
if (res.status !== 200) {
clearUserinfo();
window.location.reload();
}
const data = await res.json();
setUserinfo({
isLoggedIn: true,
displayName: data["name"],
profiles: data
});
}
export function clearUserinfo() {
const cookies = document.cookie.split(";");
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i];
const eqPos = cookie.indexOf("=");
const name = eqPos > -1 ? cookie.substring(0, eqPos) : cookie;
document.cookie = name + "=;expires=Thu, 01 Jan 1970 00:00:00 GMT";
}
setUserinfo(defaultUserinfo);
}
export function UserinfoProvider(props: any) {
return (
<UserinfoContext.Provider value={userinfo}>
{props.children}
</UserinfoContext.Provider>
);
}
export function useUserinfo() {
return useContext(UserinfoContext);
}

View File

@ -1,24 +0,0 @@
import { createContext, useContext } from "solid-js";
import { createStore } from "solid-js/store";
import { request } from "../scripts/request.ts";
const WellKnownContext = createContext<any>();
const [wellKnown, setWellKnown] = createStore<any>(null);
export async function readWellKnown() {
const res = await request("/.well-known")
setWellKnown(await res.json())
}
export function WellKnownProvider(props: any) {
return (
<WellKnownContext.Provider value={wellKnown}>
{props.children}
</WellKnownContext.Provider>
);
}
export function useWellKnown() {
return useContext(WellKnownContext);
}

View File

@ -1,44 +0,0 @@
/** @type {import("tailwindcss").Config} */
export default {
content: [
"./src/**/*.{js,jsx,ts,tsx}"
],
daisyui: {
themes: [
{
light: {
...require("daisyui/src/theming/themes")["light"],
primary: "#4750a3",
secondary: "#93c5fd",
accent: "#0f766e",
info: "#67e8f9",
success: "#15803d",
warning: "#f97316",
error: "#dc2626",
"--rounded-box": "0",
"--rounded-btn": "0",
"--rounded-badge": "0",
"--tab-radius": "0"
}
},
{
dark: {
...require("daisyui/src/theming/themes")["dark"],
primary: "#4750a3",
secondary: "#93c5fd",
accent: "#0f766e",
info: "#67e8f9",
success: "#15803d",
warning: "#f97316",
error: "#dc2626",
"--rounded-box": "0",
"--rounded-btn": "0",
"--rounded-badge": "0",
"--tab-radius": "0"
}
}
]
},
plugins: [require("daisyui"), require("@tailwindcss/typography")]
};

View File

@ -1,26 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "ES2015", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"jsxImportSource": "solid-js",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@ -1,10 +0,0 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

View File

@ -1,13 +0,0 @@
import { defineConfig } from "vite";
import solid from "vite-plugin-solid";
import devtools from "solid-devtools/vite";
export default defineConfig({
plugins: [devtools({ autoname: true }), solid()],
server: {
proxy: {
"/api": "http://localhost:8445",
"/.well-known": "http://localhost:8445"
}
}
});

15
pkg/views/.eslintrc.cjs Normal file
View File

@ -0,0 +1,15 @@
/* eslint-env node */
require('@rushstack/eslint-patch/modern-module-resolution')
module.exports = {
root: true,
'extends': [
'plugin:vue/vue3-essential',
'eslint:recommended',
'@vue/eslint-config-typescript',
'@vue/eslint-config-prettier/skip-formatting'
],
parserOptions: {
ecmaVersion: 'latest'
}
}

30
pkg/views/.gitignore vendored Normal file
View File

@ -0,0 +1,30 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
/cypress/videos/
/cypress/screenshots/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo

View File

@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": false,
"tabWidth": 2,
"singleQuote": false,
"printWidth": 120,
"trailingComma": "none"
}

8
pkg/views/.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,8 @@
{
"recommendations": [
"Vue.volar",
"Vue.vscode-typescript-vue-plugin",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode"
]
}

46
pkg/views/README.md Normal file
View File

@ -0,0 +1,46 @@
# views
This template should help get you started developing with Vue 3 in Vite.
## Recommended IDE Setup
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin).
## Type Support for `.vue` Imports in TS
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin) to make the TypeScript language service aware of `.vue` types.
If the standalone TypeScript plugin doesn't feel fast enough to you, Volar has also implemented a [Take Over Mode](https://github.com/johnsoncodehk/volar/discussions/471#discussioncomment-1361669) that is more performant. You can enable it by the following steps:
1. Disable the built-in TypeScript Extension
1) Run `Extensions: Show Built-in Extensions` from VSCode's command palette
2) Find `TypeScript and JavaScript Language Features`, right click and select `Disable (Workspace)`
2. Reload the VSCode window by running `Developer: Reload Window` from the command palette.
## Customize configuration
See [Vite Configuration Reference](https://vitejs.dev/config/).
## Project Setup
```sh
npm install
```
### Compile and Hot-Reload for Development
```sh
npm run dev
```
### Type-Check, Compile and Minify for Production
```sh
npm run build
```
### Lint with [ESLint](https://eslint.org/)
```sh
npm run lint
```

BIN
pkg/views/bun.lockb Executable file

Binary file not shown.

13
pkg/views/index.html Normal file
View File

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Goatplaza</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

41
pkg/views/package.json Normal file
View File

@ -0,0 +1,41 @@
{
"name": "views",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview",
"build-only": "vite build",
"type-check": "vue-tsc --build --force",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
"format": "prettier --write src/"
},
"dependencies": {
"@mdi/font": "^7.4.47",
"@unocss/reset": "^0.58.5",
"pinia": "^2.1.7",
"unocss": "^0.58.5",
"vue": "^3.4.15",
"vue-router": "^4.2.5",
"vuetify": "^3.5.7"
},
"devDependencies": {
"@rushstack/eslint-patch": "^1.3.3",
"@tsconfig/node20": "^20.1.2",
"@types/node": "^20.11.10",
"@vitejs/plugin-vue": "^5.0.3",
"@vitejs/plugin-vue-jsx": "^3.1.0",
"@vue/eslint-config-prettier": "^8.0.0",
"@vue/eslint-config-typescript": "^12.0.0",
"@vue/tsconfig": "^0.5.1",
"eslint": "^8.49.0",
"eslint-plugin-vue": "^9.17.0",
"npm-run-all2": "^6.1.1",
"prettier": "^3.0.3",
"typescript": "~5.3.0",
"vite": "^5.0.11",
"vue-tsc": "^1.8.27"
}
}

5
pkg/views/src/index.vue Normal file
View File

@ -0,0 +1,5 @@
<template>
<v-app>
<router-view />
</v-app>
</template>

View File

@ -0,0 +1,27 @@
<template>
<v-navigation-drawer v-model="drawerOpen" color="grey-lighten-5" floating>
<div class="d-flex text-center justify-center items-center h-[64px]">
<h1>Goatplaza</h1>
</div>
</v-navigation-drawer>
<v-app-bar height="64" color="primary" scroll-behavior="elevate" flat>
<div class="container mx-auto px-5">
<v-app-bar-nav-icon variant="text" @click.stop="toggleDrawer"></v-app-bar-nav-icon>
</div>
</v-app-bar>
<v-main>
<router-view />
</v-main>
</template>
<script setup>
import { ref } from "vue"
const drawerOpen = ref(true)
function toggleDrawer() {
drawerOpen.value = !drawerOpen.value
}
</script>

41
pkg/views/src/main.ts Normal file
View File

@ -0,0 +1,41 @@
import "virtual:uno.css"
import { createApp } from "vue"
import { createPinia } from "pinia"
import "vuetify/styles"
import { createVuetify } from "vuetify"
import * as components from "vuetify/components"
import * as directives from "vuetify/directives"
import "@mdi/font/css/materialdesignicons.min.css"
import index from "./index.vue"
import router from "./router"
const app = createApp(index)
app.use(
createVuetify({
components,
directives,
theme: {
themes: {
light: {
primary: "#4a5099",
secondary: "#2196f3",
accent: "#009688",
error: "#f44336",
warning: "#ff9800",
info: "#03a9f4",
success: "#4caf50"
}
}
}
})
)
app.use(createPinia())
app.use(router)
app.mount("#app")

View File

@ -0,0 +1,22 @@
import { createRouter, createWebHistory } from "vue-router"
import MasterLayout from "@/layouts/master.vue"
import LandingPage from "@/views/landing.vue"
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: "/",
component: MasterLayout,
children: [
{
path: "/",
name: "landing",
component: LandingPage
}
]
}
]
})
export default router

View File

@ -0,0 +1,3 @@
<template>
<div>Good morning!</div>
</template>

View File

@ -0,0 +1,14 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}

11
pkg/views/tsconfig.json Normal file
View File

@ -0,0 +1,11 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.app.json"
}
]
}

View File

@ -0,0 +1,19 @@
{
"extends": "@tsconfig/node20/tsconfig.json",
"include": [
"vite.config.*",
"vitest.config.*",
"cypress.config.*",
"nightwatch.conf.*",
"playwright.config.*"
],
"compilerOptions": {
"composite": true,
"noEmit": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"module": "ESNext",
"moduleResolution": "Bundler",
"types": ["node"]
}
}

5
pkg/views/uno.config.ts Normal file
View File

@ -0,0 +1,5 @@
import { defineConfig, presetUno } from "unocss"
export default defineConfig({
presets: [presetUno({ preflight: false })]
})

16
pkg/views/vite.config.ts Normal file
View File

@ -0,0 +1,16 @@
import { fileURLToPath, URL } from "node:url"
import { defineConfig } from "vite"
import vue from "@vitejs/plugin-vue"
import vueJsx from "@vitejs/plugin-vue-jsx"
import unocss from "unocss/vite"
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue(), vueJsx(), unocss()],
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url))
}
}
})