Compare commits

..

2 Commits
main ... dev

Author SHA1 Message Date
jasinco
5eb5e81f3f Stage2 2024-12-22 22:57:01 +08:00
jasinco
0a754c17a3 Add Backend API
add backend api and add some element of the webpage
2024-11-25 07:02:06 +08:00
30 changed files with 1036 additions and 223 deletions

5
.gitignore vendored
View File

@ -19,3 +19,8 @@ Thumbs.db
# Vite # Vite
vite.config.js.timestamp-* vite.config.js.timestamp-*
vite.config.ts.timestamp-* vite.config.ts.timestamp-*
# lock files
bun.lockb
yarn.lock
.yarn/

View File

@ -36,6 +36,3 @@ npm run build
You can preview the production build with `npm run preview`. 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. > To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
## Collaboration
The main branch is locked, please push to other branch then send merge req.

3
justfile Normal file
View File

@ -0,0 +1,3 @@
genproto:
npx protoc --ts_out ./src/lib/protobuf --proto_path ./src/lib/protobuf/definitions ./src/lib/protobuf/definitions/niming.proto

View File

@ -17,7 +17,7 @@
"@sveltejs/vite-plugin-svelte": "^4.0.0", "@sveltejs/vite-plugin-svelte": "^4.0.0",
"@types/dompurify": "^3", "@types/dompurify": "^3",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"bits-ui": "^0.21.16", "bits-ui": "^0.22.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-svelte": "^0.454.0", "lucide-svelte": "^0.454.0",
"prettier": "^3.3.2", "prettier": "^3.3.2",
@ -34,6 +34,8 @@
"packageManager": "yarn@4.2.2+sha224.1e50daf19e5e249a025569752c60b88005fddf57d10fcde5fc68b88f", "packageManager": "yarn@4.2.2+sha224.1e50daf19e5e249a025569752c60b88005fddf57d10fcde5fc68b88f",
"dependencies": { "dependencies": {
"@iconify-json/mingcute": "^1.2.1", "@iconify-json/mingcute": "^1.2.1",
"@protobuf-ts/plugin": "^2.9.4",
"axios": "^1.7.7",
"dompurify": "^3.1.7", "dompurify": "^3.1.7",
"highlight.js": "^11.10.0", "highlight.js": "^11.10.0",
"isomorphic-dompurify": "^2.16.0", "isomorphic-dompurify": "^2.16.0",

View File

@ -34,6 +34,7 @@
--ring: 224 71.4% 4.1%; --ring: 224 71.4% 4.1%;
--radius: 0.5rem; --radius: 0.5rem;
} }
.dark { .dark {
@ -49,7 +50,7 @@
--card: 35 88% 72%; --card: 35 88% 72%;
--card-foreground: 248 13% 36%; --card-foreground: 248 13% 36%;
--border: 215 27.9% 16.9%; --border: 248 13% 36%;
--input: 215 27.9% 16.9%; --input: 215 27.9% 16.9%;
--primary: 248deg 25% 18%; --primary: 248deg 25% 18%;
@ -85,12 +86,58 @@
} }
@font-face { @font-face {
font-family: "GenRyuMin2TW"; font-family: "PingFang";
src: src:
url("/GenRyuMin2TW-M.woff2") format("woff2"); 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;
} }
body { body {
font-family: "GenRyuMin2TW"; font-family: "PingFang";
font-weight: 400;
overflow-x: hidden; overflow-x: hidden;
width: 100dvw;
min-height: 100dvh;
}
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background-color: hsl(var(--muted))
}
::-webkit-scrollbar-thumb {
background-color: hsl(248deg, 13%, 36%);
border-radius: 10px;
} }

81
src/lib/api.ts Normal file
View 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;

View File

@ -0,0 +1,25 @@
<script lang="ts">
import { Button as ButtonPrimitive } from "bits-ui";
import { type Events, type Props, buttonVariants } from "./index.js";
import { cn } from "$lib/utils.js";
type $$Props = Props;
type $$Events = Events;
let className: $$Props["class"] = undefined;
export let variant: $$Props["variant"] = "default";
export let size: $$Props["size"] = "default";
export let builders: $$Props["builders"] = [];
export { className as class };
</script>
<ButtonPrimitive.Root
{builders}
class={cn(buttonVariants({ variant, size, className }))}
type="button"
{...$$restProps}
on:click
on:keydown
>
<slot />
</ButtonPrimitive.Root>

View File

@ -0,0 +1,49 @@
import { type VariantProps, tv } from "tailwind-variants";
import type { Button as ButtonPrimitive } from "bits-ui";
import Root from "./button.svelte";
const buttonVariants = tv({
base: "ring-offset-background focus-visible:ring-ring inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border-input bg-background hover:bg-accent hover:text-accent-foreground border",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
});
type Variant = VariantProps<typeof buttonVariants>["variant"];
type Size = VariantProps<typeof buttonVariants>["size"];
type Props = ButtonPrimitive.Props & {
variant?: Variant;
size?: Size;
};
type Events = ButtonPrimitive.Events;
export {
Root,
type Props,
type Events,
//
Root as Button,
type Props as ButtonProps,
type Events as ButtonEvents,
buttonVariants,
};

View 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,
};

View File

@ -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>

View File

@ -0,0 +1,32 @@
<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]">
<ScrollAreaPrimitive.Content>
<slot />
</ScrollAreaPrimitive.Content>
</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>

View 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,
};

View 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/markdown.css Normal file
View File

@ -0,0 +1 @@
@import '$lib/hljs/rose-pine.css'

View 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
View 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();

View File

@ -3,7 +3,7 @@
let { children } = $props(); let { children } = $props();
</script> </script>
<div class="sticky top-0 z-10 grid h-16 w-full grid-cols-4 bg-background"> <div class="sticky top-0 z-50 grid h-16 w-full grid-cols-4 text-nowrap bg-background">
<div class="col-span-3 grid grid-cols-4 text-4xl"> <div class="col-span-3 grid grid-cols-4 text-4xl">
<a class="m-auto" href="/">中工匿名</a> <a class="m-auto" href="/">中工匿名</a>
</div> </div>

View File

@ -1,49 +1,90 @@
<script lang="ts"> <script lang="ts">
import MessageCard from './message_card.svelte'; import MessageCard from './message_card.svelte';
import MingcuteAddCircleFill from '~icons/mingcute/add-circle-fill'; import MingcuteAddCircleFill from '~icons/mingcute/add-circle-fill';
import Overlay, { toggle_overlay, get_overlay } from './overlay.svelte'; import Marker, { get_content, files, clear_content } from './marker.svelte';
import DOMPurify from 'isomorphic-dompurify'; import api_inst, { type Result } from '$lib/api';
import Marker from './marker.svelte'; import { FetchResponse_Message, type PostResponse } from '$lib/protobuf/niming';
import { onMount } from 'svelte';
import { SvelteSet } from 'svelte/reactivity';
import { Dialog } from 'bits-ui';
import { fade } from 'svelte/transition';
let value: string; let timer = false;
let open = $state(false);
const overlay = () => { const send = () => {
if (!get_overlay()) { if (!timer) {
} else { timer = true;
api_inst.send_post(get_content(), undefined, files).then((result: Result<PostResponse>) => {
alert(result.message);
if (result.ok()) {
clear_content();
open = false;
}
setTimeout(() => {
timer = false;
}, 5000);
});
} }
toggle_overlay();
}; };
let posts = new SvelteSet<FetchResponse_Message>();
onMount(() => {
api_inst.page = 0;
let observe = new IntersectionObserver(
(_) => {
console.log('Intersecting');
api_inst.fetch().then((e) => {
console.log(api_inst.page);
if (e.ok() && e.enclose != undefined) {
e.enclose?.posts.forEach((post) => {
posts.add(post);
});
}
});
},
{
root: null,
rootMargin: '0px',
threshold: 1
}
);
const element = document.querySelector('#loader');
if (element != null) {
observe.observe(element);
}
});
$inspect(posts);
</script> </script>
<div style="width:100dvw;" class="grid h-dvh px-5 py-3"> <div
<!-- <p class="justify-self-center m-auto">空空如也...</p> --> class="relative grid h-fit min-h-dvh w-dvw grid-cols-1 justify-items-center gap-10 py-3 lg:grid-cols-2 lg:px-[10dvw]"
<MessageCard >
content=" {#each posts as p}
# H1 <MessageCard post={p}></MessageCard>
## H2 {/each}
### H3 <div class="absolute bottom-0 left-0 h-10 w-full text-center" id="loader">Loading...</div>
[url](https://google.com)
![image](http://localhost:5173/favicon.png)
Test
NormalText
"
/>
</div> </div>
<button id="add" onclick={overlay} class="text-3xl"> <MingcuteAddCircleFill /> </button> <Dialog.Root bind:open>
<Overlay> <Dialog.Trigger class="fixed bottom-[15dvh] right-[10dvw] text-4xl">
<div class="flex w-4/6 flex-col rounded-lg bg-primary px-5 py-3" style="height:60dvh;"> <MingcuteAddCircleFill />
<div class="relative h-6 w-full"> </Dialog.Trigger>
<button class="absolute left-0" onclick={overlay}>取消</button> <Dialog.Portal>
<button class="absolute right-0">發送</button> <Dialog.Overlay
transition={fade}
transitionConfig={{ duration: 150 }}
class="fixed inset-0 z-50 bg-black/80"
/>
<Dialog.Content
class="fixed left-[50%] top-[50%] z-50 h-[40dvh] w-[50dvw] translate-x-[-50%] translate-y-[-50%]
rounded-lg border bg-background p-5 shadow-popover outline-none sm:w-[80dvw] md:w-[60dvw]"
>
<div class="grid grid-cols-3">
<Dialog.Close class="text-left">離開</Dialog.Close>
<Dialog.Title class="text-center">新增文章</Dialog.Title>
<button class="pr-1 text-right" onclickcapture={send}>Send</button>
</div> </div>
<Marker text={value} height="h-[calc(100%-1.5rem)]" /> <Marker></Marker>
</div> </Dialog.Content>
</Overlay> </Dialog.Portal>
</Dialog.Root>
<style>
#add {
position: fixed;
bottom: calc(15dvh);
right: calc(10dvw);
}
</style>

View File

@ -0,0 +1,3 @@
<div>
<p>IG: <a href="">IG 帳號</a></p>
</div>

View File

@ -1,15 +1,29 @@
<script lang="ts"> <script module lang="ts">
let { text, height }: { text: string; height: string } = $props(); let content: string = $state<string>('');
import { Textarea } from '$lib/components/ui/textarea';
import { Marked } from 'marked';
import DOMPurify from 'isomorphic-dompurify';
import type { KeyboardEventHandler } from 'svelte/elements';
import { markedHighlight } from 'marked-highlight';
import hljs from 'highlight.js';
import '$lib/hljs/rose-pine.css';
let window_size = $state(0); let dyn_files = $state<FileList>();
let value = $state('');
let files: Array<File> = [];
const get_content = () => {
return content;
};
const clear_content = () => {
content = '';
files.splice(0, files.length);
};
export { files, get_content, clear_content };
</script>
<script lang="ts">
import { Textarea } from '$lib/components/ui/textarea';
import * as Tooltip from '$lib/components/ui/tooltip';
import type { KeyboardEventHandler } from 'svelte/elements';
import MingcutePicFill from '~icons/mingcute/pic-fill';
import MingcuteCloseCircleFill from '~icons/mingcute/close-circle-fill';
import { SvelteMap } from 'svelte/reactivity';
// It makes some of ARIA commands into inputs (like tab into \t)
const key_scrap: KeyboardEventHandler<HTMLTextAreaElement> = (event: KeyboardEvent) => { const key_scrap: KeyboardEventHandler<HTMLTextAreaElement> = (event: KeyboardEvent) => {
let ct: HTMLTextAreaElement = event.currentTarget as HTMLTextAreaElement; let ct: HTMLTextAreaElement = event.currentTarget as HTMLTextAreaElement;
if (event.key == 'Tab') { if (event.key == 'Tab') {
@ -21,72 +35,68 @@
} }
}; };
// marked // it use svelte's map in order to achieve reactivity(like $state)
const marked = new Marked( // https://svelte.dev/docs/svelte/svelte-reactivity
markedHighlight({ let dyn_image_links = new SvelteMap<string, string>();
emptyLangClass: 'hljs',
langPrefix: 'hljs language-', // it create a map of name and local files path to show images
highlight(code, lang, info) { const update_image = () => {
const language = hljs.getLanguage(lang) ? lang : 'plaintext'; if (dyn_files != undefined) {
return hljs.highlight(code, { language }).value; dyn_image_links.clear();
for (let i = 0; i < dyn_files.length; i++) {
let item = dyn_files.item(i);
if (item != null) {
dyn_image_links.set(item.name, URL.createObjectURL(item));
} }
}) }
); }
};
const delete_image = (key: string) => {
dyn_image_links.delete(key);
};
</script> </script>
<svelte:window bind:innerWidth={window_size} /> <div class="flex h-full w-full flex-col justify-items-center gap-3 pt-[3%]">
<div class="{height} w-full"> <Textarea
<div class="h-[4%] w-full"></div> class="mx-auto h-[80%] w-full resize-none bg-background text-base"
{#if window_size < 360} onkeydowncapture={key_scrap}
<div> placeholder="寫一些東西..."
<Textarea class="h-[96%] w-full resize-none" /> bind:value={content}
/>
<hr />
<div class="w-full">
<Tooltip.Root>
<Tooltip.Trigger>
<input
id="enclosed"
type="file"
accept=".gif,.png,.jpeg,.jpg,.webm,.heic,.heif,.mp4,.webp"
multiple
class="hidden"
bind:files={dyn_files}
onchange={update_image}
/>
<label for="enclosed" class="mx-auto w-fit"><MingcutePicFill class="text-xl" /> </label>
</Tooltip.Trigger>
<Tooltip.Content>
<p>Add a picture</p>
</Tooltip.Content>
</Tooltip.Root>
</div> </div>
{:else} <!-- selected images preview -->
<div class="grid h-[96%] w-full grid-cols-2 gap-5"> {#if dyn_image_links.size > 0}
<textarea <div class="flex h-[75px] w-full max-w-full flex-row gap-3 overflow-y-hidden">
tabindex="0" {#each dyn_image_links as entries}
class="resize-none rounded-lg bg-background p-1 text-foreground focus:outline-none focus:ring focus:ring-primary-foreground" <div class="relative h-[70px] w-[70px]">
bind:value <img src={entries[1]} alt="selected files" class="object-scale-down" />
onkeydown={key_scrap} <button class="absolute right-0 top-0" onclick={() => delete_image(entries[0])}>
></textarea> <MingcuteCloseCircleFill />
<div class="inner_card"> </button>
{@html marked.parse(value).toString()}
</div> </div>
{/each}
</div> </div>
{/if} {/if}
</div> </div>
<style> <style>
.inner_card :global {
max-width: 100%;
overflow-y: scroll;
a {
@apply text-cyan-500;
}
p:has(code) {
overflow-x: scroll;
padding: 10px 0px;
code {
/* @apply bg-amber-600; */
text-wrap: nowrap;
}
}
p {
word-break: keep-all;
overflow-wrap: break-word;
}
pre {
/* @apply bg-amber-600; */
overflow-x: scroll;
}
h1 {
font-size: 1.3rem;
}
h2 {
font-size: 1.2rem;
}
h3 {
font-size: 1.1rem;
}
}
</style> </style>

View File

@ -1,22 +1,77 @@
<script lang="ts"> <script lang="ts">
let { content }: { content: string } = $props(); let { post }: { post: FetchResponse_Message } = $props();
import { marked } from 'marked';
import * as Dialog from '$lib/components/ui/dialog'; import * as Dialog from '$lib/components/ui/dialog';
//TODO Need sanitizer import ScrollArea from '$lib/components/ui/scroll-area/scroll-area.svelte';
import type { FetchResponse_Message } from '$lib/protobuf/niming';
import DOMPurify from 'isomorphic-dompurify';
//Icons
import MingcuteForwardFill from '~icons/mingcute/forward-fill';
import MingcuteInsFill from '~icons/mingcute/ins-fill';
import MingcuteHeartFill from '~icons/mingcute/heart-fill';
const url = /(https|http):\/\/[\S]*/g;
const parser = (text: string) => {
text.replaceAll(url, (sub) => {
return `<a href=${sub}>${sub}</a>`;
});
return text;
};
let item = DOMPurify.sanitize(parser(post.content));
</script> </script>
<div <Dialog.Root>
class="card_eff flex max-h-80 max-w-64 flex-col overflow-y-hidden bg-amber-400 px-5 font-bold text-slate-900" <div class="flex h-fit w-fit flex-row-reverse">
> <div class="z-10 flex w-10 flex-col-reverse gap-3 text-2xl">
<div class="inner_card"> <!-- Interactions -->
{@html marked(content)} <button class=" text-center" aria-label="like"><MingcuteHeartFill /></button>
<button
class=" text-center"
aria-label="ig"
onclick={() => {
window.open('', '_blank');
}}
>
<MingcuteInsFill />
</button>
<button class=" text-center" aria-label="forward"><MingcuteForwardFill /></button>
</div> </div>
</div> <Dialog.Trigger
class="h-fit max-h-80 max-w-[70dvw] overflow-y-hidden bg-amber-400 text-slate-900 drop-shadow-[5px_10px_#f38f0f] lg:max-w-96"
>
<div class="inner_card">
{@html item}
{#if post.filesHash.length > 0}
<ScrollArea class="mt-10 w-full" orientation="horizontal">
{#each post.filesHash as hash}
<img src={'/article/file/' + hash} alt="pictures" />
{/each}
</ScrollArea>
{/if}
</div>
</Dialog.Trigger>
</div>
<Dialog.Content>
{@html item}
{#if post.filesHash.length > 1}
<ScrollArea class="w-full whitespace-nowrap rounded-md border" orientation="horizontal">
<div class="flex h-[35dvh] w-fit space-x-4 p-4">
{#each post.filesHash as hash}
<img src={'/article/file/' + hash} alt="pictures" class="aspect-auto object-cover" />
{/each}
</div>
</ScrollArea>
{:else if post.filesHash.length == 1}
<img
src={'/article/file/' + post.filesHash[0]}
alt="pictures"
class="aspect-auto object-cover"
/>
{/if}
</Dialog.Content>
</Dialog.Root>
<style> <style>
.card_eff {
filter: drop-shadow(5px 10px #f38f0f);
}
.inner_card :global { .inner_card :global {
a { a {
@apply text-cyan-500; @apply text-cyan-500;
@ -24,6 +79,9 @@
code { code {
@apply bg-amber-600; @apply bg-amber-600;
} }
mask-image: linear-gradient(0deg, transparent, white 50%); mask-image: linear-gradient(0deg, transparent, white 30%);
height: 100%;
padding: 5px 5% 40px;
text-align: left;
} }
</style> </style>

View File

@ -1,32 +0,0 @@
<script module lang="ts">
let overlay = $state(false);
const toggle_overlay = () => {
overlay = !overlay;
if (overlay) {
document.body.classList.add('overflow-y-hidden');
} else {
document.body.classList.remove('overflow-y-hidden');
}
};
const get_overlay = () => {
return overlay;
};
export { toggle_overlay, get_overlay };
</script>
<script lang="ts">
let { children } = $props();
</script>
<div
class={'fixed left-0 top-0 z-20 h-dvh w-dvw ' +
(overlay ? 'overlay_blur grid place-items-center' : 'hidden')}
>
{@render children()}
</div>
<style>
.overlay_blur {
backdrop-filter: blur(10px);
}
</style>

View File

@ -1,6 +1,4 @@
<div class="flex justify-center"> <div class="w-dvw text-center text-xl font-bold lg:text-3xl">
<div class="w-1/2 break-keep text-center text-2xl"> <p>“中工匿名”不記錄個人姓名。</p>
<p>“中工匿名”不記錄個人網路位置、名字。</p>
<p>如訊息侵害他人權利,違反大眾道德觀,皆不保證留存。</p> <p>如訊息侵害他人權利,違反大眾道德觀,皆不保證留存。</p>
</div>
</div> </div>

Binary file not shown.

BIN
static/PingFang Bold.woff2 Normal file

Binary file not shown.

BIN
static/PingFang Heavy.woff2 Normal file

Binary file not shown.

BIN
static/PingFang Light.woff2 Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -3,5 +3,13 @@ import { defineConfig } from 'vite';
import Icons from 'unplugin-icons/vite' import Icons from 'unplugin-icons/vite'
export default defineConfig({ export default defineConfig({
plugins: [sveltekit(), Icons({ compiler: 'svelte' })] plugins: [sveltekit(), Icons({ compiler: 'svelte' })],
server: {
proxy: {
'/article': {
target: "http://10.16.20.17:5000",
changeOrigin: true,
}
}
}
}); });