Posts

This commit is contained in:
LittleSheep 2024-02-02 23:42:42 +08:00
parent 19e1775476
commit 89521c15af
20 changed files with 426 additions and 62 deletions

1
go.mod
View File

@ -3,7 +3,6 @@ module code.smartsheep.studio/hydrogen/interactive
go 1.21.6 go 1.21.6
require ( require (
code.smartsheep.studio/hydrogen/passport v0.0.0-20240201075828-dbc09bd7af8a
github.com/go-playground/validator/v10 v10.17.0 github.com/go-playground/validator/v10 v10.17.0
github.com/gofiber/fiber/v2 v2.52.0 github.com/gofiber/fiber/v2 v2.52.0
github.com/golang-jwt/jwt/v5 v5.2.0 github.com/golang-jwt/jwt/v5 v5.2.0

2
go.sum
View File

@ -1,5 +1,3 @@
code.smartsheep.studio/hydrogen/passport v0.0.0-20240201075828-dbc09bd7af8a h1:66YEiBiB+S7QaUnNqQ/Q8zzgGNkQwcHlJdE2s/RdV6k=
code.smartsheep.studio/hydrogen/passport v0.0.0-20240201075828-dbc09bd7af8a/go.mod h1:LM5I1tdQLXY6n+hou3TPNV/v9hA/cD59zlYpspdzGks=
github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs= github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs=
github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=

View File

@ -8,6 +8,10 @@ import (
func RunMigration(source *gorm.DB) error { func RunMigration(source *gorm.DB) error {
if err := source.AutoMigrate( if err := source.AutoMigrate(
&models.Account{}, &models.Account{},
&models.Realm{},
&models.Category{},
&models.Tag{},
&models.Post{},
); err != nil { ); err != nil {
return err return err
} }

View File

@ -6,9 +6,12 @@ package models
type Account struct { type Account struct {
BaseModel BaseModel
Name string `json:"name"` Name string `json:"name"`
Avatar string `json:"avatar"` Avatar string `json:"avatar"`
EmailAddress string `json:"email_address"` Description string `json:"description"`
PowerLevel int `json:"power_level"` EmailAddress string `json:"email_address"`
ExternalID uint `json:"external_id"` PowerLevel int `json:"power_level"`
Posts []Post `json:"posts" gorm:"foreignKey:AuthorID"`
Realms []Realm `json:"realms"`
ExternalID uint `json:"external_id"`
} }

19
pkg/models/categories.go Normal file
View File

@ -0,0 +1,19 @@
package models
type Tag struct {
BaseModel
Alias string `json:"alias" gorm:"uniqueIndex"`
Name string `json:"name"`
Description string `json:"description"`
Posts []Post `json:"posts" gorm:"many2many:post_tags"`
}
type Category struct {
BaseModel
Alias string `json:"alias" gorm:"uniqueIndex"`
Name string `json:"name"`
Description string `json:"description"`
Posts []Post `json:"categories" gorm:"many2many:post_categories"`
}

17
pkg/models/posts.go Normal file
View File

@ -0,0 +1,17 @@
package models
import "time"
type Post struct {
BaseModel
Alias string `json:"alias" gorm:"uniqueIndex"`
Title string `json:"title"`
Content string `json:"content"`
Tags []Tag `gorm:"many2many:post_tags"`
Categories []Category `gorm:"many2many:post_categories"`
PublishedAt time.Time `json:"published_at"`
RealmID *uint `json:"realm_id"`
AuthorID uint `json:"author_id"`
Author Account `json:"author"`
}

10
pkg/models/realms.go Normal file
View File

@ -0,0 +1,10 @@
package models
type Realm struct {
BaseModel
Name string `json:"name"`
Description string `json:"description"`
Posts []Post `json:"posts"`
AccountID uint `json:"account_id"`
}

View File

@ -3,7 +3,7 @@ package server
import ( import (
"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"
"code.smartsheep.studio/hydrogen/passport/pkg/security" "code.smartsheep.studio/hydrogen/interactive/pkg/security"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/keyauth" "github.com/gofiber/fiber/v2/middleware/keyauth"
"strconv" "strconv"

59
pkg/server/posts_api.go Normal file
View File

@ -0,0 +1,59 @@
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"
)
func listPost(c *fiber.Ctx) error {
take := c.QueryInt("take", 0)
offset := c.QueryInt("offset", 0)
var count int64
var posts []models.Post
if err := database.C.
Model(&models.Post{}).
Count(&count).Error; err != nil {
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
if err := database.C.
Limit(take).
Offset(offset).
Order("created_at desc").
Preload("Author").
Find(&posts).Error; err != nil {
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
return c.JSON(fiber.Map{
"count": count,
"data": posts,
})
}
func createPost(c *fiber.Ctx) error {
user := c.Locals("principal").(models.Account)
var data struct {
Alias string `json:"alias" validate:"required"`
Title string `json:"title" validate:"required"`
Content string `json:"content" validate:"required"`
Tags []models.Tag `json:"tags"`
Categories []models.Category `json:"categories"`
}
if err := BindAndValidate(c, &data); err != nil {
return err
}
post, err := services.NewPost(user, data.Alias, data.Title, data.Content, data.Categories, data.Tags)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
return c.JSON(post)
}

View File

@ -61,6 +61,9 @@ func NewServer() {
api.Get("/auth", doLogin) api.Get("/auth", doLogin)
api.Get("/auth/callback", doPostLogin) api.Get("/auth/callback", doPostLogin)
api.Post("/auth/refresh", doRefreshToken) api.Post("/auth/refresh", doRefreshToken)
api.Get("/posts", listPost)
api.Post("/posts", auth, createPost)
} }
A.Use("/", cache.New(cache.Config{ A.Use("/", cache.New(cache.Config{

View File

@ -0,0 +1,40 @@
package services
import (
"code.smartsheep.studio/hydrogen/interactive/pkg/database"
"code.smartsheep.studio/hydrogen/interactive/pkg/models"
"errors"
"gorm.io/gorm"
)
func GetCategory(alias, name string) (models.Category, error) {
var category models.Category
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, nil
}
func GetTag(alias, name string) (models.Tag, error) {
var tag models.Tag
if err := database.C.Where(models.Category{Alias: alias}).First(&tag).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
tag = models.Tag{
Alias: alias,
Name: name,
}
err := database.C.Save(&tag).Error
return tag, err
}
return tag, err
}
return tag, nil
}

59
pkg/services/posts.go Normal file
View File

@ -0,0 +1,59 @@
package services
import (
"code.smartsheep.studio/hydrogen/interactive/pkg/database"
"code.smartsheep.studio/hydrogen/interactive/pkg/models"
)
func NewPost(
user models.Account,
alias, title, content string,
categories []models.Category,
tags []models.Tag,
) (models.Post, error) {
return NewPostWithRealm(user, nil, alias, title, content, categories, tags)
}
func NewPostWithRealm(
user models.Account,
realm *models.Realm,
alias, title, content string,
categories []models.Category,
tags []models.Tag,
) (models.Post, error) {
var err error
var post models.Post
for idx, category := range categories {
categories[idx], err = GetCategory(category.Alias, category.Name)
if err != nil {
return post, err
}
}
for idx, tag := range tags {
tags[idx], err = GetTag(tag.Alias, tag.Name)
if err != nil {
return post, err
}
}
var realmId *uint
if realm != nil {
realmId = &realm.ID
}
post = models.Post{
Alias: alias,
Title: title,
Content: content,
Tags: tags,
Categories: categories,
AuthorID: user.ID,
RealmID: realmId,
}
if err := database.C.Save(&post).Error; err != nil {
return post, err
}
return post, nil
}

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Goatpass</title> <title>Goatplaza</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@ -9,6 +9,7 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-free": "^6.5.1",
"@solidjs/router": "^0.10.10", "@solidjs/router": "^0.10.10",
"solid-js": "^1.8.7", "solid-js": "^1.8.7",
"universal-cookie": "^7.0.2" "universal-cookie": "^7.0.2"

View File

@ -8,6 +8,8 @@ import "./assets/fonts/fonts.css";
import { lazy } from "solid-js"; import { lazy } from "solid-js";
import { Route, Router } from "@solidjs/router"; import { Route, Router } from "@solidjs/router";
import "@fortawesome/fontawesome-free/css/all.css";
import RootLayout from "./layouts/RootLayout.tsx"; import RootLayout from "./layouts/RootLayout.tsx";
import { UserinfoProvider } from "./stores/userinfo.tsx"; import { UserinfoProvider } from "./stores/userinfo.tsx";
import { WellKnownProvider } from "./stores/wellKnown.tsx"; import { WellKnownProvider } from "./stores/wellKnown.tsx";
@ -18,7 +20,7 @@ render(() => (
<WellKnownProvider> <WellKnownProvider>
<UserinfoProvider> <UserinfoProvider>
<Router root={RootLayout}> <Router root={RootLayout}>
<Route path="/" component={lazy(() => import("./pages/dashboard.tsx"))} /> <Route path="/" component={lazy(() => import("./pages/feed.tsx"))} />
<Route path="/auth" component={lazy(() => import("./pages/auth/callout.tsx"))} /> <Route path="/auth" component={lazy(() => import("./pages/auth/callout.tsx"))} />
<Route path="/auth/callback" component={lazy(() => import("./pages/auth/callback.tsx"))} /> <Route path="/auth/callback" component={lazy(() => import("./pages/auth/callback.tsx"))} />
</Router> </Router>

View File

@ -40,7 +40,7 @@ export default function RootLayout(props: any) {
</div> </div>
}> }>
<Navbar /> <Navbar />
<main class="h-[calc(100vh-68px)] mt-[68px]">{props.children}</main> <main class="h-[calc(100vh-64px)] mt-[64px]">{props.children}</main>
</Show> </Show>
); );
} }

View File

@ -11,13 +11,7 @@ interface MenuItem {
export default function Navbar() { export default function Navbar() {
const nav: MenuItem[] = [ const nav: MenuItem[] = [
{ { label: "Feed", href: "/" }
label: "You", children: [
{ label: "Dashboard", href: "/" },
{ label: "Security", href: "/security" },
{ label: "Personalise", href: "/personalise" }
]
}
]; ];
const wellKnown = useWellKnown(); const wellKnown = useWellKnown();

View File

@ -1,43 +0,0 @@
import { useUserinfo } from "../stores/userinfo.tsx";
import { createSignal, Show } from "solid-js";
export default function DashboardPage() {
const userinfo = useUserinfo();
const [error, setError] = createSignal<string | null>(null);
function getGreeting() {
const currentHour = new Date().getHours();
if (currentHour >= 0 && currentHour < 12) {
return "Good morning! Wishing you a day filled with joy and success. ☀️";
} else if (currentHour >= 12 && currentHour < 18) {
return "Afternoon! Hope you have a productive and joyful afternoon! ☀️";
} else {
return "Good evening! Wishing you a relaxing and pleasant evening. 🌙";
}
}
return (
<div class="max-w-[720px] mx-auto px-5 pt-12">
<div id="greeting" class="px-5">
<h1 class="text-2xl font-bold">{userinfo?.displayName}</h1>
<p>{getGreeting()}</p>
</div>
<div id="alerts">
<Show when={error()}>
<div role="alert" class="alert alert-error mt-5">
<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>
);
}

View File

@ -0,0 +1,18 @@
.publishInput {
outline-style: none !important;
outline-width: 0 !important;
}
.wrapper {
display: grid;
grid-template-columns: 1fr;
column-gap: 20px;
max-height: calc(100vh - 64px);
}
@media (min-width: 768px) {
.wrapper {
grid-template-columns: 1fr 2fr 1fr;
}
}

181
pkg/view/src/pages/feed.tsx Normal file
View File

@ -0,0 +1,181 @@
import { getAtk, useUserinfo } from "../stores/userinfo.tsx";
import { createSignal, For, Show } from "solid-js";
import styles from "./feed.module.css";
export default function DashboardPage() {
const userinfo = useUserinfo();
const [error, setError] = createSignal<string | null>(null);
const [loading, setLoading] = createSignal(true);
const [submitting, setSubmitting] = createSignal(false);
const [posts, setPosts] = createSignal<any[]>([]);
const [postCount, setPostCount] = createSignal(0);
const [page, setPage] = createSignal(1);
async function readPosts() {
setLoading(true);
const res = await fetch("/api/posts?" + new URLSearchParams({
take: (10).toString(),
skip: ((page() - 1) * 10).toString()
}));
if (res.status !== 200) {
setError(await res.text());
} else {
const data = await res.json();
setPosts(data["data"]);
setPostCount(data["count"]);
setError(null);
}
setLoading(false);
}
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
})
});
if (res.status !== 200) {
setError(await res.text());
} else {
await readPosts();
form.reset();
setError(null);
}
setSubmitting(false);
}
readPosts();
return (
<div class={`${styles.wrapper} container mx-auto`}>
<div id="trending" class="card shadow-xl h-fit"></div>
<div id="content" class="card shadow-xl">
<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>
<form id="publish" onSubmit={doPost}>
<div id="publish-identity" class="flex border-y border-base-200">
<div class="avatar">
<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" class={`${styles.publishInput} input w-full`}
placeholder="The describe for a long content (Optional)" />
</div>
</div>
<textarea name="content" class={`${styles.publishInput} textarea w-full`}
placeholder="What's happend?!" />
<div id="publish-actions" class="flex justify-between border-y border-base-200">
<div>
<button type="button" class="btn btn-ghost">
<i class="fa-solid fa-paperclip"></i>
</button>
</div>
<button type="submit" class="btn btn-primary" disabled={submitting()}>
<Show when={submitting()} fallback={"Post a post"}>
<span class="loading"></span>
</Show>
</button>
</div>
</form>
<div id="posts">
<Show when={!loading()} fallback={<span class="loading loading-lg loading-infinity"></span>}>
<For each={posts()}>
{item => <div class="post-item">
<div class="flex bg-base-200">
<div class="avatar">
<div class="w-12">
<Show when={item.author.avatar}
fallback={<span class="text-3xl">{item.author.name.substring(0, 1)}</span>}>
<img alt="avatar" src={item.author.avatar} />
</Show>
</div>
</div>
<div class="flex items-center px-5">
<div>
<h3 class="font-bold text-sm">{item.author.name}</h3>
<p class="text-xs">{item.author.description}</p>
</div>
</div>
</div>
<article class="py-5 px-7">
<h2 class="card-title">{item.title}</h2>
<article class="prose">{item.content}</article>
</article>
<div class="grid grid-cols-4 border-y border-base-200">
<div class="tooltip" data-tip="Daisuki">
<button type="button" class="btn btn-ghost btn-block">
<i class="fa-solid fa-thumbs-up"></i>
<code class="font-mono">0</code>
</button>
</div>
<div class="tooltip" data-tip="Daikirai">
<button type="button" class="btn btn-ghost btn-block">
<i class="fa-solid fa-thumbs-down"></i>
<code class="font-mono">0</code>
</button>
</div>
<button type="button" class="btn btn-ghost">
<i class="fa-solid fa-reply"></i>
<span>Reply</span>
</button>
<button type="button" class="btn btn-ghost">
<i class="fa-solid fa-retweet"></i>
<span>Forward</span>
</button>
</div>
</div>}
</For>
</Show>
</div>
</div>
<div id="well-known" class="card shadow-xl h-fit"></div>
</div>
);
}