revise all
This commit is contained in:
commit
bf68826881
23
.gitignore
vendored
Normal file
23
.gitignore
vendored
Normal file
@ -0,0 +1,23 @@
|
||||
node_modules
|
||||
|
||||
# Output
|
||||
.output
|
||||
.vercel
|
||||
.netlify
|
||||
.wrangler
|
||||
/.svelte-kit
|
||||
/build
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.test
|
||||
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
4
.prettierignore
Normal file
4
.prettierignore
Normal file
@ -0,0 +1,4 @@
|
||||
# Package Managers
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
yarn.lock
|
18
.prettierrc
Normal file
18
.prettierrc
Normal file
@ -0,0 +1,18 @@
|
||||
{
|
||||
"useTabs": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 100,
|
||||
"plugins": [
|
||||
"prettier-plugin-svelte",
|
||||
"prettier-plugin-tailwindcss"
|
||||
],
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.svelte",
|
||||
"options": {
|
||||
"parser": "svelte"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
38
README.md
Normal file
38
README.md
Normal file
@ -0,0 +1,38 @@
|
||||
# sv
|
||||
|
||||
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
|
||||
|
||||
## Creating a project
|
||||
|
||||
If you're seeing this, you've probably already done this step. Congrats!
|
||||
|
||||
```bash
|
||||
# create a new project in the current directory
|
||||
npx sv create
|
||||
|
||||
# create a new project in my-app
|
||||
npx sv create my-app
|
||||
```
|
||||
|
||||
## Developing
|
||||
|
||||
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
|
||||
# or start the server and open the app in a new browser tab
|
||||
npm run dev -- --open
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
To create a production version of your app:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
You can preview the production build with `npm run preview`.
|
||||
|
||||
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
14
components.json
Normal file
14
components.json
Normal file
@ -0,0 +1,14 @@
|
||||
{
|
||||
"$schema": "https://shadcn-svelte.com/schema.json",
|
||||
"style": "default",
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.ts",
|
||||
"css": "src/app.css",
|
||||
"baseColor": "slate"
|
||||
},
|
||||
"aliases": {
|
||||
"components": "$lib/components",
|
||||
"utils": "$lib/utils"
|
||||
},
|
||||
"typescript": true
|
||||
}
|
2
justfile
Normal file
2
justfile
Normal file
@ -0,0 +1,2 @@
|
||||
genpb:
|
||||
protoc --plugin=./node_modules/.bin/protoc-gen-ts_proto --ts_proto_out=./src/lib/protobuf/ ./src/lib/protobuf/definitions/*
|
2927
package-lock.json
generated
Normal file
2927
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
44
package.json
Normal file
44
package.json
Normal file
@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "niming-v2",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"format": "prettier --write .",
|
||||
"lint": "prettier --check ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify/json": "^2.2.291",
|
||||
"@sveltejs/adapter-auto": "^3.0.0",
|
||||
"@sveltejs/adapter-static": "^3.0.8",
|
||||
"@sveltejs/kit": "^2.0.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"clsx": "^2.1.1",
|
||||
"prettier": "^3.3.2",
|
||||
"prettier-plugin-svelte": "^3.2.6",
|
||||
"prettier-plugin-tailwindcss": "^0.6.5",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwind-variants": "^0.3.0",
|
||||
"tailwindcss": "^3.4.9",
|
||||
"typescript": "^5.0.0",
|
||||
"unplugin-icons": "^0.22.0",
|
||||
"vite": "^5.4.11"
|
||||
},
|
||||
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e",
|
||||
"dependencies": {
|
||||
"@protobuf-ts/plugin": "^2.9.4",
|
||||
"axios": "^1.7.9",
|
||||
"bits-ui": "^0.22.0",
|
||||
"dompurify": "^3.2.3",
|
||||
"mode-watcher": "^0.5.0",
|
||||
"ts-proto": "^2.6.0"
|
||||
}
|
||||
}
|
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
};
|
31
src/app.css
Normal file
31
src/app.css
Normal file
@ -0,0 +1,31 @@
|
||||
@import "./color.css";
|
||||
@import "./fonts.css";
|
||||
@import "./bits-ui.css";
|
||||
@import "https://cdn.plyr.io/3.7.8/plyr.css";
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
body {
|
||||
/* @apply bg-background text-foreground; */
|
||||
background-color: hsl(var(--background));
|
||||
color: hsl(var(--foreground));
|
||||
font-family: "PingFang";
|
||||
width: 100dvw;
|
||||
min-height: 100dvh;
|
||||
box-sizing: border-box;
|
||||
|
||||
a {
|
||||
color: hsl(var(--secondary))
|
||||
}
|
||||
|
||||
margin:0px;
|
||||
}
|
||||
|
||||
}
|
14
src/app.d.ts
vendored
Normal file
14
src/app.d.ts
vendored
Normal file
@ -0,0 +1,14 @@
|
||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
// for information about these interfaces
|
||||
import 'unplugin-icons/types/svelte'
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export { };
|
12
src/app.html
Normal file
12
src/app.html
Normal file
@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
30
src/bits-ui.css
Normal file
30
src/bits-ui.css
Normal file
@ -0,0 +1,30 @@
|
||||
@import "./color.css";
|
||||
|
||||
.post_trigger {
|
||||
padding: 10px 20px;
|
||||
max-height: 60dvh;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
font-size: 15px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.post_overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 40;
|
||||
backdrop-filter: grayscale(30%) brightness(50%);
|
||||
}
|
||||
|
||||
.post_content {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 50;
|
||||
background-color: #0f291f;
|
||||
}
|
||||
|
||||
.scroll>div {
|
||||
width: 100%;
|
||||
}
|
65
src/color.css
Normal file
65
src/color.css
Normal file
@ -0,0 +1,65 @@
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 192, 9.0%, 32.7%;
|
||||
--foreground: 47, 60.0%, 89.2%;
|
||||
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
|
||||
--popover: 222.2 84% 4.9%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
|
||||
--card: 105 7% 43%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
|
||||
--primary: 210 40% 98%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
|
||||
--accent: 217.2 32.6% 17.5%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
|
||||
--ring: 212.7 26.8% 83.9%;
|
||||
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 192, 9.0%, 32.7%;
|
||||
--foreground: 47, 60.0%, 89.2%;
|
||||
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
|
||||
--popover: 222.2 84% 4.9%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
|
||||
--card: 105 7% 43%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
|
||||
--primary: 210 40% 98%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
|
||||
--accent: 217.2 32.6% 17.5%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
|
||||
--ring: 212.7 26.8% 83.9%;
|
||||
}
|
||||
}
|
35
src/fonts.css
Normal file
35
src/fonts.css
Normal file
@ -0,0 +1,35 @@
|
||||
@font-face {
|
||||
font-family: "PingFang";
|
||||
src:
|
||||
local('PingFang Bold'), src("/PingFang Bold.woff2") type("woff2");
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "PingFang";
|
||||
src:
|
||||
local('PingFang Medium'), src("/PingFang Medium.woff2") type("woff2");
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "PingFang";
|
||||
src:
|
||||
local('PingFang Heavy'), src("/PingFang Heavy.woff2") type("woff2");
|
||||
font-weight: bolder;
|
||||
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "PingFang";
|
||||
src:
|
||||
local('PingFang Light'), src("/PingFang Light.woff2") type("woff2");
|
||||
font-weight: lighter;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "PingFang";
|
||||
src:
|
||||
local('PingFang Regular'), src("/PingFang Regular.woff2") type("woff2");
|
||||
font-weight: normal;
|
||||
}
|
21
src/lib/Button.svelte
Normal file
21
src/lib/Button.svelte
Normal file
@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { MouseEventHandler } from 'svelte/elements';
|
||||
let {
|
||||
children,
|
||||
onclick = undefined,
|
||||
description = 'default text'
|
||||
}: {
|
||||
onclick: MouseEventHandler<HTMLButtonElement> | undefined;
|
||||
children: Snippet;
|
||||
description: string;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<button {onclick}> {@render children()}</button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>{description}</Tooltip.Content>
|
||||
</Tooltip.Root>
|
136
src/lib/Editor.svelte
Normal file
136
src/lib/Editor.svelte
Normal file
@ -0,0 +1,136 @@
|
||||
<script lang="ts">
|
||||
import Button from './Button.svelte';
|
||||
import ScrollArea from './components/ui/scroll-area/scroll-area.svelte';
|
||||
import Textarea from './components/ui/textarea/textarea.svelte';
|
||||
import { Dialog } from 'bits-ui';
|
||||
import MynauiImageRectangle from '~icons/mynaui/image-rectangle';
|
||||
import MynauiAddQueueSolid from '~icons/mynaui/add-queue-solid';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
|
||||
let { mode } = $props();
|
||||
|
||||
let post_message = $state('');
|
||||
|
||||
const save_draft = () => {
|
||||
//determine cookie id
|
||||
let kv_pair = document.cookie.split(';').map((e) => e.trimStart());
|
||||
let last_id = kv_pair.findLast((e) => e.startsWith('postdraft'));
|
||||
//default id = 0
|
||||
let id = 0;
|
||||
// find last id and plus one
|
||||
if (last_id) {
|
||||
id = Number.parseInt(last_id.split('=')[0].split('_')[1]) + 1;
|
||||
}
|
||||
|
||||
let draft_message = window.btoa(post_message).replaceAll('=', '#');
|
||||
|
||||
//save
|
||||
console.log(`postdraft_${id}=${draft_message}`);
|
||||
document.cookie = `postdraft_${id}=${draft_message}`;
|
||||
};
|
||||
|
||||
let locker = false;
|
||||
const debounce = (callback: Function) => {
|
||||
if (!locker) {
|
||||
locker = true;
|
||||
callback();
|
||||
setTimeout(() => {
|
||||
locker = false;
|
||||
}, 2000);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
let selected_images = new SvelteMap<string, File>();
|
||||
|
||||
const image_to_url = $derived.by(() => {
|
||||
let name_with_url: Map<string, string> = new Map<string, string>();
|
||||
for (const file of selected_images.values()) {
|
||||
name_with_url.set(file.name, URL.createObjectURL(file));
|
||||
}
|
||||
return name_with_url;
|
||||
});
|
||||
|
||||
const add_image = (_: MouseEvent) => {
|
||||
console.log('add_image');
|
||||
// pseudo input element
|
||||
let file_input = document.createElement('input');
|
||||
file_input.type = 'file';
|
||||
file_input.accept = '.gif,.png,.jpeg,.jpg,.webm,.heic,.heif,.mp4,.webp';
|
||||
file_input.multiple = true;
|
||||
file_input.classList.add('hidden');
|
||||
file_input.onchange = () => {
|
||||
for (let iter = 0; iter < (file_input.files ? file_input.files.length : 0); iter++) {
|
||||
const file = file_input.files?.item(iter);
|
||||
if (file) {
|
||||
selected_images.set(file.name, file);
|
||||
}
|
||||
}
|
||||
};
|
||||
file_input.onclose = () => {
|
||||
file_input.remove();
|
||||
};
|
||||
file_input.click();
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if mode === 'wide'}
|
||||
<div
|
||||
class="border-box mb-7 h-[60dvh] self-end rounded-md bg-primary px-3 pt-1.5 text-primary-foreground"
|
||||
style="margin-inline: 7.5%"
|
||||
>
|
||||
<div class="relative h-10">
|
||||
<button class="absolute left-0" onclickcapture={() => debounce(save_draft)}>儲存</button>
|
||||
<button class="absolute right-0">送出</button>
|
||||
</div>
|
||||
<Textarea placeholder="匿名訊息" class="h-2/3 resize-none" bind:value={post_message}></Textarea>
|
||||
<div class="text-2xl">
|
||||
<!-- <button aria-label="add image"><MynauiImageRectangle></MynauiImageRectangle></button> -->
|
||||
<Button description="附加圖片" onclick={add_image}
|
||||
><MynauiImageRectangle></MynauiImageRectangle></Button
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<ScrollArea>
|
||||
<div class="flex w-max flex-row gap-3">
|
||||
{#each image_to_url as map}
|
||||
<img src={map[1]} class="rounded-md md:h-10" alt={map[0]} />
|
||||
{/each}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
{:else if mode === 'compact'}
|
||||
<Dialog.Root>
|
||||
<Dialog.Trigger class="fixed bottom-10 right-10">
|
||||
<MynauiAddQueueSolid class="text-3xl" />
|
||||
</Dialog.Trigger>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay class="fixed inset-0 z-50 bg-black/50" />
|
||||
<Dialog.Content
|
||||
class="fixed left-[50%] top-[50%] z-50 h-fit w-[85dvw] -translate-x-1/2 -translate-y-1/2 rounded-2xl bg-popover px-3 py-2"
|
||||
>
|
||||
<div class="relative h-10">
|
||||
<button class="absolute left-0" onclickcapture={() => debounce(save_draft)}>儲存</button>
|
||||
<button class="absolute right-0">送出</button>
|
||||
</div>
|
||||
<Textarea placeholder="匿名訊息" class="min-h-40 resize-none" bind:value={post_message}
|
||||
></Textarea>
|
||||
<div class="text-2xl">
|
||||
<!-- <button aria-label="add image"><MynauiImageRectangle></MynauiImageRectangle></button> -->
|
||||
<Button description="附加圖片" onclick={add_image}
|
||||
><MynauiImageRectangle></MynauiImageRectangle></Button
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<ScrollArea orientation="horizontal">
|
||||
<div class="flex w-max flex-row gap-3">
|
||||
{#each image_to_url as map}
|
||||
<img src={map[1]} class="h-52 rounded-md" alt={map[0]} />
|
||||
{/each}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
{/if}
|
52
src/lib/Images.svelte
Normal file
52
src/lib/Images.svelte
Normal file
@ -0,0 +1,52 @@
|
||||
<script lang="ts">
|
||||
import axios from 'axios';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
|
||||
let { hashIds }: { hashIds: Array<string> } = $props();
|
||||
|
||||
let filetype_url_map = new SvelteMap<string, string>();
|
||||
|
||||
let len = $state(0);
|
||||
|
||||
for (const hash of hashIds) {
|
||||
axios.head(`/article/file/${hash}`).then((e) => {
|
||||
const type = e.headers['content-type']?.toString();
|
||||
if (type) {
|
||||
filetype_url_map.set(`/article/file/${hash}`, type);
|
||||
len++;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const single = (): [string, string] => {
|
||||
return filetype_url_map.entries().toArray()[0];
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if len > 1}
|
||||
<ScrollArea
|
||||
class="my-auto h-fit w-fit max-w-[90%] whitespace-nowrap rounded-md border-0"
|
||||
orientation="horizontal"
|
||||
>
|
||||
<div class="flex h-36 w-max items-center space-x-4 lg:h-64">
|
||||
{#each filetype_url_map as map}
|
||||
{#if map[1].startsWith('image')}
|
||||
<img src={map[0]} class="top-0 h-full object-contain" alt="Pic" />
|
||||
{:else if map[1].startsWith('video')}
|
||||
<video class="h-36 lg:h-64" id="player" playsinline controls>
|
||||
<source src={map[0]} type={map[1]} />
|
||||
</video>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
{:else if len == 1}
|
||||
{#if single()[1].startsWith('image')}
|
||||
<img src={single()[0]} class="h-36 w-fit rounded-md object-contain lg:h-64" alt="Pic" />
|
||||
{:else if single()[1].startsWith('video')}
|
||||
<video class="h-36 lg:h-64" src={single()[0]}></video>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
</style>
|
43
src/lib/Navigation.svelte
Normal file
43
src/lib/Navigation.svelte
Normal file
@ -0,0 +1,43 @@
|
||||
<script lang="ts">
|
||||
import { ModeWatcher } from 'mode-watcher';
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<div id="container">
|
||||
<a href="/" class="left"><h1>匿名中工</h1> </a>
|
||||
<ModeWatcher />
|
||||
<a href="/tos">服務條款</a>
|
||||
<a href="/" target="_blank">IG</a>
|
||||
{@render children()}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
#container {
|
||||
width: 100dvw;
|
||||
height: 10dvh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
box-sizing: border-box;
|
||||
padding: 0px 10px;
|
||||
position: sticky;
|
||||
top: 0px;
|
||||
z-index: 50;
|
||||
a {
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
margin-top: auto;
|
||||
margin-bottom: auto;
|
||||
h1 {
|
||||
margin: auto 0 auto;
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
}
|
||||
gap: 20px;
|
||||
}
|
||||
.left {
|
||||
margin-right: auto;
|
||||
}
|
||||
</style>
|
46
src/lib/Post.svelte
Normal file
46
src/lib/Post.svelte
Normal file
@ -0,0 +1,46 @@
|
||||
<script lang="ts">
|
||||
import { FetchResponse_Message } from './protobuf/niming';
|
||||
import { Dialog, ScrollArea } from 'bits-ui';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { fade } from 'svelte/transition';
|
||||
import Images from './Images.svelte';
|
||||
let { post }: { post: FetchResponse_Message } = $props();
|
||||
|
||||
const url = /(https|http):\/\/[\S]*/g;
|
||||
const preprocess = (text: string) => {
|
||||
// Replace https:// or http:// text with <a href="">
|
||||
text.replaceAll(url, (sub) => {
|
||||
return `<a href=${sub}>${sub}</a>`;
|
||||
});
|
||||
|
||||
return DOMPurify.sanitize(text);
|
||||
};
|
||||
</script>
|
||||
|
||||
<div id="container">
|
||||
<Dialog.Root>
|
||||
<Dialog.Trigger class="post_trigger mb-10 rounded-lg bg-card text-card-foreground">
|
||||
<p class="h-fit max-h-[40dvh] overflow-y-hidden text-ellipsis text-pretty break-words">
|
||||
{@html preprocess(post.content)}
|
||||
</p>
|
||||
<!-- <div class="pt-10"></div> -->
|
||||
<Images hashIds={post.filesHash}></Images>
|
||||
</Dialog.Trigger>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay transition={fade} transitionConfig={{ duration: 150 }} class="post_overlay" />
|
||||
<Dialog.Content class="post_content h-fit min-h-[40dvh] w-[85dvw] rounded-lg lg:w-[50dvw]">
|
||||
<Dialog.Close>Close</Dialog.Close>
|
||||
<p class="h-fit text-pretty break-words">
|
||||
{@html preprocess(post.content)}
|
||||
</p>
|
||||
<Images hashIds={post.filesHash}></Images>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
#container {
|
||||
display: contents;
|
||||
}
|
||||
</style>
|
81
src/lib/api.ts
Normal file
81
src/lib/api.ts
Normal file
@ -0,0 +1,81 @@
|
||||
import { FetchResponse, Post, PostResponse } from '$lib/protobuf/niming';
|
||||
import axios, { type AxiosRequestConfig, type AxiosResponse } from 'axios';
|
||||
|
||||
|
||||
|
||||
export class Result<T> {
|
||||
status: number
|
||||
enclose?: T
|
||||
message?: string
|
||||
constructor(status: number, enclose?: T, message?: string) {
|
||||
this.status = status;
|
||||
this.enclose = enclose;
|
||||
this.message = message;
|
||||
}
|
||||
ok(): boolean { return this.status == 1 }
|
||||
set_status(status: number) {
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function axios_request(method: string, url: string, params?: Map<string, string> | Object, data?: Uint8Array): Promise<AxiosResponse<ArrayBuffer, any>> {
|
||||
let cfg: AxiosRequestConfig<Uint8Array> = {
|
||||
method,
|
||||
url,
|
||||
params,
|
||||
data,
|
||||
responseType: "arraybuffer",
|
||||
headers: { 'Content-Type': 'application/x-protobuf' },
|
||||
}
|
||||
|
||||
return axios(cfg)
|
||||
}
|
||||
|
||||
export class API {
|
||||
page: number
|
||||
constructor() {
|
||||
this.page = 0
|
||||
}
|
||||
async send_post(content: string, refer: bigint | undefined, files: File[] | undefined) {
|
||||
let files_buf: Uint8Array[] = [];
|
||||
files?.forEach((e) => {
|
||||
e.arrayBuffer().then((arrbuf) => {
|
||||
files_buf.push(new Uint8Array(arrbuf));
|
||||
});
|
||||
});
|
||||
|
||||
let ctx = Post.create({ content: content, files: files_buf });
|
||||
if (refer !== undefined) {
|
||||
ctx.ref = refer;
|
||||
}
|
||||
|
||||
let result = new Result<PostResponse>(0)
|
||||
|
||||
await axios_request('post', '/article', undefined, Post.toBinary(ctx))
|
||||
.then(e => {
|
||||
result.enclose = PostResponse.fromBinary(new Uint8Array(e.data))
|
||||
result.set_status(1);
|
||||
}).catch(e => {
|
||||
result.message = e.response?.data
|
||||
});
|
||||
return result;
|
||||
}
|
||||
async fetch(): Promise<Result<FetchResponse>> {
|
||||
let result = new Result<FetchResponse>(0)
|
||||
|
||||
await axios_request('get', '/article/list', { page: this.page })
|
||||
.then(e => {
|
||||
result.enclose = FetchResponse.fromBinary(new Uint8Array(e.data))
|
||||
result.set_status(1);
|
||||
this.page += 1;
|
||||
})
|
||||
.catch(e => {
|
||||
result.message = e.response?.data
|
||||
})
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
let api_inst = new API();
|
||||
export default api_inst;
|
10
src/lib/components/ui/scroll-area/index.ts
Normal file
10
src/lib/components/ui/scroll-area/index.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import Scrollbar from "./scroll-area-scrollbar.svelte";
|
||||
import Root from "./scroll-area.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
Scrollbar,
|
||||
//,
|
||||
Root as ScrollArea,
|
||||
Scrollbar as ScrollAreaScrollbar,
|
||||
};
|
@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import { ScrollArea as ScrollAreaPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
type $$Props = ScrollAreaPrimitive.ScrollbarProps & {
|
||||
orientation?: "vertical" | "horizontal";
|
||||
};
|
||||
|
||||
let className: $$Props["class"] = undefined;
|
||||
export let orientation: $$Props["orientation"] = "vertical";
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<ScrollAreaPrimitive.Scrollbar
|
||||
{orientation}
|
||||
class={cn(
|
||||
"flex touch-none select-none transition-colors",
|
||||
orientation === "vertical" && "h-full w-2.5 border-l border-l-transparent p-px",
|
||||
orientation === "horizontal" && "h-2.5 w-full border-t border-t-transparent p-px",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<slot />
|
||||
<ScrollAreaPrimitive.Thumb
|
||||
class={cn("bg-border relative rounded-full", orientation === "vertical" && "flex-1")}
|
||||
/>
|
||||
</ScrollAreaPrimitive.Scrollbar>
|
38
src/lib/components/ui/scroll-area/scroll-area.svelte
Normal file
38
src/lib/components/ui/scroll-area/scroll-area.svelte
Normal file
@ -0,0 +1,38 @@
|
||||
<script lang="ts">
|
||||
import { ScrollArea as ScrollAreaPrimitive } from 'bits-ui';
|
||||
import { Scrollbar } from './index.js';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
type $$Props = ScrollAreaPrimitive.Props & {
|
||||
orientation?: 'vertical' | 'horizontal' | 'both';
|
||||
scrollbarXClasses?: string;
|
||||
scrollbarYClasses?: string;
|
||||
};
|
||||
|
||||
let className: $$Props['class'] = undefined;
|
||||
export { className as class };
|
||||
export let orientation = 'vertical';
|
||||
export let scrollbarXClasses: string = '';
|
||||
export let scrollbarYClasses: string = '';
|
||||
</script>
|
||||
|
||||
<ScrollAreaPrimitive.Root {...$$restProps} class={cn('relative overflow-hidden', className)}>
|
||||
<ScrollAreaPrimitive.Viewport class="h-full w-full rounded-[inherit]">
|
||||
{#if orientation === 'vertical'}
|
||||
<ScrollAreaPrimitive.Content class="block w-full" style="">
|
||||
<slot />
|
||||
</ScrollAreaPrimitive.Content>
|
||||
{:else}
|
||||
<ScrollAreaPrimitive.Content>
|
||||
<slot />
|
||||
</ScrollAreaPrimitive.Content>
|
||||
{/if}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
{#if orientation === 'vertical' || orientation === 'both'}
|
||||
<Scrollbar orientation="vertical" class={scrollbarYClasses} />
|
||||
{/if}
|
||||
{#if orientation === 'horizontal' || orientation === 'both'}
|
||||
<Scrollbar orientation="horizontal" class={scrollbarXClasses} />
|
||||
{/if}
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
7
src/lib/components/ui/separator/index.ts
Normal file
7
src/lib/components/ui/separator/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import Root from "./separator.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Separator,
|
||||
};
|
22
src/lib/components/ui/separator/separator.svelte
Normal file
22
src/lib/components/ui/separator/separator.svelte
Normal file
@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { Separator as SeparatorPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
type $$Props = SeparatorPrimitive.Props;
|
||||
|
||||
let className: $$Props["class"] = undefined;
|
||||
export let orientation: $$Props["orientation"] = "horizontal";
|
||||
export let decorative: $$Props["decorative"] = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<SeparatorPrimitive.Root
|
||||
class={cn(
|
||||
"bg-border shrink-0",
|
||||
orientation === "horizontal" ? "h-[1px] w-full" : "min-h-full w-[1px]",
|
||||
className
|
||||
)}
|
||||
{orientation}
|
||||
{decorative}
|
||||
{...$$restProps}
|
||||
/>
|
28
src/lib/components/ui/textarea/index.ts
Normal file
28
src/lib/components/ui/textarea/index.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import Root from "./textarea.svelte";
|
||||
|
||||
type FormTextareaEvent<T extends Event = Event> = T & {
|
||||
currentTarget: EventTarget & HTMLTextAreaElement;
|
||||
};
|
||||
|
||||
type TextareaEvents = {
|
||||
blur: FormTextareaEvent<FocusEvent>;
|
||||
change: FormTextareaEvent<Event>;
|
||||
click: FormTextareaEvent<MouseEvent>;
|
||||
focus: FormTextareaEvent<FocusEvent>;
|
||||
keydown: FormTextareaEvent<KeyboardEvent>;
|
||||
keypress: FormTextareaEvent<KeyboardEvent>;
|
||||
keyup: FormTextareaEvent<KeyboardEvent>;
|
||||
mouseover: FormTextareaEvent<MouseEvent>;
|
||||
mouseenter: FormTextareaEvent<MouseEvent>;
|
||||
mouseleave: FormTextareaEvent<MouseEvent>;
|
||||
paste: FormTextareaEvent<ClipboardEvent>;
|
||||
input: FormTextareaEvent<InputEvent>;
|
||||
};
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Textarea,
|
||||
type TextareaEvents,
|
||||
type FormTextareaEvent,
|
||||
};
|
38
src/lib/components/ui/textarea/textarea.svelte
Normal file
38
src/lib/components/ui/textarea/textarea.svelte
Normal file
@ -0,0 +1,38 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLTextareaAttributes } from "svelte/elements";
|
||||
import type { TextareaEvents } from "./index.js";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
type $$Props = HTMLTextareaAttributes;
|
||||
type $$Events = TextareaEvents;
|
||||
|
||||
let className: $$Props["class"] = undefined;
|
||||
export let value: $$Props["value"] = undefined;
|
||||
export { className as class };
|
||||
|
||||
// Workaround for https://github.com/sveltejs/svelte/issues/9305
|
||||
// Fixed in Svelte 5, but not backported to 4.x.
|
||||
export let readonly: $$Props["readonly"] = undefined;
|
||||
</script>
|
||||
|
||||
<textarea
|
||||
class={cn(
|
||||
"border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex min-h-[80px] w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
bind:value
|
||||
{readonly}
|
||||
on:blur
|
||||
on:change
|
||||
on:click
|
||||
on:focus
|
||||
on:keydown
|
||||
on:keypress
|
||||
on:keyup
|
||||
on:mouseover
|
||||
on:mouseenter
|
||||
on:mouseleave
|
||||
on:paste
|
||||
on:input
|
||||
{...$$restProps}
|
||||
></textarea>
|
15
src/lib/components/ui/tooltip/index.ts
Normal file
15
src/lib/components/ui/tooltip/index.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { Tooltip as TooltipPrimitive } from "bits-ui";
|
||||
import Content from "./tooltip-content.svelte";
|
||||
|
||||
const Root = TooltipPrimitive.Root;
|
||||
const Trigger = TooltipPrimitive.Trigger;
|
||||
|
||||
export {
|
||||
Root,
|
||||
Trigger,
|
||||
Content,
|
||||
//
|
||||
Root as Tooltip,
|
||||
Content as TooltipContent,
|
||||
Trigger as TooltipTrigger,
|
||||
};
|
28
src/lib/components/ui/tooltip/tooltip-content.svelte
Normal file
28
src/lib/components/ui/tooltip/tooltip-content.svelte
Normal file
@ -0,0 +1,28 @@
|
||||
<script lang="ts">
|
||||
import { Tooltip as TooltipPrimitive } from "bits-ui";
|
||||
import { cn, flyAndScale } from "$lib/utils.js";
|
||||
|
||||
type $$Props = TooltipPrimitive.ContentProps;
|
||||
|
||||
let className: $$Props["class"] = undefined;
|
||||
export let sideOffset: $$Props["sideOffset"] = 4;
|
||||
export let transition: $$Props["transition"] = flyAndScale;
|
||||
export let transitionConfig: $$Props["transitionConfig"] = {
|
||||
y: 8,
|
||||
duration: 150,
|
||||
};
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<TooltipPrimitive.Content
|
||||
{transition}
|
||||
{transitionConfig}
|
||||
{sideOffset}
|
||||
class={cn(
|
||||
"bg-popover text-popover-foreground z-50 overflow-hidden rounded-md border px-3 py-1.5 text-sm shadow-md",
|
||||
className
|
||||
)}
|
||||
{...$$restProps}
|
||||
>
|
||||
<slot />
|
||||
</TooltipPrimitive.Content>
|
1
src/lib/index.ts
Normal file
1
src/lib/index.ts
Normal file
@ -0,0 +1 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
38
src/lib/protobuf/definitions/niming.proto
Normal file
38
src/lib/protobuf/definitions/niming.proto
Normal file
@ -0,0 +1,38 @@
|
||||
syntax = "proto3";
|
||||
|
||||
// This is for posting a paragraph.
|
||||
message Post {
|
||||
string content = 1;
|
||||
// reply to a post, like a mail chat.
|
||||
optional int64 ref = 2;
|
||||
repeated bytes files = 3;
|
||||
}
|
||||
|
||||
enum Status {
|
||||
Failed = 0;
|
||||
Success = 1;
|
||||
}
|
||||
|
||||
// The response of the posting, defining what should return.
|
||||
message PostResponse {
|
||||
Status status = 1;
|
||||
string hash = 2;
|
||||
uint64 id = 3;
|
||||
optional string failed_message = 4;
|
||||
}
|
||||
|
||||
message FetchResponse {
|
||||
message Message {
|
||||
uint64 id = 1;
|
||||
string content = 2;
|
||||
// reply to a post, like a mail chat.
|
||||
// optional uint64 ref = 3;
|
||||
// request files through /article/file/<id> with MIME type.
|
||||
// See it as a BLOB url;
|
||||
repeated string files_hash = 3;
|
||||
optional string igid = 4;
|
||||
repeated string comments_hash = 5;
|
||||
}
|
||||
// Several post info
|
||||
repeated Message posts = 1;
|
||||
}
|
367
src/lib/protobuf/niming.ts
Normal file
367
src/lib/protobuf/niming.ts
Normal file
@ -0,0 +1,367 @@
|
||||
// @generated by protobuf-ts 2.9.4
|
||||
// @generated from protobuf file "niming.proto" (syntax proto3)
|
||||
// tslint:disable
|
||||
import type { BinaryWriteOptions } from "@protobuf-ts/runtime";
|
||||
import type { IBinaryWriter } from "@protobuf-ts/runtime";
|
||||
import { WireType } from "@protobuf-ts/runtime";
|
||||
import type { BinaryReadOptions } from "@protobuf-ts/runtime";
|
||||
import type { IBinaryReader } from "@protobuf-ts/runtime";
|
||||
import { UnknownFieldHandler } from "@protobuf-ts/runtime";
|
||||
import type { PartialMessage } from "@protobuf-ts/runtime";
|
||||
import { reflectionMergePartial } from "@protobuf-ts/runtime";
|
||||
import { MessageType } from "@protobuf-ts/runtime";
|
||||
/**
|
||||
* This is for posting a paragraph.
|
||||
*
|
||||
* @generated from protobuf message Post
|
||||
*/
|
||||
export interface Post {
|
||||
/**
|
||||
* @generated from protobuf field: string content = 1;
|
||||
*/
|
||||
content: string;
|
||||
/**
|
||||
* reply to a post, like a mail chat.
|
||||
*
|
||||
* @generated from protobuf field: optional int64 ref = 2;
|
||||
*/
|
||||
ref?: bigint;
|
||||
/**
|
||||
* @generated from protobuf field: repeated bytes files = 3;
|
||||
*/
|
||||
files: Uint8Array[];
|
||||
}
|
||||
/**
|
||||
* The response of the posting, defining what should return.
|
||||
*
|
||||
* @generated from protobuf message PostResponse
|
||||
*/
|
||||
export interface PostResponse {
|
||||
/**
|
||||
* @generated from protobuf field: Status status = 1;
|
||||
*/
|
||||
status: Status;
|
||||
/**
|
||||
* @generated from protobuf field: string hash = 2;
|
||||
*/
|
||||
hash: string;
|
||||
/**
|
||||
* @generated from protobuf field: uint64 id = 3;
|
||||
*/
|
||||
id: bigint;
|
||||
/**
|
||||
* @generated from protobuf field: optional string failed_message = 4;
|
||||
*/
|
||||
failedMessage?: string;
|
||||
}
|
||||
/**
|
||||
* @generated from protobuf message FetchResponse
|
||||
*/
|
||||
export interface FetchResponse {
|
||||
/**
|
||||
* Several post info
|
||||
*
|
||||
* @generated from protobuf field: repeated FetchResponse.Message posts = 1;
|
||||
*/
|
||||
posts: FetchResponse_Message[];
|
||||
}
|
||||
/**
|
||||
* @generated from protobuf message FetchResponse.Message
|
||||
*/
|
||||
export interface FetchResponse_Message {
|
||||
/**
|
||||
* @generated from protobuf field: uint64 id = 1;
|
||||
*/
|
||||
id: bigint;
|
||||
/**
|
||||
* @generated from protobuf field: string content = 2;
|
||||
*/
|
||||
content: string;
|
||||
/**
|
||||
* reply to a post, like a mail chat.
|
||||
* optional uint64 ref = 3;
|
||||
* request files through /article/file/<id> with MIME type.
|
||||
* See it as a BLOB url;
|
||||
*
|
||||
* @generated from protobuf field: repeated string files_hash = 3;
|
||||
*/
|
||||
filesHash: string[];
|
||||
/**
|
||||
* @generated from protobuf field: optional string igid = 4;
|
||||
*/
|
||||
igid?: string;
|
||||
/**
|
||||
* @generated from protobuf field: repeated string comments_hash = 5;
|
||||
*/
|
||||
commentsHash: string[];
|
||||
}
|
||||
/**
|
||||
* @generated from protobuf enum Status
|
||||
*/
|
||||
export enum Status {
|
||||
/**
|
||||
* @generated from protobuf enum value: Failed = 0;
|
||||
*/
|
||||
Failed = 0,
|
||||
/**
|
||||
* @generated from protobuf enum value: Success = 1;
|
||||
*/
|
||||
Success = 1
|
||||
}
|
||||
// @generated message type with reflection information, may provide speed optimized methods
|
||||
class Post$Type extends MessageType<Post> {
|
||||
constructor() {
|
||||
super("Post", [
|
||||
{ no: 1, name: "content", kind: "scalar", T: 9 /*ScalarType.STRING*/ },
|
||||
{ no: 2, name: "ref", kind: "scalar", opt: true, T: 3 /*ScalarType.INT64*/, L: 0 /*LongType.BIGINT*/ },
|
||||
{ no: 3, name: "files", kind: "scalar", repeat: 2 /*RepeatType.UNPACKED*/, T: 12 /*ScalarType.BYTES*/ }
|
||||
]);
|
||||
}
|
||||
create(value?: PartialMessage<Post>): Post {
|
||||
const message = globalThis.Object.create((this.messagePrototype!));
|
||||
message.content = "";
|
||||
message.files = [];
|
||||
if (value !== undefined)
|
||||
reflectionMergePartial<Post>(this, message, value);
|
||||
return message;
|
||||
}
|
||||
internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: Post): Post {
|
||||
let message = target ?? this.create(), end = reader.pos + length;
|
||||
while (reader.pos < end) {
|
||||
let [fieldNo, wireType] = reader.tag();
|
||||
switch (fieldNo) {
|
||||
case /* string content */ 1:
|
||||
message.content = reader.string();
|
||||
break;
|
||||
case /* optional int64 ref */ 2:
|
||||
message.ref = reader.int64().toBigInt();
|
||||
break;
|
||||
case /* repeated bytes files */ 3:
|
||||
message.files.push(reader.bytes());
|
||||
break;
|
||||
default:
|
||||
let u = options.readUnknownField;
|
||||
if (u === "throw")
|
||||
throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`);
|
||||
let d = reader.skip(wireType);
|
||||
if (u !== false)
|
||||
(u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d);
|
||||
}
|
||||
}
|
||||
return message;
|
||||
}
|
||||
internalBinaryWrite(message: Post, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter {
|
||||
/* string content = 1; */
|
||||
if (message.content !== "")
|
||||
writer.tag(1, WireType.LengthDelimited).string(message.content);
|
||||
/* optional int64 ref = 2; */
|
||||
if (message.ref !== undefined)
|
||||
writer.tag(2, WireType.Varint).int64(message.ref);
|
||||
/* repeated bytes files = 3; */
|
||||
for (let i = 0; i < message.files.length; i++)
|
||||
writer.tag(3, WireType.LengthDelimited).bytes(message.files[i]);
|
||||
let u = options.writeUnknownFields;
|
||||
if (u !== false)
|
||||
(u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer);
|
||||
return writer;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* @generated MessageType for protobuf message Post
|
||||
*/
|
||||
export const Post = new Post$Type();
|
||||
// @generated message type with reflection information, may provide speed optimized methods
|
||||
class PostResponse$Type extends MessageType<PostResponse> {
|
||||
constructor() {
|
||||
super("PostResponse", [
|
||||
{ no: 1, name: "status", kind: "enum", T: () => ["Status", Status] },
|
||||
{ no: 2, name: "hash", kind: "scalar", T: 9 /*ScalarType.STRING*/ },
|
||||
{ no: 3, name: "id", kind: "scalar", T: 4 /*ScalarType.UINT64*/, L: 0 /*LongType.BIGINT*/ },
|
||||
{ no: 4, name: "failed_message", kind: "scalar", opt: true, T: 9 /*ScalarType.STRING*/ }
|
||||
]);
|
||||
}
|
||||
create(value?: PartialMessage<PostResponse>): PostResponse {
|
||||
const message = globalThis.Object.create((this.messagePrototype!));
|
||||
message.status = 0;
|
||||
message.hash = "";
|
||||
message.id = 0n;
|
||||
if (value !== undefined)
|
||||
reflectionMergePartial<PostResponse>(this, message, value);
|
||||
return message;
|
||||
}
|
||||
internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: PostResponse): PostResponse {
|
||||
let message = target ?? this.create(), end = reader.pos + length;
|
||||
while (reader.pos < end) {
|
||||
let [fieldNo, wireType] = reader.tag();
|
||||
switch (fieldNo) {
|
||||
case /* Status status */ 1:
|
||||
message.status = reader.int32();
|
||||
break;
|
||||
case /* string hash */ 2:
|
||||
message.hash = reader.string();
|
||||
break;
|
||||
case /* uint64 id */ 3:
|
||||
message.id = reader.uint64().toBigInt();
|
||||
break;
|
||||
case /* optional string failed_message */ 4:
|
||||
message.failedMessage = reader.string();
|
||||
break;
|
||||
default:
|
||||
let u = options.readUnknownField;
|
||||
if (u === "throw")
|
||||
throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`);
|
||||
let d = reader.skip(wireType);
|
||||
if (u !== false)
|
||||
(u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d);
|
||||
}
|
||||
}
|
||||
return message;
|
||||
}
|
||||
internalBinaryWrite(message: PostResponse, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter {
|
||||
/* Status status = 1; */
|
||||
if (message.status !== 0)
|
||||
writer.tag(1, WireType.Varint).int32(message.status);
|
||||
/* string hash = 2; */
|
||||
if (message.hash !== "")
|
||||
writer.tag(2, WireType.LengthDelimited).string(message.hash);
|
||||
/* uint64 id = 3; */
|
||||
if (message.id !== 0n)
|
||||
writer.tag(3, WireType.Varint).uint64(message.id);
|
||||
/* optional string failed_message = 4; */
|
||||
if (message.failedMessage !== undefined)
|
||||
writer.tag(4, WireType.LengthDelimited).string(message.failedMessage);
|
||||
let u = options.writeUnknownFields;
|
||||
if (u !== false)
|
||||
(u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer);
|
||||
return writer;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* @generated MessageType for protobuf message PostResponse
|
||||
*/
|
||||
export const PostResponse = new PostResponse$Type();
|
||||
// @generated message type with reflection information, may provide speed optimized methods
|
||||
class FetchResponse$Type extends MessageType<FetchResponse> {
|
||||
constructor() {
|
||||
super("FetchResponse", [
|
||||
{ no: 1, name: "posts", kind: "message", repeat: 1 /*RepeatType.PACKED*/, T: () => FetchResponse_Message }
|
||||
]);
|
||||
}
|
||||
create(value?: PartialMessage<FetchResponse>): FetchResponse {
|
||||
const message = globalThis.Object.create((this.messagePrototype!));
|
||||
message.posts = [];
|
||||
if (value !== undefined)
|
||||
reflectionMergePartial<FetchResponse>(this, message, value);
|
||||
return message;
|
||||
}
|
||||
internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: FetchResponse): FetchResponse {
|
||||
let message = target ?? this.create(), end = reader.pos + length;
|
||||
while (reader.pos < end) {
|
||||
let [fieldNo, wireType] = reader.tag();
|
||||
switch (fieldNo) {
|
||||
case /* repeated FetchResponse.Message posts */ 1:
|
||||
message.posts.push(FetchResponse_Message.internalBinaryRead(reader, reader.uint32(), options));
|
||||
break;
|
||||
default:
|
||||
let u = options.readUnknownField;
|
||||
if (u === "throw")
|
||||
throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`);
|
||||
let d = reader.skip(wireType);
|
||||
if (u !== false)
|
||||
(u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d);
|
||||
}
|
||||
}
|
||||
return message;
|
||||
}
|
||||
internalBinaryWrite(message: FetchResponse, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter {
|
||||
/* repeated FetchResponse.Message posts = 1; */
|
||||
for (let i = 0; i < message.posts.length; i++)
|
||||
FetchResponse_Message.internalBinaryWrite(message.posts[i], writer.tag(1, WireType.LengthDelimited).fork(), options).join();
|
||||
let u = options.writeUnknownFields;
|
||||
if (u !== false)
|
||||
(u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer);
|
||||
return writer;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* @generated MessageType for protobuf message FetchResponse
|
||||
*/
|
||||
export const FetchResponse = new FetchResponse$Type();
|
||||
// @generated message type with reflection information, may provide speed optimized methods
|
||||
class FetchResponse_Message$Type extends MessageType<FetchResponse_Message> {
|
||||
constructor() {
|
||||
super("FetchResponse.Message", [
|
||||
{ no: 1, name: "id", kind: "scalar", T: 4 /*ScalarType.UINT64*/, L: 0 /*LongType.BIGINT*/ },
|
||||
{ no: 2, name: "content", kind: "scalar", T: 9 /*ScalarType.STRING*/ },
|
||||
{ no: 3, name: "files_hash", kind: "scalar", repeat: 2 /*RepeatType.UNPACKED*/, T: 9 /*ScalarType.STRING*/ },
|
||||
{ no: 4, name: "igid", kind: "scalar", opt: true, T: 9 /*ScalarType.STRING*/ },
|
||||
{ no: 5, name: "comments_hash", kind: "scalar", repeat: 2 /*RepeatType.UNPACKED*/, T: 9 /*ScalarType.STRING*/ }
|
||||
]);
|
||||
}
|
||||
create(value?: PartialMessage<FetchResponse_Message>): FetchResponse_Message {
|
||||
const message = globalThis.Object.create((this.messagePrototype!));
|
||||
message.id = 0n;
|
||||
message.content = "";
|
||||
message.filesHash = [];
|
||||
message.commentsHash = [];
|
||||
if (value !== undefined)
|
||||
reflectionMergePartial<FetchResponse_Message>(this, message, value);
|
||||
return message;
|
||||
}
|
||||
internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: FetchResponse_Message): FetchResponse_Message {
|
||||
let message = target ?? this.create(), end = reader.pos + length;
|
||||
while (reader.pos < end) {
|
||||
let [fieldNo, wireType] = reader.tag();
|
||||
switch (fieldNo) {
|
||||
case /* uint64 id */ 1:
|
||||
message.id = reader.uint64().toBigInt();
|
||||
break;
|
||||
case /* string content */ 2:
|
||||
message.content = reader.string();
|
||||
break;
|
||||
case /* repeated string files_hash */ 3:
|
||||
message.filesHash.push(reader.string());
|
||||
break;
|
||||
case /* optional string igid */ 4:
|
||||
message.igid = reader.string();
|
||||
break;
|
||||
case /* repeated string comments_hash */ 5:
|
||||
message.commentsHash.push(reader.string());
|
||||
break;
|
||||
default:
|
||||
let u = options.readUnknownField;
|
||||
if (u === "throw")
|
||||
throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`);
|
||||
let d = reader.skip(wireType);
|
||||
if (u !== false)
|
||||
(u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d);
|
||||
}
|
||||
}
|
||||
return message;
|
||||
}
|
||||
internalBinaryWrite(message: FetchResponse_Message, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter {
|
||||
/* uint64 id = 1; */
|
||||
if (message.id !== 0n)
|
||||
writer.tag(1, WireType.Varint).uint64(message.id);
|
||||
/* string content = 2; */
|
||||
if (message.content !== "")
|
||||
writer.tag(2, WireType.LengthDelimited).string(message.content);
|
||||
/* repeated string files_hash = 3; */
|
||||
for (let i = 0; i < message.filesHash.length; i++)
|
||||
writer.tag(3, WireType.LengthDelimited).string(message.filesHash[i]);
|
||||
/* optional string igid = 4; */
|
||||
if (message.igid !== undefined)
|
||||
writer.tag(4, WireType.LengthDelimited).string(message.igid);
|
||||
/* repeated string comments_hash = 5; */
|
||||
for (let i = 0; i < message.commentsHash.length; i++)
|
||||
writer.tag(5, WireType.LengthDelimited).string(message.commentsHash[i]);
|
||||
let u = options.writeUnknownFields;
|
||||
if (u !== false)
|
||||
(u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer);
|
||||
return writer;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* @generated MessageType for protobuf message FetchResponse.Message
|
||||
*/
|
||||
export const FetchResponse_Message = new FetchResponse_Message$Type();
|
62
src/lib/utils.ts
Normal file
62
src/lib/utils.ts
Normal file
@ -0,0 +1,62 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { cubicOut } from "svelte/easing";
|
||||
import type { TransitionConfig } from "svelte/transition";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
type FlyAndScaleParams = {
|
||||
y?: number;
|
||||
x?: number;
|
||||
start?: number;
|
||||
duration?: number;
|
||||
};
|
||||
|
||||
export const flyAndScale = (
|
||||
node: Element,
|
||||
params: FlyAndScaleParams = { y: -8, x: 0, start: 0.95, duration: 150 }
|
||||
): TransitionConfig => {
|
||||
const style = getComputedStyle(node);
|
||||
const transform = style.transform === "none" ? "" : style.transform;
|
||||
|
||||
const scaleConversion = (
|
||||
valueA: number,
|
||||
scaleA: [number, number],
|
||||
scaleB: [number, number]
|
||||
) => {
|
||||
const [minA, maxA] = scaleA;
|
||||
const [minB, maxB] = scaleB;
|
||||
|
||||
const percentage = (valueA - minA) / (maxA - minA);
|
||||
const valueB = percentage * (maxB - minB) + minB;
|
||||
|
||||
return valueB;
|
||||
};
|
||||
|
||||
const styleToString = (
|
||||
style: Record<string, number | string | undefined>
|
||||
): string => {
|
||||
return Object.keys(style).reduce((str, key) => {
|
||||
if (style[key] === undefined) return str;
|
||||
return str + `${key}:${style[key]};`;
|
||||
}, "");
|
||||
};
|
||||
|
||||
return {
|
||||
duration: params.duration ?? 200,
|
||||
delay: 0,
|
||||
css: (t) => {
|
||||
const y = scaleConversion(t, [0, 1], [params.y ?? 5, 0]);
|
||||
const x = scaleConversion(t, [0, 1], [params.x ?? 0, 0]);
|
||||
const scale = scaleConversion(t, [0, 1], [params.start ?? 0.95, 1]);
|
||||
|
||||
return styleToString({
|
||||
transform: `${transform} translate3d(${x}px, ${y}px, 0) scale(${scale})`,
|
||||
opacity: t
|
||||
});
|
||||
},
|
||||
easing: cubicOut
|
||||
};
|
||||
};
|
13
src/routes/+error.svelte
Normal file
13
src/routes/+error.svelte
Normal file
@ -0,0 +1,13 @@
|
||||
<div id="container">
|
||||
<h1>虛空之境,杳無人煙</h1>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
h1 {
|
||||
font-size: 3rem;
|
||||
}
|
||||
#container {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
</style>
|
6
src/routes/+layout.svelte
Normal file
6
src/routes/+layout.svelte
Normal file
@ -0,0 +1,6 @@
|
||||
<script>
|
||||
let { children } = $props();
|
||||
import '../app.css';
|
||||
</script>
|
||||
|
||||
{@render children()}
|
76
src/routes/+page.svelte
Normal file
76
src/routes/+page.svelte
Normal file
@ -0,0 +1,76 @@
|
||||
<script lang="ts">
|
||||
import Navigation from '$lib/Navigation.svelte';
|
||||
import api_inst from '$lib/api';
|
||||
import { FetchResponse_Message } from '$lib/protobuf/niming';
|
||||
import { onMount } from 'svelte';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import Post from '$lib/Post.svelte';
|
||||
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
|
||||
import Editor from '$lib/Editor.svelte';
|
||||
|
||||
let posts = new SvelteSet<FetchResponse_Message>();
|
||||
let width = $state(0);
|
||||
|
||||
onMount(() => {
|
||||
let trigger = document.querySelector('#trigger');
|
||||
let observe = new IntersectionObserver(
|
||||
() => {
|
||||
api_inst.fetch().then((e) => {
|
||||
if (e.enclose?.posts !== undefined) {
|
||||
for (const i of e.enclose?.posts) {
|
||||
posts.add(i);
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
{ root: null, rootMargin: '0px', threshold: 1.0 }
|
||||
);
|
||||
if (trigger) {
|
||||
observe.observe(trigger);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:body bind:clientWidth={width} />
|
||||
|
||||
<Navigation><div></div></Navigation>
|
||||
<!-- Posts -->
|
||||
<div id="container" class:layout1={width >= 950} class:layout2={width < 950}>
|
||||
{#if width >= 950}
|
||||
<ScrollArea class="h-full w-full pl-[3%] pr-[7.5%]" orientation="vertical">
|
||||
<div class="flex h-max flex-col">
|
||||
{#each posts as post}
|
||||
<Post {post} />
|
||||
{/each}
|
||||
<p id="trigger"></p>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<Editor mode="wide" />
|
||||
{:else}
|
||||
<ScrollArea class="h-full w-full pl-[3%] pr-[3%]" orientation="vertical">
|
||||
<div class="flex h-max flex-col">
|
||||
{#each posts as post}
|
||||
<Post {post} />
|
||||
{/each}
|
||||
<p id="trigger"></p>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<Editor mode="compact" />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
#container {
|
||||
width: 100dvw;
|
||||
box-sizing: border-box;
|
||||
overflow-x: hidden;
|
||||
height: 90dvh;
|
||||
}
|
||||
.layout1 {
|
||||
display: grid;
|
||||
grid-template-columns: 8fr 4fr;
|
||||
}
|
||||
.layout2 {
|
||||
position: relative;
|
||||
}
|
||||
</style>
|
11
src/routes/tos/+page.svelte
Normal file
11
src/routes/tos/+page.svelte
Normal file
@ -0,0 +1,11 @@
|
||||
<script>
|
||||
import Separator from '$lib/components/ui/separator/separator.svelte';
|
||||
import Navigation from '$lib/Navigation.svelte';
|
||||
</script>
|
||||
|
||||
<Navigation><div></div></Navigation>
|
||||
<div class="h-fit w-dvw px-[15%] text-center">
|
||||
<h1 class="text-4xl font-bold">服務條款</h1>
|
||||
<Separator class="my-8 text-foreground"></Separator>
|
||||
<ol>Termainl Is the Best Stuff</ol>
|
||||
</div>
|
3
src/vite-env.d.ts
vendored
Normal file
3
src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
/// <reference types="svelte" />
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="unplugin-icons/types/svelte" />
|
BIN
static/PingFang Bold.woff2
Normal file
BIN
static/PingFang Bold.woff2
Normal file
Binary file not shown.
BIN
static/PingFang Heavy.woff2
Normal file
BIN
static/PingFang Heavy.woff2
Normal file
Binary file not shown.
BIN
static/PingFang Light.woff2
Normal file
BIN
static/PingFang Light.woff2
Normal file
Binary file not shown.
BIN
static/PingFang Medium.woff2
Normal file
BIN
static/PingFang Medium.woff2
Normal file
Binary file not shown.
BIN
static/PingFang Regular.woff2
Normal file
BIN
static/PingFang Regular.woff2
Normal file
Binary file not shown.
BIN
static/favicon.png
Normal file
BIN
static/favicon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.5 KiB |
18
svelte.config.js
Normal file
18
svelte.config.js
Normal file
@ -0,0 +1,18 @@
|
||||
import adapter from '@sveltejs/adapter-static';
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
// Consult https://svelte.dev/docs/kit/integrations
|
||||
// for more information about preprocessors
|
||||
preprocess: vitePreprocess(),
|
||||
|
||||
kit: {
|
||||
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
|
||||
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
|
||||
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
|
||||
adapter: adapter({ pages: 'build', assets: 'build', preprocess: false, strict: true })
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
61
tailwind.config.ts
Normal file
61
tailwind.config.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import { fontFamily } from "tailwindcss/defaultTheme";
|
||||
import type { Config } from "tailwindcss";
|
||||
|
||||
const config: Config = {
|
||||
darkMode: "selector",
|
||||
content: ["./src/**/*.{html,js,svelte,ts}"],
|
||||
safelist: ["dark"],
|
||||
theme: {
|
||||
container: {
|
||||
center: true,
|
||||
padding: "2rem",
|
||||
screens: {
|
||||
"2xl": "1400px"
|
||||
}
|
||||
},
|
||||
extend: {
|
||||
colors: {
|
||||
border: "hsl(var(--border) / <alpha-value>)",
|
||||
input: "hsl(var(--input) / <alpha-value>)",
|
||||
ring: "hsl(var(--ring) / <alpha-value>)",
|
||||
background: "hsl(var(--background) / <alpha-value>)",
|
||||
foreground: "hsl(var(--foreground) / <alpha-value>)",
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary) / <alpha-value>)",
|
||||
foreground: "hsl(var(--primary-foreground) / <alpha-value>)"
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "hsl(var(--secondary) / <alpha-value>)",
|
||||
foreground: "hsl(var(--secondary-foreground) / <alpha-value>)"
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "hsl(var(--destructive) / <alpha-value>)",
|
||||
foreground: "hsl(var(--destructive-foreground) / <alpha-value>)"
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted) / <alpha-value>)",
|
||||
foreground: "hsl(var(--muted-foreground) / <alpha-value>)"
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "hsl(var(--accent) / <alpha-value>)",
|
||||
foreground: "hsl(var(--accent-foreground) / <alpha-value>)"
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: "hsl(var(--popover) / <alpha-value>)",
|
||||
foreground: "hsl(var(--popover-foreground) / <alpha-value>)"
|
||||
},
|
||||
card: {
|
||||
DEFAULT: "hsl(var(--card) / <alpha-value>)",
|
||||
foreground: "hsl(var(--card-foreground) / <alpha-value>)"
|
||||
}
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)"
|
||||
},
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
19
tsconfig.json
Normal file
19
tsconfig.json
Normal file
@ -0,0 +1,19 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
||||
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
||||
//
|
||||
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
|
||||
// from the referenced tsconfig.json - TypeScript does not merge them in
|
||||
}
|
18
vite.config.ts
Normal file
18
vite.config.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
import Icons from 'unplugin-icons/vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit(), Icons({
|
||||
compiler: 'svelte',
|
||||
})],
|
||||
|
||||
server: {
|
||||
proxy: {
|
||||
'/article': {
|
||||
target: "http://10.16.20.17:5000",
|
||||
changeOrigin: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
Loading…
Reference in New Issue
Block a user