Better categories

This commit is contained in:
LittleSheep 2024-02-14 22:03:45 +08:00
parent 2e9304fecd
commit 31a09a9074
7 changed files with 283 additions and 96 deletions

View File

@ -0,0 +1,89 @@
package server
import (
"code.smartsheep.studio/hydrogen/interactive/pkg/models"
"code.smartsheep.studio/hydrogen/interactive/pkg/services"
"github.com/gofiber/fiber/v2"
)
func listCategroies(c *fiber.Ctx) error {
categories, err := services.ListCategory()
if err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
}
return c.JSON(categories)
}
func newCategory(c *fiber.Ctx) error {
user := c.Locals("principal").(models.Account)
if user.PowerLevel <= 55 {
return fiber.NewError(fiber.StatusForbidden, "require power level 55 to create categories")
}
var data struct {
Alias string `json:"alias" validate:"required"`
Name string `json:"name" validate:"required"`
Description string `json:"description"`
}
if err := BindAndValidate(c, &data); err != nil {
return err
}
category, err := services.NewCategory(data.Alias, data.Name, data.Description)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
return c.JSON(category)
}
func editCategory(c *fiber.Ctx) error {
user := c.Locals("principal").(models.Account)
if user.PowerLevel <= 55 {
return fiber.NewError(fiber.StatusForbidden, "require power level 55 to edit categories")
}
id, _ := c.ParamsInt("categoryId", 0)
category, err := services.GetCategoryWithID(uint(id))
if err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
}
var data struct {
Alias string `json:"alias" validate:"required"`
Name string `json:"name" validate:"required"`
Description string `json:"description"`
}
if err := BindAndValidate(c, &data); err != nil {
return err
}
category, err = services.EditCategory(category, data.Alias, data.Name, data.Description)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
return c.JSON(category)
}
func deleteCategory(c *fiber.Ctx) error {
user := c.Locals("principal").(models.Account)
if user.PowerLevel <= 55 {
return fiber.NewError(fiber.StatusForbidden, "require power level 55 to delete categories")
}
id, _ := c.ParamsInt("categoryId", 0)
category, err := services.GetCategoryWithID(uint(id))
if err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
}
if err := services.DeleteCategory(category); err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
return c.JSON(category)
}

View File

@ -54,7 +54,7 @@ func listAvailableRealm(c *fiber.Ctx) error {
func createRealm(c *fiber.Ctx) error { func createRealm(c *fiber.Ctx) error {
user := c.Locals("principal").(models.Account) user := c.Locals("principal").(models.Account)
if user.PowerLevel < 10 { if user.PowerLevel < 10 {
return fiber.NewError(fiber.StatusForbidden, "require power level 10 to create realm") return fiber.NewError(fiber.StatusForbidden, "require power level 10 to create realms")
} }
var data struct { var data struct {

View File

@ -1,6 +1,10 @@
package server package server
import ( import (
"net/http"
"strings"
"time"
"code.smartsheep.studio/hydrogen/interactive/pkg/view" "code.smartsheep.studio/hydrogen/interactive/pkg/view"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/cache" "github.com/gofiber/fiber/v2/middleware/cache"
@ -11,9 +15,6 @@ import (
jsoniter "github.com/json-iterator/go" jsoniter "github.com/json-iterator/go"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"github.com/spf13/viper" "github.com/spf13/viper"
"net/http"
"strings"
"time"
) )
var A *fiber.App var A *fiber.App
@ -76,6 +77,11 @@ func NewServer() {
api.Put("/posts/:postId", auth, editPost) api.Put("/posts/:postId", auth, editPost)
api.Delete("/posts/:postId", auth, deletePost) api.Delete("/posts/:postId", auth, deletePost)
api.Get("/categories", listCategroies)
api.Post("/categories", auth, newCategory)
api.Put("/categories/:categoryId", auth, editCategory)
api.Delete("/categories/:categoryId", auth, deleteCategory)
api.Get("/creators/posts", auth, listOwnPost) api.Get("/creators/posts", auth, listOwnPost)
api.Get("/creators/posts/:postId", auth, getOwnPost) api.Get("/creators/posts/:postId", auth, getOwnPost)

View File

@ -1,29 +1,66 @@
package services package services
import ( import (
"errors"
"code.smartsheep.studio/hydrogen/interactive/pkg/database" "code.smartsheep.studio/hydrogen/interactive/pkg/database"
"code.smartsheep.studio/hydrogen/interactive/pkg/models" "code.smartsheep.studio/hydrogen/interactive/pkg/models"
"errors"
"gorm.io/gorm" "gorm.io/gorm"
) )
func GetCategory(alias, name string) (models.Category, error) { func ListCategory() ([]models.Category, error) {
var categories []models.Category
err := database.C.Find(&categories).Error
return categories, err
}
func GetCategory(alias string) (models.Category, error) {
var category models.Category var category models.Category
if err := database.C.Where(models.Category{Alias: alias}).First(&category).Error; err != nil { if err := database.C.Where(models.Category{Alias: alias}).First(&category).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
category = models.Category{
Alias: alias,
Name: name,
}
err := database.C.Save(&category).Error
return category, err
}
return category, err return category, err
} }
return category, nil return category, nil
} }
func GetTag(alias, name string) (models.Tag, error) { func GetCategoryWithID(id uint) (models.Category, error) {
var category models.Category
if err := database.C.Where(models.Category{
BaseModel: models.BaseModel{ID: id},
}).First(&category).Error; err != nil {
return category, err
}
return category, nil
}
func NewCategory(alias, name, description string) (models.Category, error) {
category := models.Category{
Alias: alias,
Name: name,
Description: description,
}
err := database.C.Save(&category).Error
return category, err
}
func EditCategory(category models.Category, alias, name, description string) (models.Category, error) {
category.Alias = alias
category.Name = name
category.Description = description
err := database.C.Save(&category).Error
return category, err
}
func DeleteCategory(category models.Category) error {
return database.C.Delete(category).Error
}
func GetTagOrCreate(alias, name string) (models.Tag, error) {
var tag models.Tag var tag models.Tag
if err := database.C.Where(models.Category{Alias: alias}).First(&tag).Error; err != nil { if err := database.C.Where(models.Category{Alias: alias}).First(&tag).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {

View File

@ -3,9 +3,10 @@ package services
import ( import (
"errors" "errors"
"fmt" "fmt"
"time"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"time"
"code.smartsheep.studio/hydrogen/interactive/pkg/database" "code.smartsheep.studio/hydrogen/interactive/pkg/database"
"code.smartsheep.studio/hydrogen/interactive/pkg/models" "code.smartsheep.studio/hydrogen/interactive/pkg/models"
@ -141,13 +142,13 @@ func NewPost(
var err error var err error
var post models.Post var post models.Post
for idx, category := range categories { for idx, category := range categories {
categories[idx], err = GetCategory(category.Alias, category.Name) categories[idx], err = GetCategory(category.Alias)
if err != nil { if err != nil {
return post, err return post, err
} }
} }
for idx, tag := range tags { for idx, tag := range tags {
tags[idx], err = GetTag(tag.Alias, tag.Name) tags[idx], err = GetTagOrCreate(tag.Alias, tag.Name)
if err != nil { if err != nil {
return post, err return post, err
} }
@ -248,13 +249,13 @@ func EditPost(
) (models.Post, error) { ) (models.Post, error) {
var err error var err error
for idx, category := range categories { for idx, category := range categories {
categories[idx], err = GetCategory(category.Alias, category.Name) categories[idx], err = GetCategory(category.Alias)
if err != nil { if err != nil {
return post, err return post, err
} }
} }
for idx, tag := range tags { for idx, tag := range tags {
tags[idx], err = GetTag(tag.Alias, tag.Name) tags[idx], err = GetTagOrCreate(tag.Alias, tag.Name)
if err != nil { if err != nil {
return post, err return post, err
} }

View File

@ -5,24 +5,34 @@ import { getAtk, useUserinfo } from "../../stores/userinfo.tsx";
import styles from "./PostPublish.module.css"; import styles from "./PostPublish.module.css";
export default function PostEditActions(props: { export default function PostEditActions(props: {
editing?: any, editing?: any;
onInputAlias: (value: string) => void, onInputAlias: (value: string) => void;
onInputPublish: (value: string) => void, onInputPublish: (value: string) => void;
onInputAttachments: (value: any[]) => void, onInputAttachments: (value: any[]) => void;
onInputCategories: (categories: any[]) => void, onInputCategories: (categories: any[]) => void;
onInputTags: (tags: any[]) => void, onInputTags: (tags: any[]) => void;
onError: (message: string | null) => void, onError: (message: string | null) => void;
}) { }) {
const userinfo = useUserinfo(); const userinfo = useUserinfo();
const [uploading, setUploading] = createSignal(false); const [uploading, setUploading] = createSignal(false);
const [attachments, setAttachments] = createSignal<any[]>(props.editing?.attachments ?? []); const [attachments, setAttachments] = createSignal<any[]>(props.editing?.attachments ?? []);
const [categories, setCategories] = createSignal<{ alias: string, name: string }[]>(props.editing?.categories ?? []); const [categories, setCategories] = createSignal<{ alias: string; name: string }[]>(props.editing?.categories ?? []);
const [tags, setTags] = createSignal<{ alias: string, name: string }[]>(props.editing?.tags ?? []); const [tags, setTags] = createSignal<{ alias: string; name: string }[]>(props.editing?.tags ?? []);
const [availableCategories, setAvailableCategories] = createSignal<any[]>([]);
const [attachmentMode, setAttachmentMode] = createSignal(0); const [attachmentMode, setAttachmentMode] = createSignal(0);
async function readCategories() {
const res = await fetch("/api/categories");
if (res.status === 200) {
setAvailableCategories(await res.json());
}
}
readCategories();
async function uploadAttachment(evt: SubmitEvent) { async function uploadAttachment(evt: SubmitEvent) {
evt.preventDefault(); evt.preventDefault();
@ -33,8 +43,8 @@ export default function PostEditActions(props: {
setUploading(true); setUploading(true);
const res = await fetch("/api/attachments", { const res = await fetch("/api/attachments", {
method: "POST", method: "POST",
headers: { "Authorization": `Bearer ${getAtk()}` }, headers: { Authorization: `Bearer ${getAtk()}` },
body: data body: data,
}); });
if (res.status !== 200) { if (res.status !== 200) {
props.onError(await res.text()); props.onError(await res.text());
@ -54,10 +64,14 @@ export default function PostEditActions(props: {
const form = evt.target as HTMLFormElement; const form = evt.target as HTMLFormElement;
const data = Object.fromEntries(new FormData(form)); const data = Object.fromEntries(new FormData(form));
setAttachments(attachments().concat([{ setAttachments(
...data, attachments().concat([
author_id: userinfo?.profiles?.id {
}])); ...data,
author_id: userinfo?.profiles?.id,
},
]),
);
props.onInputAttachments(attachments()); props.onInputAttachments(attachments());
form.reset(); form.reset();
} }
@ -74,10 +88,11 @@ export default function PostEditActions(props: {
const form = evt.target as HTMLFormElement; const form = evt.target as HTMLFormElement;
const data = Object.fromEntries(new FormData(form)); const data = Object.fromEntries(new FormData(form));
if (!data.alias) data.alias = crypto.randomUUID().replace(/-/g, ""); if (!data.category) return;
if (!data.name) return;
setCategories(categories().concat([data as any])); const item = availableCategories().find((item) => item.alias === data.category);
setCategories(categories().concat([item]));
props.onInputCategories(categories()); props.onInputCategories(categories());
form.reset(); form.reset();
} }
@ -134,19 +149,21 @@ export default function PostEditActions(props: {
<span class="label-text">Alias</span> <span class="label-text">Alias</span>
</div> </div>
<input <input
name="alias" type="text" placeholder="Type here" name="alias"
type="text"
placeholder="Type here"
class="input input-bordered w-full" class="input input-bordered w-full"
value={props.editing?.alias ?? ""} value={props.editing?.alias ?? ""}
onInput={(evt) => props.onInputAlias(evt.target.value)} onInput={(evt) => props.onInputAlias(evt.target.value)}
/> />
<div class="label"> <div class="label">
<span class="label-text-alt"> <span class="label-text-alt">Leave blank to generate a random string.</span>
Leave blank to generate a random string.
</span>
</div> </div>
</label> </label>
<div class="modal-action"> <div class="modal-action">
<button type="button" class="btn" onClick={() => closeModel("#alias")}>Close</button> <button type="button" class="btn" onClick={() => closeModel("#alias")}>
Close
</button>
</div> </div>
</div> </div>
</dialog> </dialog>
@ -159,7 +176,8 @@ export default function PostEditActions(props: {
<span class="label-text">Published At</span> <span class="label-text">Published At</span>
</div> </div>
<input <input
name="published_at" type="datetime-local" name="published_at"
type="datetime-local"
placeholder="Pick a date" placeholder="Pick a date"
class="input input-bordered w-full" class="input input-bordered w-full"
value={props.editing?.published_at ?? ""} value={props.editing?.published_at ?? ""}
@ -167,13 +185,14 @@ export default function PostEditActions(props: {
/> />
<div class="label"> <div class="label">
<span class="label-text-alt"> <span class="label-text-alt">
Before this time, your post will not be visible for everyone. Before this time, your post will not be visible for everyone. You can modify this plan on Creator Hub.
You can modify this plan on Creator Hub.
</span> </span>
</div> </div>
</label> </label>
<div class="modal-action"> <div class="modal-action">
<button type="button" class="btn" onClick={() => closeModel("#planning-publish")}>Close</button> <button type="button" class="btn" onClick={() => closeModel("#planning-publish")}>
Close
</button>
</div> </div>
</div> </div>
</dialog> </dialog>
@ -183,10 +202,24 @@ export default function PostEditActions(props: {
<h3 class="font-bold text-lg mx-1">Attachments</h3> <h3 class="font-bold text-lg mx-1">Attachments</h3>
<div role="tablist" class="tabs tabs-boxed mt-3"> <div role="tablist" class="tabs tabs-boxed mt-3">
<input type="radio" name="attachment" role="tab" class="tab" aria-label="File picker" <input
checked={attachmentMode() === 0} onClick={() => setAttachmentMode(0)} /> type="radio"
<input type="radio" name="attachment" role="tab" class="tab" aria-label="External link" name="attachment"
checked={attachmentMode() === 1} onClick={() => setAttachmentMode(1)} /> role="tab"
class="tab"
aria-label="File picker"
checked={attachmentMode() === 0}
onClick={() => setAttachmentMode(0)}
/>
<input
type="radio"
name="attachment"
role="tab"
class="tab"
aria-label="External link"
checked={attachmentMode() === 1}
onClick={() => setAttachmentMode(1)}
/>
</div> </div>
<Switch> <Switch>
@ -197,8 +230,12 @@ export default function PostEditActions(props: {
<span class="label-text">Pick a file</span> <span class="label-text">Pick a file</span>
</div> </div>
<div class="join"> <div class="join">
<input required type="file" name="attachment" <input
class="join-item file-input file-input-bordered w-full" /> 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()}> <button type="submit" class="join-item btn btn-primary" disabled={uploading()}>
<i class="fa-solid fa-upload"></i> <i class="fa-solid fa-upload"></i>
</button> </button>
@ -216,14 +253,29 @@ export default function PostEditActions(props: {
<span class="label-text">Attach an external file</span> <span class="label-text">Attach an external file</span>
</div> </div>
<div class="join"> <div class="join">
<input required type="text" name="mimetype" class="join-item input input-bordered w-full" <input
placeholder="Mimetype" /> required
<input required type="text" name="filename" class="join-item input input-bordered w-full" type="text"
placeholder="Name" /> name="mimetype"
class="join-item input input-bordered w-full"
placeholder="Mimetype"
/>
<input
required
type="text"
name="filename"
class="join-item input input-bordered w-full"
placeholder="Name"
/>
</div> </div>
<div class="join"> <div class="join">
<input required type="text" name="external_url" class="join-item input input-bordered w-full" <input
placeholder="External URL" /> required
type="text"
name="external_url"
class="join-item input input-bordered w-full"
placeholder="External URL"
/>
<button type="submit" class="join-item btn btn-primary"> <button type="submit" class="join-item btn btn-primary">
<i class="fa-solid fa-plus"></i> <i class="fa-solid fa-plus"></i>
</button> </button>
@ -240,19 +292,23 @@ export default function PostEditActions(props: {
<h3 class="font-bold mt-3 mx-1">Attachment list</h3> <h3 class="font-bold mt-3 mx-1">Attachment list</h3>
<ol class="mt-2 mx-1 text-sm"> <ol class="mt-2 mx-1 text-sm">
<For each={attachments()}> <For each={attachments()}>
{(item, idx) => <li> {(item, idx) => (
<i class="fa-regular fa-file me-2"></i> <li>
{item.filename} <i class="fa-regular fa-file me-2"></i>
<button class="ml-2" onClick={() => removeAttachment(idx())}> {item.filename}
<i class="fa-solid fa-delete-left"></i> <button class="ml-2" onClick={() => removeAttachment(idx())}>
</button> <i class="fa-solid fa-delete-left"></i>
</li>} </button>
</li>
)}
</For> </For>
</ol> </ol>
</Show> </Show>
<div class="modal-action"> <div class="modal-action">
<button type="button" class="btn" onClick={() => closeModel("#attachments")}>Close</button> <button type="button" class="btn" onClick={() => closeModel("#attachments")}>
Close
</button>
</div> </div>
</div> </div>
</dialog> </dialog>
@ -266,18 +322,13 @@ export default function PostEditActions(props: {
<span class="label-text">Add a category</span> <span class="label-text">Add a category</span>
</div> </div>
<div class="join"> <div class="join">
<input type="text" name="alias" placeholder="Alias" class="join-item input input-bordered w-full" /> <select name="category" class="join-item select select-bordered w-full">
<input type="text" name="name" placeholder="Name" class="join-item input input-bordered w-full" /> <For each={availableCategories()}>{(item) => <option value={item.alias}>{item.name}</option>}</For>
</select>
<button type="submit" class="join-item btn btn-primary"> <button type="submit" class="join-item btn btn-primary">
<i class="fa-solid fa-plus"></i> <i class="fa-solid fa-plus"></i>
</button> </button>
</div> </div>
<div class="label">
<span class="label-text-alt">
Alias is the url key of this category. Lowercase only, required length 4-24.
Leave blank for auto generate.
</span>
</div>
</label> </label>
</form> </form>
@ -285,13 +336,15 @@ export default function PostEditActions(props: {
<h3 class="font-bold mt-3 mx-1">Category list</h3> <h3 class="font-bold mt-3 mx-1">Category list</h3>
<ol class="mt-2 mx-1 text-sm"> <ol class="mt-2 mx-1 text-sm">
<For each={categories()}> <For each={categories()}>
{(item, idx) => <li> {(item, idx) => (
<i class="fa-solid fa-layer-group me-2"></i> <li>
{item.name} <span class={styles.description}>#{item.alias}</span> <i class="fa-solid fa-layer-group me-2"></i>
<button class="ml-2" onClick={() => removeCategory(idx())}> {item.name} <span class={styles.description}>#{item.alias}</span>
<i class="fa-solid fa-delete-left"></i> <button class="ml-2" onClick={() => removeCategory(idx())}>
</button> <i class="fa-solid fa-delete-left"></i>
</li>} </button>
</li>
)}
</For> </For>
</ol> </ol>
</Show> </Show>
@ -310,8 +363,7 @@ export default function PostEditActions(props: {
</div> </div>
<div class="label"> <div class="label">
<span class="label-text-alt"> <span class="label-text-alt">
Alias is the url key of this tag. Lowercase only, required length 4-24. Alias is the url key of this tag. Lowercase only, required length 4-24. Leave blank for auto generate.
Leave blank for auto generate.
</span> </span>
</div> </div>
</label> </label>
@ -321,22 +373,26 @@ export default function PostEditActions(props: {
<h3 class="font-bold mt-3 mx-1">Category list</h3> <h3 class="font-bold mt-3 mx-1">Category list</h3>
<ol class="mt-2 mx-1 text-sm"> <ol class="mt-2 mx-1 text-sm">
<For each={tags()}> <For each={tags()}>
{(item, idx) => <li> {(item, idx) => (
<i class="fa-solid fa-tag me-2"></i> <li>
{item.name} <span class={styles.description}>#{item.alias}</span> <i class="fa-solid fa-tag me-2"></i>
<button class="ml-2" onClick={() => removeTag(idx())}> {item.name} <span class={styles.description}>#{item.alias}</span>
<i class="fa-solid fa-delete-left"></i> <button class="ml-2" onClick={() => removeTag(idx())}>
</button> <i class="fa-solid fa-delete-left"></i>
</li>} </button>
</li>
)}
</For> </For>
</ol> </ol>
</Show> </Show>
<div class="modal-action"> <div class="modal-action">
<button type="button" class="btn" onClick={() => closeModel("#categories-and-tags")}>Close</button> <button type="button" class="btn" onClick={() => closeModel("#categories-and-tags")}>
Close
</button>
</div> </div>
</div> </div>
</dialog> </dialog>
</> </>
); );
} }

View File

@ -1,4 +1,4 @@
debug = true debug = false
name = "Goatplaza" name = "Goatplaza"
maintainer = "SmartSheep Studio" maintainer = "SmartSheep Studio"
@ -9,8 +9,6 @@ secret = "LtTjzAGFLshwXhN4ZD4nG5KlMv1MWcsvfv03TSZYnT1VhiAnLIZFTnHUwR0XhGgi"
content = "uploads" content = "uploads"
everyone_postable = false
[passport] [passport]
client_id = "goatplaza" client_id = "goatplaza"
client_secret = "Z9k9AFTj^p" client_secret = "Z9k9AFTj^p"