Creator hub

This commit is contained in:
LittleSheep 2024-02-11 13:12:37 +08:00
parent 4dbbb423e7
commit a5d6071bef
39 changed files with 1015 additions and 257 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

View File

@ -0,0 +1,57 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<HTMLCodeStyleSettings>
<option name="HTML_SPACE_INSIDE_EMPTY_TAG" value="true" />
</HTMLCodeStyleSettings>
<JSCodeStyleSettings version="0">
<option name="FORCE_SEMICOLON_STYLE" value="true" />
<option name="SPACE_BEFORE_FUNCTION_LEFT_PARENTH" value="false" />
<option name="FORCE_QUOTE_STYlE" value="true" />
<option name="ENFORCE_TRAILING_COMMA" value="Remove" />
<option name="SPACES_WITHIN_OBJECT_LITERAL_BRACES" value="true" />
<option name="SPACES_WITHIN_IMPORTS" value="true" />
</JSCodeStyleSettings>
<TypeScriptCodeStyleSettings version="0">
<option name="FORCE_SEMICOLON_STYLE" value="true" />
<option name="SPACE_BEFORE_FUNCTION_LEFT_PARENTH" value="false" />
<option name="FORCE_QUOTE_STYlE" value="true" />
<option name="ENFORCE_TRAILING_COMMA" value="Remove" />
<option name="SPACES_WITHIN_OBJECT_LITERAL_BRACES" value="true" />
<option name="SPACES_WITHIN_IMPORTS" value="true" />
</TypeScriptCodeStyleSettings>
<VueCodeStyleSettings>
<option name="INTERPOLATION_NEW_LINE_AFTER_START_DELIMITER" value="false" />
<option name="INTERPOLATION_NEW_LINE_BEFORE_END_DELIMITER" value="false" />
</VueCodeStyleSettings>
<codeStyleSettings language="HTML">
<option name="SOFT_MARGINS" value="120" />
<indentOptions>
<option name="INDENT_SIZE" value="2" />
<option name="CONTINUATION_INDENT_SIZE" value="2" />
<option name="TAB_SIZE" value="2" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="JavaScript">
<option name="SOFT_MARGINS" value="120" />
<indentOptions>
<option name="INDENT_SIZE" value="2" />
<option name="CONTINUATION_INDENT_SIZE" value="2" />
<option name="TAB_SIZE" value="2" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="TypeScript">
<option name="SOFT_MARGINS" value="120" />
<indentOptions>
<option name="INDENT_SIZE" value="2" />
<option name="CONTINUATION_INDENT_SIZE" value="2" />
<option name="TAB_SIZE" value="2" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="Vue">
<option name="SOFT_MARGINS" value="120" />
<indentOptions>
<option name="CONTINUATION_INDENT_SIZE" value="2" />
</indentOptions>
</codeStyleSettings>
</code_scheme>
</component>

View File

@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</state>
</component>

12
.idea/dataSources.xml Normal file
View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="hy_interactive@localhost" uuid="2e2101b2-4037-47ee-88ed-456dc2cb4423">
<driver-ref>postgresql</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
<jdbc-url>jdbc:postgresql://localhost:5432/hy_interactive</jdbc-url>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
</component>
</project>

View File

@ -0,0 +1,11 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="DuplicatedCode" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<Languages>
<language minSize="54" name="TypeScript" />
</Languages>
</inspection_tool>
<inspection_tool class="SqlDialectInspection" enabled="false" level="WARNING" enabled_by_default="false" />
</profile>
</component>

6
.idea/sqldialects.xml Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="SqlDialectMappings">
<file url="file://$PROJECT_DIR$/pkg/server/posts_api.go" dialect="PostgreSQL" />
</component>
</project>

BIN
pkg/.DS_Store vendored Normal file

Binary file not shown.

View File

@ -5,6 +5,7 @@ import "time"
type Post struct {
BaseModel
// TODO Introduce thumbnail
Alias string `json:"alias" gorm:"uniqueIndex"`
Title string `json:"title"`
Content string `json:"content"`

View File

@ -0,0 +1,95 @@
package server
import (
"code.smartsheep.studio/hydrogen/interactive/pkg/database"
"code.smartsheep.studio/hydrogen/interactive/pkg/models"
"code.smartsheep.studio/hydrogen/interactive/pkg/services"
"github.com/gofiber/fiber/v2"
"github.com/samber/lo"
"time"
)
func getOwnPost(c *fiber.Ctx) error {
user := c.Locals("principal").(models.Account)
id := c.Params("postId")
take := c.QueryInt("take", 0)
offset := c.QueryInt("offset", 0)
tx := database.C.Where(&models.Post{
Alias: id,
AuthorID: user.ID,
})
post, err := services.GetPost(tx)
if err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
}
tx = database.C.
Where(&models.Post{ReplyID: &post.ID}).
Where("published_at <= ? OR published_at IS NULL", time.Now()).
Order("created_at desc")
var count int64
if err := tx.
Model(&models.Post{}).
Count(&count).Error; err != nil {
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
posts, err := services.ListPost(tx, take, offset)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
return c.JSON(fiber.Map{
"data": post,
"count": count,
"related": posts,
})
}
func listOwnPost(c *fiber.Ctx) error {
take := c.QueryInt("take", 0)
offset := c.QueryInt("offset", 0)
realmId := c.QueryInt("realmId", 0)
user := c.Locals("principal").(models.Account)
tx := database.C.
Where(&models.Post{AuthorID: user.ID}).
Where("published_at <= ? OR published_at IS NULL", time.Now()).
Order("created_at desc")
if realmId > 0 {
tx = tx.Where(&models.Post{RealmID: lo.ToPtr(uint(realmId))})
} else {
tx = tx.Where("realm_id IS NULL")
}
if len(c.Query("category")) > 0 {
tx = services.FilterPostWithCategory(tx, c.Query("category"))
}
if len(c.Query("tag")) > 0 {
tx = services.FilterPostWithTag(tx, c.Query("tag"))
}
var count int64
if err := tx.
Model(&models.Post{}).
Count(&count).Error; err != nil {
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
posts, err := services.ListPost(tx, take, offset)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
return c.JSON(fiber.Map{
"count": count,
"data": posts,
})
}

View File

@ -19,7 +19,7 @@ func getPost(c *fiber.Ctx) error {
tx := database.C.Where(&models.Post{
Alias: id,
})
}).Where("published_at <= ? OR published_at IS NULL", time.Now())
post, err := services.GetPost(tx)
if err != nil {

View File

@ -40,6 +40,17 @@ func listOwnedRealm(c *fiber.Ctx) error {
return c.JSON(realms)
}
func listAvailableRealm(c *fiber.Ctx) error {
user := c.Locals("principal").(models.Account)
realms, err := services.ListRealmIsAvailable(user)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
return c.JSON(realms)
}
func createRealm(c *fiber.Ctx) error {
user := c.Locals("principal").(models.Account)
if user.PowerLevel < 10 {

View File

@ -76,8 +76,12 @@ func NewServer() {
api.Put("/posts/:postId", auth, editPost)
api.Delete("/posts/:postId", auth, deletePost)
api.Get("/creators/posts", auth, listOwnPost)
api.Get("/creators/posts/:postId", auth, getOwnPost)
api.Get("/realms", listRealm)
api.Get("/realms/me", auth, listOwnedRealm)
api.Get("/realms/me/available", auth, listAvailableRealm)
api.Get("/realms/:realmId", getRealm)
api.Post("/realms", auth, createRealm)
api.Post("/realms/:realmId/invite", auth, inviteRealm)

View File

@ -3,6 +3,7 @@ package services
import (
"code.smartsheep.studio/hydrogen/interactive/pkg/database"
"code.smartsheep.studio/hydrogen/interactive/pkg/models"
"github.com/samber/lo"
)
func ListRealm() ([]models.Realm, error) {
@ -23,6 +24,28 @@ func ListRealmWithUser(user models.Account) ([]models.Realm, error) {
return realms, nil
}
func ListRealmIsAvailable(user models.Account) ([]models.Realm, error) {
var realms []models.Realm
var members []models.RealmMember
if err := database.C.Where(&models.RealmMember{
AccountID: user.ID,
}).Find(&members).Error; err != nil {
return realms, err
}
idx := lo.Map(members, func(item models.RealmMember, index int) uint {
return item.RealmID
})
if err := database.C.Where(&models.Realm{
IsPublic: true,
}).Or("id IN ?", idx).Find(&realms).Error; err != nil {
return realms, err
}
return realms, nil
}
func NewRealm(user models.Account, name, description string, isPublic bool) (models.Realm, error) {
realm := models.Realm{
Name: name,

View File

@ -12,6 +12,7 @@
"@fortawesome/fontawesome-free": "^6.5.1",
"@solidjs/router": "^0.10.10",
"artplayer": "^5.1.1",
"cherry-markdown": "^0.8.38",
"dompurify": "^3.0.8",
"flv.js": "^1.6.2",
"hls.js": "^1.5.3",

View File

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

View File

@ -1,119 +1,28 @@
import { createEffect, createSignal, For, Match, Show, Switch } from "solid-js";
import { getAtk, useUserinfo } from "../stores/userinfo.tsx";
import { closeModel, openModel } from "../../scripts/modals.ts";
import { createSignal, For, Match, Show, Switch } from "solid-js";
import { getAtk, useUserinfo } from "../../stores/userinfo.tsx";
import styles from "./PostPublish.module.css";
import { closeModel, openModel } from "../scripts/modals.ts";
export default function PostPublish(props: {
replying?: any,
reposting?: any,
export default function PostEditActions(props: {
editing?: any,
realmId?: number,
onReset: () => void,
onInputAlias: (value: string) => void,
onInputPublish: (value: string) => void,
onInputAttachments: (value: any[]) => void,
onInputCategories: (categories: any[]) => void,
onInputTags: (tags: any[]) => void,
onError: (message: string | null) => void,
onPost: () => void
}) {
const userinfo = useUserinfo();
const userinfo = useUserinfo()
if (!userinfo?.isLoggedIn) {
return (
<div class="py-9 flex justify-center items-center">
<div class="text-center">
<h2 class="text-lg font-bold">Login!</h2>
<p>Or keep silent.</p>
</div>
</div>
);
}
const [submitting, setSubmitting] = createSignal(false);
const [uploading, setUploading] = createSignal(false);
const [attachments, setAttachments] = createSignal<any[]>([]);
const [categories, setCategories] = createSignal<{ alias: string, name: string }[]>([]);
const [tags, setTags] = createSignal<{ alias: string, name: string }[]>([]);
const [attachments, setAttachments] = createSignal<any[]>(props.editing?.attachments ?? []);
const [categories, setCategories] = createSignal<{ alias: string, name: string }[]>(props.editing?.categories ?? []);
const [tags, setTags] = createSignal<{ alias: string, name: string }[]>(props.editing?.tags ?? []);
const [attachmentMode, setAttachmentMode] = createSignal(0);
createEffect(() => {
setAttachments(props.editing?.attachments ?? []);
setCategories(props.editing?.categories ?? []);
setTags(props.editing?.tags ?? []);
}, [props.editing]);
async function doPost(evt: SubmitEvent) {
evt.preventDefault();
const form = evt.target as HTMLFormElement;
const data = Object.fromEntries(new FormData(form));
if (!data.content) return;
setSubmitting(true);
const res = await fetch("/api/posts", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${getAtk()}`
},
body: JSON.stringify({
alias: data.alias ?? crypto.randomUUID().replace(/-/g, ""),
title: data.title,
content: data.content,
attachments: attachments(),
categories: categories(),
tags: tags(),
realm_id: data.publish_in_realm ? props.realmId : undefined,
published_at: data.published_at ? new Date(data.published_at as string) : new Date(),
repost_to: props.reposting?.id,
reply_to: props.replying?.id
})
});
if (res.status !== 200) {
props.onError(await res.text());
} else {
form.reset();
props.onError(null);
props.onPost();
}
setSubmitting(false);
}
async function doEdit(evt: SubmitEvent) {
evt.preventDefault();
const form = evt.target as HTMLFormElement;
const data = Object.fromEntries(new FormData(form));
if (!data.content) return;
if (uploading()) return;
setSubmitting(true);
const res = await fetch(`/api/posts/${props.editing?.id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${getAtk()}`
},
body: JSON.stringify({
alias: data.alias ?? crypto.randomUUID().replace(/-/g, ""),
title: data.title,
content: data.content,
attachments: attachments(),
categories: categories(),
tags: tags(),
realm_id: props.realmId,
published_at: data.published_at ? new Date(data.published_at as string) : new Date()
})
});
if (res.status !== 200) {
props.onError(await res.text());
} else {
form.reset();
props.onError(null);
props.onPost();
}
setSubmitting(false);
}
async function uploadAttachment(evt: SubmitEvent) {
evt.preventDefault();
@ -148,6 +57,7 @@ export default function PostPublish(props: {
...data,
author_id: userinfo?.profiles?.id
}]));
props.onInputCategories(categories())
form.reset();
}
@ -160,6 +70,7 @@ export default function PostPublish(props: {
if (!data.name) return;
setCategories(categories().concat([data as any]));
props.onInputCategories(categories())
form.reset();
}
@ -176,156 +87,83 @@ export default function PostPublish(props: {
if (!data.name) return;
setTags(tags().concat([data as any]));
props.onInputTags(tags())
form.reset();
}
function removeTag(target: any) {
setTags(tags().filter(item => item.alias !== target.alias));
}
function resetForm() {
setAttachments([]);
setCategories([]);
setTags([]);
props.onReset();
props.onInputTags(tags())
}
return (
<>
<form id="publish" onSubmit={(evt) => (props.editing ? doEdit : doPost)(evt)} onReset={() => resetForm()}>
<div id="publish-identity" class="flex border-y border-base-200">
<div class="avatar pl-[20px]">
<div class="w-12">
<Show when={userinfo?.profiles?.avatar}
fallback={<span class="text-3xl">{userinfo?.displayName.substring(0, 1)}</span>}>
<img alt="avatar" src={userinfo?.profiles?.avatar} />
</Show>
<div class="flex pl-[20px]">
<button type="button" class="btn btn-ghost w-12" onClick={() => openModel("#alias")}>
<i class="fa-solid fa-link"></i>
</button>
<button type="button" class="btn btn-ghost w-12" onClick={() => openModel("#attachments")}>
<i class="fa-solid fa-paperclip"></i>
</button>
<button type="button" class="btn btn-ghost w-12" onClick={() => openModel("#planning-publish")}>
<i class="fa-solid fa-calendar-day"></i>
</button>
<button type="button" class="btn btn-ghost w-12" onClick={() => openModel("#categories-and-tags")}>
<i class="fa-solid fa-tag"></i>
</button>
</div>
<dialog id="alias" class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg mx-1">Permalink</h3>
<label class="form-control w-full mt-3">
<div class="label">
<span class="label-text">Alias</span>
</div>
</div>
<div class="flex flex-grow">
<input name="title" value={props.editing?.title ?? ""}
class={`${styles.publishInput} input w-full`}
placeholder="The describe for a long content" />
</div>
</div>
<Show when={props.reposting}>
<div role="alert" class="bg-base-200 flex justify-between">
<div class="px-5 py-3">
<i class="fa-solid fa-circle-info me-3"></i>
You are reposting a post from <b>{props.reposting?.author?.nick}</b>
</div>
<button type="reset" class="btn btn-ghost w-12" disabled={submitting()}>
<i class="fa-solid fa-xmark"></i>
</button>
</div>
</Show>
<Show when={props.replying}>
<div role="alert" class="bg-base-200 flex justify-between">
<div class="px-5 py-3">
<i class="fa-solid fa-circle-info me-3"></i>
You are replying a post from <b>{props.replying?.author?.nick}</b>
</div>
<button type="reset" class="btn btn-ghost w-12" disabled={submitting()}>
<i class="fa-solid fa-xmark"></i>
</button>
</div>
</Show>
<Show when={props.editing}>
<div role="alert" class="bg-base-200 flex justify-between">
<div class="px-5 py-3">
<i class="fa-solid fa-circle-info me-3"></i>
You are editing a post published at{" "}
<b>{new Date(props.editing?.created_at).toLocaleString()}</b>
</div>
<button type="reset" class="btn btn-ghost w-12" disabled={submitting()}>
<i class="fa-solid fa-xmark"></i>
</button>
</div>
</Show>
<Show when={props.realmId && !props.editing}>
<div class="border-b border-base-200 px-5 h-[48px] flex items-center">
<div class="form-control flex-grow">
<label class="label cursor-pointer">
<span class="label-text">Publish in this realm</span>
<input name="publish_in_realm" type="checkbox" checked class="checkbox checkbox-primary" />
</label>
</div>
</div>
</Show>
<textarea required name="content" value={props.editing?.content ?? ""}
class={`${styles.publishInput} textarea w-full`}
placeholder="What's happened?! (Support markdown)" />
<div id="publish-actions" class="flex justify-between border-y border-base-200">
<div class="flex pl-[20px]">
<button type="button" class="btn btn-ghost w-12" onClick={() => openModel("#alias")}>
<i class="fa-solid fa-link"></i>
</button>
<button type="button" class="btn btn-ghost w-12" onClick={() => openModel("#attachments")}>
<i class="fa-solid fa-paperclip"></i>
</button>
<button type="button" class="btn btn-ghost w-12" onClick={() => openModel("#planning-publish")}>
<i class="fa-solid fa-calendar-day"></i>
</button>
<button type="button" class="btn btn-ghost w-12" onClick={() => openModel("#categories-and-tags")}>
<i class="fa-solid fa-tag"></i>
</button>
</div>
<div>
<button type="submit" class="btn btn-primary" disabled={submitting()}>
<Show when={submitting()} fallback={props.editing ? "Save changes" : "Post a post"}>
<span class="loading"></span>
</Show>
</button>
</div>
</div>
<dialog id="alias" class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg mx-1">Permalink</h3>
<label class="form-control w-full mt-3">
<div class="label">
<span class="label-text">Alias</span>
</div>
<input name="alias" type="text" placeholder="Type here" class="input input-bordered w-full" />
<div class="label">
<input
name="alias" type="text" placeholder="Type here"
class="input input-bordered w-full"
value={props.editing?.alias ?? ""}
onInput={(evt) => props.onInputAlias(evt.target.value)}
/>
<div class="label">
<span class="label-text-alt">
Leave blank to generate a random string.
</span>
</div>
</label>
<div class="modal-action">
<button type="button" class="btn" onClick={() => closeModel("#alias")}>Close</button>
</div>
</label>
<div class="modal-action">
<button type="button" class="btn" onClick={() => closeModel("#alias")}>Close</button>
</div>
</dialog>
</div>
</dialog>
<dialog id="planning-publish" class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg mx-1">Planning Publish</h3>
<label class="form-control w-full mt-3">
<div class="label">
<span class="label-text">Published At</span>
</div>
<input name="published_at" type="datetime-local" placeholder="Pick a date"
class="input input-bordered w-full" />
<div class="label">
<dialog id="planning-publish" class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg mx-1">Planning Publish</h3>
<label class="form-control w-full mt-3">
<div class="label">
<span class="label-text">Published At</span>
</div>
<input
name="published_at" type="datetime-local"
placeholder="Pick a date"
class="input input-bordered w-full"
value={props.editing?.published_at ?? ""}
onInput={(evt) => props.onInputAlias(evt.target.value)}
/>
<div class="label">
<span class="label-text-alt">
Before this time, your post will not be visible for everyone.
You can modify this plan on Creator Hub.
</span>
</div>
</label>
<div class="modal-action">
<button type="button" class="btn" onClick={() => closeModel("#planning-publish")}>Close</button>
</div>
</label>
<div class="modal-action">
<button type="button" class="btn" onClick={() => closeModel("#planning-publish")}>Close</button>
</div>
</dialog>
</form>
</div>
</dialog>
<dialog id="attachments" class="modal">
<div class="modal-box">
@ -346,7 +184,7 @@ export default function PostPublish(props: {
<span class="label-text">Pick a file</span>
</div>
<div class="join">
<input required type="file" name="attachment"
<input required type="file" name="attachment"
class="join-item file-input file-input-bordered w-full" />
<button type="submit" class="join-item btn btn-primary" disabled={uploading()}>
<i class="fa-solid fa-upload"></i>

View File

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

View File

@ -1,5 +1,5 @@
import { createSignal, For, Show } from "solid-js";
import { getAtk, useUserinfo } from "../stores/userinfo.tsx";
import { getAtk, useUserinfo } from "../../stores/userinfo.tsx";
import PostAttachments from "./PostAttachments.tsx";
import * as marked from "marked";
import DOMPurify from "dompurify";

View File

@ -2,7 +2,8 @@ import { createMemo, createSignal, For, Show } from "solid-js";
import styles from "./PostList.module.css";
import PostItem from "./PostItem.tsx";
import { getAtk } from "../stores/userinfo.tsx";
import LoadingAnimation from "../LoadingAnimation.tsx";
import { getAtk } from "../../stores/userinfo.tsx";
export default function PostList(props: {
noRelated?: boolean,
@ -86,10 +87,7 @@ export default function PostList(props: {
</div>
<Show when={loading()}>
<div class="w-full border-b border-base-200 pt-5 pb-7 text-center">
<p class="loading loading-lg loading-infinity"></p>
<p>Creating fake news...</p>
</div>
<LoadingAnimation />
</Show>
</div>
</div>

View File

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

View File

@ -23,3 +23,17 @@ html, body {
display: none;
width: 0;
}
.cherry, .cherry-toolbar, .cherry-editor, .cherry-previewer, .cherry-drag {
box-shadow: none !important;
}
.cherry-drag {
width: 2px !important;
}
@media (prefers-color-scheme: dark) {
.cherry-drag {
background: oklch(var(--b2)) !important;
}
}

View File

@ -11,9 +11,10 @@ import { Route, Router } from "@solidjs/router";
import "@fortawesome/fontawesome-free/css/all.css";
import RootLayout from "./layouts/RootLayout.tsx";
import Feed from "./pages/view.tsx";
import FeedView from "./pages/view.tsx";
import Global from "./pages/global.tsx";
import PostReference from "./pages/post.tsx";
import CreatorView from "./pages/creators/view.tsx";
import { UserinfoProvider } from "./stores/userinfo.tsx";
import { WellKnownProvider } from "./stores/wellKnown.tsx";
@ -23,7 +24,7 @@ render(() => (
<WellKnownProvider>
<UserinfoProvider>
<Router root={RootLayout}>
<Route path="/" component={Feed}>
<Route path="/" component={FeedView}>
<Route path="/" component={Global} />
<Route path="/posts/:postId" component={PostReference} />
<Route path="/search" component={lazy(() => import("./pages/search.tsx"))} />
@ -31,6 +32,11 @@ render(() => (
<Route path="/realms/:realmId" component={lazy(() => import("./pages/realms/realm.tsx"))} />
<Route path="/accounts/:accountId" component={lazy(() => import("./pages/account.tsx"))} />
</Route>
<Route path="/creators" component={CreatorView}>
<Route path="/" component={lazy(() => import("./pages/creators"))} />
<Route path="/publish" component={lazy(() => import("./pages/creators/publish.tsx"))} />
<Route path="/edit/:postId" component={lazy(() => import("./pages/creators/edit.tsx"))} />
</Route>
<Route path="/auth" component={lazy(() => import("./pages/auth/callout.tsx"))} />
<Route path="/auth/callback" component={lazy(() => import("./pages/auth/callback.tsx"))} />
</Router>

View File

@ -22,7 +22,7 @@ export default function RootLayout(props: any) {
}, [ready, userinfo]);
function keepGate(path: string, embedded: boolean, e?: BeforeLeaveEventArgs) {
const blacklist = ["/creator"];
const blacklist = ["/creators"];
if (!userinfo?.isLoggedIn && blacklist.includes(path)) {
if (!e?.defaultPrevented) e?.preventDefault();

View File

@ -11,6 +11,7 @@ interface MenuItem {
export default function Navbar() {
const nav: MenuItem[] = [
{ label: "Creators", href: "/creators" },
{ label: "Feed", href: "/" },
{ label: "Realms", href: "/realms" }
];

View File

@ -1,9 +1,9 @@
import { createSignal, Show } from "solid-js";
import { useParams } from "@solidjs/router";
import PostList from "../components/PostList.tsx";
import PostList from "../components/posts/PostList.tsx";
import NameCard from "../components/NameCard.tsx";
import PostPublish from "../components/PostPublish.tsx";
import PostPublish from "../components/posts/PostPublish.tsx";
import { createStore } from "solid-js/store";
import { closeModel, openModel } from "../scripts/modals.ts";
@ -66,7 +66,6 @@ export default function AccountPage() {
<NameCard accountId={params["accountId"]} onError={setError} />
<dialog id="post-publish" class="modal">
<div class="modal-box p-0 w-[540px]">
<PostPublish

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,16 @@
import styles from "./view.module.css";
export default function CreatorView(props: any) {
return (
<div class={`${styles.wrapper} container mx-auto`}>
<div id="nav" class="card shadow-xl h-fit">
<h2 class="text-xl font-bold mt-1 py-5 px-7">Creator Hub</h2>
</div>
<div id="content" class="card shadow-xl">
{props.children}
</div>
</div>
);
}

View File

@ -1,7 +1,7 @@
import { createSignal, Show } from "solid-js";
import PostList from "../components/PostList.tsx";
import PostPublish from "../components/PostPublish.tsx";
import PostList from "../components/posts/PostList.tsx";
import PostPublish from "../components/posts/PostPublish.tsx";
import { createStore } from "solid-js/store";
export default function DashboardPage() {

View File

@ -2,9 +2,9 @@ import { createSignal, Show } from "solid-js";
import { useNavigate, useParams, useSearchParams } from "@solidjs/router";
import { createStore } from "solid-js/store";
import { closeModel, openModel } from "../scripts/modals.ts";
import PostPublish from "../components/PostPublish.tsx";
import PostList from "../components/PostList.tsx";
import PostItem from "../components/PostItem.tsx";
import PostPublish from "../components/posts/PostPublish.tsx";
import PostList from "../components/posts/PostList.tsx";
import PostItem from "../components/posts/PostItem.tsx";
import { getAtk } from "../stores/userinfo.tsx";
export default function PostPage() {

View File

@ -2,8 +2,8 @@ import { createSignal, Show } from "solid-js";
import { createStore } from "solid-js/store";
import { useNavigate, useParams } from "@solidjs/router";
import PostList from "../../components/PostList.tsx";
import PostPublish from "../../components/PostPublish.tsx";
import PostList from "../../components/posts/PostList.tsx";
import PostPublish from "../../components/posts/PostPublish.tsx";
import styles from "./realm.module.css";
import { getAtk, useUserinfo } from "../../stores/userinfo.tsx";

View File

@ -1,8 +1,8 @@
import { useNavigate, useSearchParams } from "@solidjs/router";
import { createSignal, Show } from "solid-js";
import { createStore } from "solid-js/store";
import PostPublish from "../components/PostPublish.tsx";
import PostList from "../components/PostList.tsx";
import PostPublish from "../components/posts/PostPublish.tsx";
import PostList from "../components/posts/PostList.tsx";
import { closeModel, openModel } from "../scripts/modals.ts";
export default function SearchPage() {

View File

@ -1,11 +1,11 @@
import styles from "./view.module.css";
export default function DashboardPage(props: any) {
export default function FeedView(props: any) {
return (
<div class={`${styles.wrapper} container mx-auto`}>
<div id="trending" class="card shadow-xl h-fit"></div>
<div id="content max-w-[100vw]" class="card shadow-xl">
<div id="content" class="card shadow-xl">
{props.children}
</div>