🔀 Merge pull request '♻️ Interactive v2' (#1) from refactor/v2 into master
Reviewed-on: Hydrogen/Interactive#1
This commit is contained in:
commit
bc2517dcda
46
.air.toml
Normal file
46
.air.toml
Normal file
@ -0,0 +1,46 @@
|
||||
root = "."
|
||||
testdata_dir = "testdata"
|
||||
tmp_dir = "dist"
|
||||
|
||||
[build]
|
||||
args_bin = []
|
||||
bin = "./dist/server"
|
||||
cmd = "go build -o ./dist/server ./pkg/cmd/main.go"
|
||||
delay = 1000
|
||||
exclude_dir = ["assets", "tmp", "vendor", "testdata", "pkg/views"]
|
||||
exclude_file = []
|
||||
exclude_regex = ["_test.go"]
|
||||
exclude_unchanged = false
|
||||
follow_symlink = false
|
||||
full_bin = ""
|
||||
include_dir = []
|
||||
include_ext = ["go", "tpl", "tmpl", "html"]
|
||||
include_file = []
|
||||
kill_delay = "0s"
|
||||
log = "build-errors.log"
|
||||
poll = false
|
||||
poll_interval = 0
|
||||
post_cmd = []
|
||||
pre_cmd = []
|
||||
rerun = false
|
||||
rerun_delay = 500
|
||||
send_interrupt = false
|
||||
stop_on_error = false
|
||||
|
||||
[color]
|
||||
app = ""
|
||||
build = "yellow"
|
||||
main = "magenta"
|
||||
runner = "green"
|
||||
watcher = "cyan"
|
||||
|
||||
[log]
|
||||
main_only = false
|
||||
time = false
|
||||
|
||||
[misc]
|
||||
clean_on_exit = false
|
||||
|
||||
[screen]
|
||||
clear_on_rebuild = false
|
||||
keep_scroll = true
|
2
.github/workflows/nightly.yml
vendored
2
.github/workflows/nightly.yml
vendored
@ -25,4 +25,4 @@ jobs:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
tags: xsheep2010/interactive:nightly
|
||||
tags: xsheep2010/interactive:v2
|
||||
|
3
.gitignore
vendored
3
.gitignore
vendored
@ -1 +1,4 @@
|
||||
/uploads
|
||||
/dist
|
||||
|
||||
.DS_Store
|
||||
|
@ -19,10 +19,6 @@
|
||||
<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>
|
||||
|
@ -1,6 +1,7 @@
|
||||
<?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" />
|
||||
<file url="file://$PROJECT_DIR$/pkg/server/moments_api.go" dialect="PostgreSQL" />
|
||||
<file url="file://$APPLICATION_CONFIG_DIR$/consoles/db/2e2101b2-4037-47ee-88ed-456dc2cb4423/console.sql" dialect="PostgreSQL" />
|
||||
</component>
|
||||
</project>
|
@ -5,7 +5,7 @@ RUN apk add nodejs npm
|
||||
|
||||
WORKDIR /source
|
||||
COPY . .
|
||||
WORKDIR /source/pkg/view
|
||||
WORKDIR /source/pkg/views
|
||||
RUN npm install
|
||||
RUN npm run build
|
||||
WORKDIR /source
|
||||
|
1
go.mod
1
go.mod
@ -24,6 +24,7 @@ require (
|
||||
github.com/andybalholm/brotli v1.0.5 // indirect
|
||||
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
|
||||
github.com/gertd/go-pluralize v0.2.1 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-sql-driver/mysql v1.7.1 // indirect
|
||||
|
2
go.sum
2
go.sum
@ -17,6 +17,8 @@ github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nos
|
||||
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
|
||||
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
|
||||
github.com/gertd/go-pluralize v0.2.1 h1:M3uASbVjMnTsPb0PNqg+E/24Vwigyo/tvyMTtAlLgiA=
|
||||
github.com/gertd/go-pluralize v0.2.1/go.mod h1:rbYaKDbsXxmRfr8uygAEKhOWsjyrrqrkHVpZvoOp8zk=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
|
@ -13,9 +13,10 @@ func RunMigration(source *gorm.DB) error {
|
||||
&models.RealmMember{},
|
||||
&models.Category{},
|
||||
&models.Tag{},
|
||||
&models.Post{},
|
||||
&models.PostLike{},
|
||||
&models.PostDislike{},
|
||||
&models.Moment{},
|
||||
&models.Article{},
|
||||
&models.Comment{},
|
||||
&models.Reaction{},
|
||||
&models.Attachment{},
|
||||
); err != nil {
|
||||
return err
|
||||
|
@ -14,10 +14,10 @@ type Account struct {
|
||||
Description string `json:"description"`
|
||||
EmailAddress string `json:"email_address"`
|
||||
PowerLevel int `json:"power_level"`
|
||||
Posts []Post `json:"posts" gorm:"foreignKey:AuthorID"`
|
||||
Moments []Moment `json:"moments" gorm:"foreignKey:AuthorID"`
|
||||
Articles []Article `json:"articles" gorm:"foreignKey:AuthorID"`
|
||||
Attachments []Attachment `json:"attachments" gorm:"foreignKey:AuthorID"`
|
||||
LikedPosts []PostLike `json:"liked_posts"`
|
||||
DislikedPosts []PostDislike `json:"disliked_posts"`
|
||||
Reactions []Reaction `json:"reactions"`
|
||||
RealmIdentities []RealmMember `json:"identities"`
|
||||
Realms []Realm `json:"realms"`
|
||||
ExternalID uint `json:"external_id"`
|
||||
|
41
pkg/models/articles.go
Normal file
41
pkg/models/articles.go
Normal file
@ -0,0 +1,41 @@
|
||||
package models
|
||||
|
||||
type Article struct {
|
||||
PostBase
|
||||
|
||||
Title string `json:"title"`
|
||||
Hashtags []Tag `json:"tags" gorm:"many2many:article_tags"`
|
||||
Categories []Category `json:"categories" gorm:"many2many:article_categories"`
|
||||
Reactions []Reaction `json:"reactions"`
|
||||
Attachments []Attachment `json:"attachments"`
|
||||
Description string `json:"description"`
|
||||
Content string `json:"content"`
|
||||
RealmID *uint `json:"realm_id"`
|
||||
Realm *Realm `json:"realm"`
|
||||
|
||||
Comments []Comment `json:"comments" gorm:"foreignKey:ArticleID"`
|
||||
}
|
||||
|
||||
func (p *Article) GetReplyTo() PostInterface {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Article) GetRepostTo() PostInterface {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Article) GetHashtags() []Tag {
|
||||
return p.Hashtags
|
||||
}
|
||||
|
||||
func (p *Article) GetCategories() []Category {
|
||||
return p.Categories
|
||||
}
|
||||
|
||||
func (p *Article) SetHashtags(tags []Tag) {
|
||||
p.Hashtags = tags
|
||||
}
|
||||
|
||||
func (p *Article) SetCategories(categories []Category) {
|
||||
p.Categories = categories
|
||||
}
|
@ -2,8 +2,18 @@ package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/spf13/viper"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
type AttachmentType = uint8
|
||||
|
||||
const (
|
||||
AttachmentOthers = AttachmentType(iota)
|
||||
AttachmentPhoto
|
||||
AttachmentVideo
|
||||
AttachmentAudio
|
||||
)
|
||||
|
||||
type Attachment struct {
|
||||
@ -13,10 +23,12 @@ type Attachment struct {
|
||||
Filesize int64 `json:"filesize"`
|
||||
Filename string `json:"filename"`
|
||||
Mimetype string `json:"mimetype"`
|
||||
Type AttachmentType `json:"type"`
|
||||
ExternalUrl string `json:"external_url"`
|
||||
Post *Post `json:"post"`
|
||||
Author Account `json:"author"`
|
||||
PostID *uint `json:"post_id"`
|
||||
ArticleID *uint `json:"article_id"`
|
||||
MomentID *uint `json:"moment_id"`
|
||||
CommentID *uint `json:"comment_id"`
|
||||
AuthorID uint `json:"author_id"`
|
||||
}
|
||||
|
||||
|
@ -6,7 +6,9 @@ type Tag struct {
|
||||
Alias string `json:"alias" gorm:"uniqueIndex" validate:"lowercase,alphanum,min=4,max=24"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Posts []Post `json:"posts" gorm:"many2many:post_tags"`
|
||||
Articles []Article `json:"articles" gorm:"many2many:article_tags"`
|
||||
Moments []Moment `json:"moments" gorm:"many2many:moment_tags"`
|
||||
Comments []Comment `json:"comments" gorm:"many2many:comment_tags"`
|
||||
}
|
||||
|
||||
type Category struct {
|
||||
@ -15,5 +17,7 @@ type Category struct {
|
||||
Alias string `json:"alias" gorm:"uniqueIndex" validate:"lowercase,alphanum,min=4,max=24"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Posts []Post `json:"categories" gorm:"many2many:post_categories"`
|
||||
Articles []Article `json:"articles" gorm:"many2many:article_categories"`
|
||||
Moments []Moment `json:"moments" gorm:"many2many:moment_categories"`
|
||||
Comments []Comment `json:"comments" gorm:"many2many:comment_categories"`
|
||||
}
|
||||
|
37
pkg/models/comments.go
Normal file
37
pkg/models/comments.go
Normal file
@ -0,0 +1,37 @@
|
||||
package models
|
||||
|
||||
type Comment struct {
|
||||
PostBase
|
||||
|
||||
Content string `json:"content"`
|
||||
Hashtags []Tag `json:"tags" gorm:"many2many:comment_tags"`
|
||||
Categories []Category `json:"categories" gorm:"many2many:comment_categories"`
|
||||
Reactions []Reaction `json:"reactions"`
|
||||
ReplyID *uint `json:"reply_id"`
|
||||
ReplyTo *Comment `json:"reply_to" gorm:"foreignKey:ReplyID"`
|
||||
|
||||
ArticleID *uint `json:"article_id"`
|
||||
MomentID *uint `json:"moment_id"`
|
||||
Article *Article `json:"article"`
|
||||
Moment *Moment `json:"moment"`
|
||||
}
|
||||
|
||||
func (p *Comment) GetReplyTo() PostInterface {
|
||||
return p.ReplyTo
|
||||
}
|
||||
|
||||
func (p *Comment) GetHashtags() []Tag {
|
||||
return p.Hashtags
|
||||
}
|
||||
|
||||
func (p *Comment) GetCategories() []Category {
|
||||
return p.Categories
|
||||
}
|
||||
|
||||
func (p *Comment) SetHashtags(tags []Tag) {
|
||||
p.Hashtags = tags
|
||||
}
|
||||
|
||||
func (p *Comment) SetCategories(categories []Category) {
|
||||
p.Categories = categories
|
||||
}
|
22
pkg/models/feed.go
Normal file
22
pkg/models/feed.go
Normal file
@ -0,0 +1,22 @@
|
||||
package models
|
||||
|
||||
type Feed struct {
|
||||
BaseModel
|
||||
|
||||
Alias string `json:"alias"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Content string `json:"content"`
|
||||
ModelType string `json:"model_type"`
|
||||
|
||||
CommentCount int64 `json:"comment_count"`
|
||||
ReactionCount int64 `json:"reaction_count"`
|
||||
|
||||
AuthorID uint `json:"author_id"`
|
||||
RealmID *uint `json:"realm_id"`
|
||||
|
||||
Author Account `json:"author" gorm:"embedded"`
|
||||
|
||||
Attachments []Attachment `json:"attachments" gorm:"-"`
|
||||
ReactionList map[string]int64 `json:"reaction_list" gorm:"-"`
|
||||
}
|
41
pkg/models/moments.go
Normal file
41
pkg/models/moments.go
Normal file
@ -0,0 +1,41 @@
|
||||
package models
|
||||
|
||||
type Moment struct {
|
||||
PostBase
|
||||
|
||||
Content string `json:"content"`
|
||||
Hashtags []Tag `json:"tags" gorm:"many2many:moment_tags"`
|
||||
Categories []Category `json:"categories" gorm:"many2many:moment_categories"`
|
||||
Reactions []Reaction `json:"reactions"`
|
||||
Attachments []Attachment `json:"attachments"`
|
||||
RealmID *uint `json:"realm_id"`
|
||||
RepostID *uint `json:"repost_id"`
|
||||
Realm *Realm `json:"realm"`
|
||||
RepostTo *Moment `json:"repost_to" gorm:"foreignKey:RepostID"`
|
||||
|
||||
Comments []Comment `json:"comments" gorm:"foreignKey:MomentID"`
|
||||
}
|
||||
|
||||
func (p *Moment) GetRepostTo() PostInterface {
|
||||
return p.RepostTo
|
||||
}
|
||||
|
||||
func (p *Moment) GetRealm() *Realm {
|
||||
return p.Realm
|
||||
}
|
||||
|
||||
func (p *Moment) GetHashtags() []Tag {
|
||||
return p.Hashtags
|
||||
}
|
||||
|
||||
func (p *Moment) GetCategories() []Category {
|
||||
return p.Categories
|
||||
}
|
||||
|
||||
func (p *Moment) SetHashtags(tags []Tag) {
|
||||
p.Hashtags = tags
|
||||
}
|
||||
|
||||
func (p *Moment) SetCategories(categories []Category) {
|
||||
p.Categories = categories
|
||||
}
|
@ -1,32 +1,64 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type Post struct {
|
||||
type PostReactInfo struct {
|
||||
PostID uint `json:"post_id"`
|
||||
LikeCount int64 `json:"like_count"`
|
||||
DislikeCount int64 `json:"dislike_count"`
|
||||
ReplyCount int64 `json:"reply_count"`
|
||||
RepostCount int64 `json:"repost_count"`
|
||||
}
|
||||
|
||||
type PostBase struct {
|
||||
BaseModel
|
||||
|
||||
Alias string `json:"alias" gorm:"uniqueIndex"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Tags []Tag `json:"tags" gorm:"many2many:post_tags"`
|
||||
Categories []Category `json:"categories" gorm:"many2many:post_categories"`
|
||||
Attachments []Attachment `json:"attachments"`
|
||||
LikedAccounts []PostLike `json:"liked_accounts"`
|
||||
DislikedAccounts []PostDislike `json:"disliked_accounts"`
|
||||
RepostTo *Post `json:"repost_to" gorm:"foreignKey:RepostID"`
|
||||
ReplyTo *Post `json:"reply_to" gorm:"foreignKey:ReplyID"`
|
||||
PinnedAt *time.Time `json:"pinned_at"`
|
||||
EditedAt *time.Time `json:"edited_at"`
|
||||
PublishedAt time.Time `json:"published_at"`
|
||||
RepostID *uint `json:"repost_id"`
|
||||
ReplyID *uint `json:"reply_id"`
|
||||
RealmID *uint `json:"realm_id"`
|
||||
PublishedAt *time.Time `json:"published_at"`
|
||||
|
||||
AuthorID uint `json:"author_id"`
|
||||
Author Account `json:"author"`
|
||||
|
||||
// Dynamic Calculating Values
|
||||
LikeCount int64 `json:"like_count" gorm:"-"`
|
||||
DislikeCount int64 `json:"dislike_count" gorm:"-"`
|
||||
ReplyCount int64 `json:"reply_count" gorm:"-"`
|
||||
RepostCount int64 `json:"repost_count" gorm:"-"`
|
||||
// Dynamic Calculated Values
|
||||
ReactionList map[string]int64 `json:"reaction_list" gorm:"-"`
|
||||
}
|
||||
|
||||
func (p *PostBase) GetID() uint {
|
||||
return p.ID
|
||||
}
|
||||
|
||||
func (p *PostBase) GetReplyTo() PostInterface {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *PostBase) GetRepostTo() PostInterface {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *PostBase) GetAuthor() Account {
|
||||
return p.Author
|
||||
}
|
||||
|
||||
func (p *PostBase) GetRealm() *Realm {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *PostBase) SetReactionList(list map[string]int64) {
|
||||
p.ReactionList = list
|
||||
}
|
||||
|
||||
type PostInterface interface {
|
||||
GetID() uint
|
||||
GetHashtags() []Tag
|
||||
GetCategories() []Category
|
||||
GetReplyTo() PostInterface
|
||||
GetRepostTo() PostInterface
|
||||
GetAuthor() Account
|
||||
GetRealm() *Realm
|
||||
|
||||
SetHashtags([]Tag)
|
||||
SetCategories([]Category)
|
||||
SetReactionList(map[string]int64)
|
||||
}
|
||||
|
@ -1,19 +1,27 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type PostLike struct {
|
||||
type ReactionAttitude = uint8
|
||||
|
||||
const (
|
||||
AttitudeNeutral = ReactionAttitude(iota)
|
||||
AttitudePositive
|
||||
AttitudeNegative
|
||||
)
|
||||
|
||||
type Reaction struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
PostID uint `json:"post_id"`
|
||||
AccountID uint `json:"account_id"`
|
||||
}
|
||||
|
||||
type PostDislike struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
PostID uint `json:"post_id"`
|
||||
Symbol string `json:"symbol"`
|
||||
Attitude ReactionAttitude `json:"attitude"`
|
||||
|
||||
ArticleID *uint `json:"article_id"`
|
||||
MomentID *uint `json:"moment_id"`
|
||||
CommentID *uint `json:"comment_id"`
|
||||
AccountID uint `json:"account_id"`
|
||||
}
|
||||
|
@ -5,7 +5,8 @@ type Realm struct {
|
||||
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Posts []Post `json:"posts"`
|
||||
Articles []Article `json:"article"`
|
||||
Moments []Moment `json:"moments"`
|
||||
Members []RealmMember `json:"members"`
|
||||
IsPublic bool `json:"is_public"`
|
||||
AccountID uint `json:"account_id"`
|
||||
|
140
pkg/server/articles_api.go
Normal file
140
pkg/server/articles_api.go
Normal file
@ -0,0 +1,140 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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/google/uuid"
|
||||
)
|
||||
|
||||
func contextArticle() *services.PostTypeContext {
|
||||
return &services.PostTypeContext{
|
||||
Tx: database.C,
|
||||
TableName: "articles",
|
||||
ColumnName: "article",
|
||||
CanReply: false,
|
||||
CanRepost: false,
|
||||
}
|
||||
}
|
||||
|
||||
func createArticle(c *fiber.Ctx) error {
|
||||
user := c.Locals("principal").(models.Account)
|
||||
|
||||
var data struct {
|
||||
Alias string `json:"alias" form:"alias"`
|
||||
Title string `json:"title" form:"title" validate:"required"`
|
||||
Description string `json:"description" form:"description"`
|
||||
Content string `json:"content" form:"content" validate:"required"`
|
||||
Hashtags []models.Tag `json:"hashtags" form:"hashtags"`
|
||||
Categories []models.Category `json:"categories" form:"categories"`
|
||||
Attachments []models.Attachment `json:"attachments" form:"attachments"`
|
||||
PublishedAt *time.Time `json:"published_at" form:"published_at"`
|
||||
RealmID *uint `json:"realm_id" form:"realm_id"`
|
||||
}
|
||||
|
||||
if err := BindAndValidate(c, &data); err != nil {
|
||||
return err
|
||||
} else if len(data.Alias) == 0 {
|
||||
data.Alias = strings.ReplaceAll(uuid.NewString(), "-", "")
|
||||
}
|
||||
|
||||
item := &models.Article{
|
||||
PostBase: models.PostBase{
|
||||
Alias: data.Alias,
|
||||
PublishedAt: data.PublishedAt,
|
||||
AuthorID: user.ID,
|
||||
},
|
||||
Hashtags: data.Hashtags,
|
||||
Categories: data.Categories,
|
||||
Attachments: data.Attachments,
|
||||
Title: data.Title,
|
||||
Description: data.Description,
|
||||
Content: data.Content,
|
||||
RealmID: data.RealmID,
|
||||
}
|
||||
|
||||
var realm *models.Realm
|
||||
if data.RealmID != nil {
|
||||
if err := database.C.Where(&models.Realm{
|
||||
BaseModel: models.BaseModel{ID: *data.RealmID},
|
||||
}).First(&realm).Error; err != nil {
|
||||
return fiber.NewError(fiber.StatusBadRequest, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
if item, err := services.NewPost(item); err != nil {
|
||||
return fiber.NewError(fiber.StatusBadRequest, err.Error())
|
||||
} else {
|
||||
return c.JSON(item)
|
||||
}
|
||||
}
|
||||
|
||||
func editArticle(c *fiber.Ctx) error {
|
||||
user := c.Locals("principal").(models.Account)
|
||||
id, _ := c.ParamsInt("articleId", 0)
|
||||
|
||||
var data struct {
|
||||
Alias string `json:"alias" form:"alias" validate:"required"`
|
||||
Title string `json:"title" form:"title" validate:"required"`
|
||||
Description string `json:"description" form:"description"`
|
||||
Content string `json:"content" form:"content" validate:"required"`
|
||||
PublishedAt *time.Time `json:"published_at" form:"published_at"`
|
||||
Hashtags []models.Tag `json:"hashtags" form:"hashtags"`
|
||||
Categories []models.Category `json:"categories" form:"categories"`
|
||||
Attachments []models.Attachment `json:"attachments" form:"attachments"`
|
||||
}
|
||||
|
||||
if err := BindAndValidate(c, &data); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var item *models.Article
|
||||
if err := database.C.Where(models.Article{
|
||||
PostBase: models.PostBase{
|
||||
BaseModel: models.BaseModel{ID: uint(id)},
|
||||
AuthorID: user.ID,
|
||||
},
|
||||
}).First(&item).Error; err != nil {
|
||||
return fiber.NewError(fiber.StatusNotFound, err.Error())
|
||||
}
|
||||
|
||||
item.Alias = data.Alias
|
||||
item.Title = data.Title
|
||||
item.Description = data.Description
|
||||
item.Content = data.Content
|
||||
item.PublishedAt = data.PublishedAt
|
||||
item.Hashtags = data.Hashtags
|
||||
item.Categories = data.Categories
|
||||
item.Attachments = data.Attachments
|
||||
|
||||
if item, err := services.EditPost(item); err != nil {
|
||||
return fiber.NewError(fiber.StatusBadRequest, err.Error())
|
||||
} else {
|
||||
return c.JSON(item)
|
||||
}
|
||||
}
|
||||
|
||||
func deleteArticle(c *fiber.Ctx) error {
|
||||
user := c.Locals("principal").(models.Account)
|
||||
id, _ := c.ParamsInt("articleId", 0)
|
||||
|
||||
var item *models.Article
|
||||
if err := database.C.Where(models.Article{
|
||||
PostBase: models.PostBase{
|
||||
BaseModel: models.BaseModel{ID: uint(id)},
|
||||
AuthorID: user.ID,
|
||||
},
|
||||
}).First(&item).Error; err != nil {
|
||||
return fiber.NewError(fiber.StatusNotFound, err.Error())
|
||||
}
|
||||
|
||||
if err := services.DeletePost(item); err != nil {
|
||||
return fiber.NewError(fiber.StatusBadRequest, err.Error())
|
||||
}
|
||||
|
||||
return c.SendStatus(fiber.StatusOK)
|
||||
}
|
@ -6,7 +6,7 @@ import (
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
func listCategroies(c *fiber.Ctx) error {
|
||||
func listCategories(c *fiber.Ctx) error {
|
||||
categories, err := services.ListCategory()
|
||||
if err != nil {
|
||||
return fiber.NewError(fiber.StatusNotFound, err.Error())
|
||||
|
187
pkg/server/comments_api.go
Normal file
187
pkg/server/comments_api.go
Normal file
@ -0,0 +1,187 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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/google/uuid"
|
||||
)
|
||||
|
||||
func contextComment() *services.PostTypeContext {
|
||||
return &services.PostTypeContext{
|
||||
Tx: database.C,
|
||||
TableName: "comments",
|
||||
ColumnName: "comment",
|
||||
CanReply: false,
|
||||
CanRepost: true,
|
||||
}
|
||||
}
|
||||
|
||||
func listComment(c *fiber.Ctx) error {
|
||||
take := c.QueryInt("take", 0)
|
||||
offset := c.QueryInt("offset", 0)
|
||||
|
||||
alias := c.Params("postId")
|
||||
|
||||
mx := c.Locals(postContextKey).(*services.PostTypeContext).
|
||||
FilterPublishedAt(time.Now())
|
||||
|
||||
item, err := mx.GetViaAlias(alias)
|
||||
if err != nil {
|
||||
return fiber.NewError(fiber.StatusNotFound, err.Error())
|
||||
}
|
||||
|
||||
data, err := mx.ListComment(item.ID, take, offset)
|
||||
if err != nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
count, err := mx.CountComment(item.ID)
|
||||
if err != nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"count": count,
|
||||
"data": data,
|
||||
})
|
||||
}
|
||||
|
||||
func createComment(c *fiber.Ctx) error {
|
||||
user := c.Locals("principal").(models.Account)
|
||||
|
||||
var data struct {
|
||||
Alias string `json:"alias" form:"alias"`
|
||||
Content string `json:"content" form:"content" validate:"required"`
|
||||
PublishedAt *time.Time `json:"published_at" form:"published_at"`
|
||||
Hashtags []models.Tag `json:"hashtags" form:"hashtags"`
|
||||
Categories []models.Category `json:"categories" form:"categories"`
|
||||
ReplyTo uint `json:"reply_to" form:"reply_to"`
|
||||
}
|
||||
|
||||
if err := BindAndValidate(c, &data); err != nil {
|
||||
return err
|
||||
} else if len(data.Alias) == 0 {
|
||||
data.Alias = strings.ReplaceAll(uuid.NewString(), "-", "")
|
||||
}
|
||||
|
||||
item := &models.Comment{
|
||||
PostBase: models.PostBase{
|
||||
Alias: data.Alias,
|
||||
PublishedAt: data.PublishedAt,
|
||||
AuthorID: user.ID,
|
||||
},
|
||||
Hashtags: data.Hashtags,
|
||||
Categories: data.Categories,
|
||||
Content: data.Content,
|
||||
}
|
||||
|
||||
postType := c.Params("postType")
|
||||
alias := c.Params("postId")
|
||||
|
||||
var err error
|
||||
var res models.Feed
|
||||
|
||||
switch postType {
|
||||
case "moments":
|
||||
err = database.C.Model(&models.Moment{}).Where("alias = ?", alias).Select("id").First(&res).Error
|
||||
case "articles":
|
||||
err = database.C.Model(&models.Article{}).Where("alias = ?", alias).Select("id").First(&res).Error
|
||||
default:
|
||||
return fiber.NewError(fiber.StatusBadRequest, "comment must belongs to a resource")
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("belongs to resource was not found: %v", err))
|
||||
} else {
|
||||
switch postType {
|
||||
case "moments":
|
||||
item.MomentID = &res.ID
|
||||
case "articles":
|
||||
item.ArticleID = &res.ID
|
||||
}
|
||||
}
|
||||
|
||||
var relatedCount int64
|
||||
if data.ReplyTo > 0 {
|
||||
if err := database.C.Where("id = ?", data.ReplyTo).
|
||||
Model(&models.Comment{}).Count(&relatedCount).Error; err != nil {
|
||||
return fiber.NewError(fiber.StatusBadRequest, err.Error())
|
||||
} else if relatedCount <= 0 {
|
||||
return fiber.NewError(fiber.StatusNotFound, "related post was not found")
|
||||
} else {
|
||||
item.ReplyID = &data.ReplyTo
|
||||
}
|
||||
}
|
||||
|
||||
if item, err := services.NewPost(item); err != nil {
|
||||
return fiber.NewError(fiber.StatusBadRequest, err.Error())
|
||||
} else {
|
||||
return c.JSON(item)
|
||||
}
|
||||
}
|
||||
|
||||
func editComment(c *fiber.Ctx) error {
|
||||
user := c.Locals("principal").(models.Account)
|
||||
id, _ := c.ParamsInt("commentId", 0)
|
||||
|
||||
var data struct {
|
||||
Alias string `json:"alias" form:"alias" validate:"required"`
|
||||
Content string `json:"content" form:"content" validate:"required"`
|
||||
PublishedAt *time.Time `json:"published_at" form:"published_at"`
|
||||
Hashtags []models.Tag `json:"hashtags" form:"hashtags"`
|
||||
Categories []models.Category `json:"categories" form:"categories"`
|
||||
}
|
||||
|
||||
if err := BindAndValidate(c, &data); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var item *models.Comment
|
||||
if err := database.C.Where(models.Comment{
|
||||
PostBase: models.PostBase{
|
||||
BaseModel: models.BaseModel{ID: uint(id)},
|
||||
AuthorID: user.ID,
|
||||
},
|
||||
}).First(&item).Error; err != nil {
|
||||
return fiber.NewError(fiber.StatusNotFound, err.Error())
|
||||
}
|
||||
|
||||
item.Alias = data.Alias
|
||||
item.Content = data.Content
|
||||
item.PublishedAt = data.PublishedAt
|
||||
item.Hashtags = data.Hashtags
|
||||
item.Categories = data.Categories
|
||||
|
||||
if item, err := services.EditPost(item); err != nil {
|
||||
return fiber.NewError(fiber.StatusBadRequest, err.Error())
|
||||
} else {
|
||||
return c.JSON(item)
|
||||
}
|
||||
}
|
||||
|
||||
func deleteComment(c *fiber.Ctx) error {
|
||||
user := c.Locals("principal").(models.Account)
|
||||
id, _ := c.ParamsInt("commentId", 0)
|
||||
|
||||
var item *models.Comment
|
||||
if err := database.C.Where(models.Comment{
|
||||
PostBase: models.PostBase{
|
||||
BaseModel: models.BaseModel{ID: uint(id)},
|
||||
AuthorID: user.ID,
|
||||
},
|
||||
}).First(&item).Error; err != nil {
|
||||
return fiber.NewError(fiber.StatusNotFound, err.Error())
|
||||
}
|
||||
|
||||
if err := services.DeletePost(item); err != nil {
|
||||
return fiber.NewError(fiber.StatusBadRequest, err.Error())
|
||||
}
|
||||
|
||||
return c.SendStatus(fiber.StatusOK)
|
||||
}
|
@ -1,93 +0,0 @@
|
||||
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))})
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
191
pkg/server/feed_api.go
Normal file
191
pkg/server/feed_api.go
Normal file
@ -0,0 +1,191 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"code.smartsheep.studio/hydrogen/interactive/pkg/database"
|
||||
"code.smartsheep.studio/hydrogen/interactive/pkg/models"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/samber/lo"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
const (
|
||||
queryArticle = "id, created_at, updated_at, alias, title, NULL as content, description, realm_id, author_id, 'article' as model_type"
|
||||
queryMoment = "id, created_at, updated_at, alias, NULL as title, content, NULL as description, realm_id, author_id, 'moment' as model_type"
|
||||
)
|
||||
|
||||
func listFeed(c *fiber.Ctx) error {
|
||||
take := c.QueryInt("take", 0)
|
||||
offset := c.QueryInt("offset", 0)
|
||||
realmId := c.QueryInt("realmId", 0)
|
||||
|
||||
if take > 20 {
|
||||
take = 20
|
||||
}
|
||||
|
||||
var whereCondition string
|
||||
|
||||
if realmId > 0 {
|
||||
whereCondition += fmt.Sprintf("feed.realm_id = %d", realmId)
|
||||
} else {
|
||||
whereCondition += "feed.realm_id IS NULL"
|
||||
}
|
||||
|
||||
var author models.Account
|
||||
if len(c.Query("authorId")) > 0 {
|
||||
if err := database.C.Where(&models.Account{Name: c.Query("authorId")}).First(&author).Error; err != nil {
|
||||
return fiber.NewError(fiber.StatusNotFound, err.Error())
|
||||
} else {
|
||||
whereCondition += fmt.Sprintf("AND feed.author_id = %d", author.ID)
|
||||
}
|
||||
}
|
||||
|
||||
var result []*models.Feed
|
||||
|
||||
userTable := viper.GetString("database.prefix") + "accounts"
|
||||
commentTable := viper.GetString("database.prefix") + "comments"
|
||||
reactionTable := viper.GetString("database.prefix") + "reactions"
|
||||
|
||||
database.C.Raw(
|
||||
fmt.Sprintf(`SELECT feed.*, author.*,
|
||||
COALESCE(comment_count, 0) AS comment_count,
|
||||
COALESCE(reaction_count, 0) AS reaction_count
|
||||
FROM (? UNION ALL ?) AS feed
|
||||
INNER JOIN %s AS author ON author_id = author.id
|
||||
LEFT JOIN (SELECT article_id, moment_id, COUNT(*) AS comment_count
|
||||
FROM %s
|
||||
GROUP BY article_id, moment_id) AS comments
|
||||
ON (feed.model_type = 'article' AND feed.id = comments.article_id) OR
|
||||
(feed.model_type = 'moment' AND feed.id = comments.moment_id)
|
||||
LEFT JOIN (SELECT article_id, moment_id, COUNT(*) AS reaction_count
|
||||
FROM %s
|
||||
GROUP BY article_id, moment_id) AS reactions
|
||||
ON (feed.model_type = 'article' AND feed.id = reactions.article_id) OR
|
||||
(feed.model_type = 'moment' AND feed.id = reactions.moment_id)
|
||||
WHERE %s ORDER BY feed.created_at desc LIMIT ? OFFSET ?`,
|
||||
userTable,
|
||||
commentTable,
|
||||
reactionTable,
|
||||
whereCondition,
|
||||
),
|
||||
database.C.Select(queryArticle).Model(&models.Article{}),
|
||||
database.C.Select(queryMoment).Model(&models.Moment{}),
|
||||
take,
|
||||
offset,
|
||||
).Scan(&result)
|
||||
|
||||
if !c.QueryBool("noReact", false) {
|
||||
var reactions []struct {
|
||||
PostID uint
|
||||
Symbol string
|
||||
Count int64
|
||||
}
|
||||
|
||||
revertReaction := func(dataset string) error {
|
||||
itemMap := lo.SliceToMap(lo.FilterMap(result, func(item *models.Feed, index int) (*models.Feed, bool) {
|
||||
return item, item.ModelType == dataset
|
||||
}), func(item *models.Feed) (uint, *models.Feed) {
|
||||
return item.ID, item
|
||||
})
|
||||
|
||||
idx := lo.Map(lo.Filter(result, func(item *models.Feed, index int) bool {
|
||||
return item.ModelType == dataset
|
||||
}), func(item *models.Feed, index int) uint {
|
||||
return item.ID
|
||||
})
|
||||
|
||||
if err := database.C.Model(&models.Reaction{}).
|
||||
Select(dataset+"_id as post_id, symbol, COUNT(id) as count").
|
||||
Where(dataset+"_id IN (?)", idx).
|
||||
Group("post_id, symbol").
|
||||
Scan(&reactions).Error; err != nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
list := map[uint]map[string]int64{}
|
||||
for _, info := range reactions {
|
||||
if _, ok := list[info.PostID]; !ok {
|
||||
list[info.PostID] = make(map[string]int64)
|
||||
}
|
||||
list[info.PostID][info.Symbol] = info.Count
|
||||
}
|
||||
|
||||
for k, v := range list {
|
||||
if post, ok := itemMap[k]; ok {
|
||||
post.ReactionList = v
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := revertReaction("article"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := revertReaction("moment"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if !c.QueryBool("noAttachment", false) {
|
||||
revertAttachment := func(dataset string) error {
|
||||
var attachments []struct {
|
||||
models.Attachment
|
||||
|
||||
PostID uint `json:"post_id"`
|
||||
}
|
||||
|
||||
itemMap := lo.SliceToMap(lo.FilterMap(result, func(item *models.Feed, index int) (*models.Feed, bool) {
|
||||
return item, item.ModelType == dataset
|
||||
}), func(item *models.Feed) (uint, *models.Feed) {
|
||||
return item.ID, item
|
||||
})
|
||||
|
||||
idx := lo.Map(lo.Filter(result, func(item *models.Feed, index int) bool {
|
||||
return item.ModelType == dataset
|
||||
}), func(item *models.Feed, index int) uint {
|
||||
return item.ID
|
||||
})
|
||||
|
||||
if err := database.C.
|
||||
Model(&models.Attachment{}).
|
||||
Select(dataset+"_id as post_id, *").
|
||||
Where(dataset+"_id IN (?)", idx).
|
||||
Scan(&attachments).Error; err != nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
list := map[uint][]models.Attachment{}
|
||||
for _, info := range attachments {
|
||||
list[info.PostID] = append(list[info.PostID], info.Attachment)
|
||||
}
|
||||
|
||||
for k, v := range list {
|
||||
if post, ok := itemMap[k]; ok {
|
||||
post.Attachments = v
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := revertAttachment("article"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := revertAttachment("moment"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
var count int64
|
||||
database.C.Raw(`SELECT COUNT(*) FROM (? UNION ALL ?) as feed`,
|
||||
database.C.Select(queryArticle).Model(&models.Article{}),
|
||||
database.C.Select(queryMoment).Model(&models.Moment{}),
|
||||
).Scan(&count)
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"count": count,
|
||||
"data": result,
|
||||
})
|
||||
}
|
146
pkg/server/moments_api.go
Normal file
146
pkg/server/moments_api.go
Normal file
@ -0,0 +1,146 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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/google/uuid"
|
||||
)
|
||||
|
||||
func contextMoment() *services.PostTypeContext {
|
||||
return &services.PostTypeContext{
|
||||
Tx: database.C,
|
||||
TableName: "moments",
|
||||
ColumnName: "moment",
|
||||
CanReply: false,
|
||||
CanRepost: true,
|
||||
}
|
||||
}
|
||||
|
||||
func createMoment(c *fiber.Ctx) error {
|
||||
user := c.Locals("principal").(models.Account)
|
||||
|
||||
var data struct {
|
||||
Alias string `json:"alias" form:"alias"`
|
||||
Content string `json:"content" form:"content" validate:"required,max=1024"`
|
||||
Hashtags []models.Tag `json:"hashtags" form:"hashtags"`
|
||||
Categories []models.Category `json:"categories" form:"categories"`
|
||||
Attachments []models.Attachment `json:"attachments" form:"attachments"`
|
||||
PublishedAt *time.Time `json:"published_at" form:"published_at"`
|
||||
RealmID *uint `json:"realm_id" form:"realm_id"`
|
||||
RepostTo uint `json:"repost_to" form:"repost_to"`
|
||||
}
|
||||
|
||||
if err := BindAndValidate(c, &data); err != nil {
|
||||
return err
|
||||
} else if len(data.Alias) == 0 {
|
||||
data.Alias = strings.ReplaceAll(uuid.NewString(), "-", "")
|
||||
}
|
||||
|
||||
item := &models.Moment{
|
||||
PostBase: models.PostBase{
|
||||
Alias: data.Alias,
|
||||
PublishedAt: data.PublishedAt,
|
||||
AuthorID: user.ID,
|
||||
},
|
||||
Hashtags: data.Hashtags,
|
||||
Categories: data.Categories,
|
||||
Attachments: data.Attachments,
|
||||
Content: data.Content,
|
||||
RealmID: data.RealmID,
|
||||
}
|
||||
|
||||
var relatedCount int64
|
||||
if data.RepostTo > 0 {
|
||||
if err := database.C.Where("id = ?", data.RepostTo).
|
||||
Model(&models.Moment{}).Count(&relatedCount).Error; err != nil {
|
||||
return fiber.NewError(fiber.StatusBadRequest, err.Error())
|
||||
} else if relatedCount <= 0 {
|
||||
return fiber.NewError(fiber.StatusNotFound, "related post was not found")
|
||||
} else {
|
||||
item.RepostID = &data.RepostTo
|
||||
}
|
||||
}
|
||||
|
||||
var realm *models.Realm
|
||||
if data.RealmID != nil {
|
||||
if err := database.C.Where(&models.Realm{
|
||||
BaseModel: models.BaseModel{ID: *data.RealmID},
|
||||
}).First(&realm).Error; err != nil {
|
||||
return fiber.NewError(fiber.StatusBadRequest, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
item, err := services.NewPost(item)
|
||||
if err != nil {
|
||||
return fiber.NewError(fiber.StatusBadRequest, err.Error())
|
||||
}
|
||||
|
||||
return c.JSON(item)
|
||||
}
|
||||
|
||||
func editMoment(c *fiber.Ctx) error {
|
||||
user := c.Locals("principal").(models.Account)
|
||||
id, _ := c.ParamsInt("momentId", 0)
|
||||
|
||||
var data struct {
|
||||
Alias string `json:"alias" form:"alias" validate:"required"`
|
||||
Content string `json:"content" form:"content" validate:"required,max=1024"`
|
||||
PublishedAt *time.Time `json:"published_at" form:"published_at"`
|
||||
Hashtags []models.Tag `json:"hashtags" form:"hashtags"`
|
||||
Categories []models.Category `json:"categories" form:"categories"`
|
||||
Attachments []models.Attachment `json:"attachments" form:"attachments"`
|
||||
}
|
||||
|
||||
if err := BindAndValidate(c, &data); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var item *models.Moment
|
||||
if err := database.C.Where(models.Comment{
|
||||
PostBase: models.PostBase{
|
||||
BaseModel: models.BaseModel{ID: uint(id)},
|
||||
AuthorID: user.ID,
|
||||
},
|
||||
}).First(&item).Error; err != nil {
|
||||
return fiber.NewError(fiber.StatusNotFound, err.Error())
|
||||
}
|
||||
|
||||
item.Alias = data.Alias
|
||||
item.Content = data.Content
|
||||
item.PublishedAt = data.PublishedAt
|
||||
item.Hashtags = data.Hashtags
|
||||
item.Categories = data.Categories
|
||||
item.Attachments = data.Attachments
|
||||
|
||||
if item, err := services.EditPost(item); err != nil {
|
||||
return fiber.NewError(fiber.StatusBadRequest, err.Error())
|
||||
} else {
|
||||
return c.JSON(item)
|
||||
}
|
||||
}
|
||||
|
||||
func deleteMoment(c *fiber.Ctx) error {
|
||||
user := c.Locals("principal").(models.Account)
|
||||
id, _ := c.ParamsInt("momentId", 0)
|
||||
|
||||
var item *models.Moment
|
||||
if err := database.C.Where(models.Comment{
|
||||
PostBase: models.PostBase{
|
||||
BaseModel: models.BaseModel{ID: uint(id)},
|
||||
AuthorID: user.ID,
|
||||
},
|
||||
}).First(&item).Error; err != nil {
|
||||
return fiber.NewError(fiber.StatusNotFound, err.Error())
|
||||
}
|
||||
|
||||
if err := services.DeletePost(item); err != nil {
|
||||
return fiber.NewError(fiber.StatusBadRequest, err.Error())
|
||||
}
|
||||
|
||||
return c.SendStatus(fiber.StatusOK)
|
||||
}
|
@ -1,271 +1,151 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"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/google/uuid"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
var postContextKey = "ptx"
|
||||
|
||||
func useDynamicContext(c *fiber.Ctx) error {
|
||||
postType := c.Params("postType")
|
||||
switch postType {
|
||||
case "articles":
|
||||
c.Locals(postContextKey, contextArticle())
|
||||
case "moments":
|
||||
c.Locals(postContextKey, contextMoment())
|
||||
case "comments":
|
||||
c.Locals(postContextKey, contextComment())
|
||||
default:
|
||||
return fiber.NewError(fiber.StatusBadRequest, "invalid dataset")
|
||||
}
|
||||
|
||||
return c.Next()
|
||||
}
|
||||
|
||||
func getPost(c *fiber.Ctx) error {
|
||||
id := c.Params("postId")
|
||||
take := c.QueryInt("take", 0)
|
||||
offset := c.QueryInt("offset", 0)
|
||||
alias := c.Params("postId")
|
||||
|
||||
tx := database.C.Where(&models.Post{
|
||||
Alias: id,
|
||||
}).Where("published_at <= ? OR published_at IS NULL", time.Now())
|
||||
mx := c.Locals(postContextKey).(*services.PostTypeContext).
|
||||
FilterPublishedAt(time.Now())
|
||||
|
||||
post, err := services.GetPost(tx)
|
||||
item, err := mx.GetViaAlias(alias)
|
||||
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 {
|
||||
item.ReactionList, err = mx.CountReactions(item.ID)
|
||||
if 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,
|
||||
})
|
||||
return c.JSON(item)
|
||||
}
|
||||
|
||||
func listPost(c *fiber.Ctx) error {
|
||||
take := c.QueryInt("take", 0)
|
||||
offset := c.QueryInt("offset", 0)
|
||||
|
||||
realmId := c.QueryInt("realmId", 0)
|
||||
|
||||
tx := database.C.
|
||||
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")
|
||||
}
|
||||
mx := c.Locals(postContextKey).(*services.PostTypeContext).
|
||||
FilterPublishedAt(time.Now()).
|
||||
FilterRealm(uint(realmId)).
|
||||
SortCreatedAt("desc")
|
||||
|
||||
var author models.Account
|
||||
if len(c.Query("authorId")) > 0 {
|
||||
if err := database.C.Where(&models.Account{Name: c.Query("authorId")}).First(&author).Error; err != nil {
|
||||
return fiber.NewError(fiber.StatusNotFound, err.Error())
|
||||
}
|
||||
tx = tx.Where(&models.Post{AuthorID: author.ID})
|
||||
mx = mx.FilterAuthor(author.ID)
|
||||
}
|
||||
|
||||
if len(c.Query("category")) > 0 {
|
||||
tx = services.FilterPostWithCategory(tx, c.Query("category"))
|
||||
mx = mx.FilterWithCategory(c.Query("category"))
|
||||
}
|
||||
if len(c.Query("tag")) > 0 {
|
||||
tx = services.FilterPostWithTag(tx, c.Query("tag"))
|
||||
mx = mx.FilterWithTag(c.Query("tag"))
|
||||
}
|
||||
|
||||
if !c.QueryBool("reply", true) {
|
||||
tx = tx.Where("reply_id IS NULL")
|
||||
mx = mx.FilterReply(true)
|
||||
}
|
||||
|
||||
var count int64
|
||||
if err := tx.
|
||||
Model(&models.Post{}).
|
||||
Count(&count).Error; err != nil {
|
||||
count, err := mx.Count()
|
||||
if err != nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
posts, err := services.ListPost(tx, take, offset)
|
||||
items, err := mx.List(take, offset)
|
||||
if err != nil {
|
||||
return fiber.NewError(fiber.StatusBadRequest, err.Error())
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"count": count,
|
||||
"data": posts,
|
||||
"data": items,
|
||||
})
|
||||
}
|
||||
|
||||
func createPost(c *fiber.Ctx) error {
|
||||
user := c.Locals("principal").(models.Account)
|
||||
|
||||
var data struct {
|
||||
Alias string `json:"alias"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content" validate:"required"`
|
||||
Tags []models.Tag `json:"tags"`
|
||||
Categories []models.Category `json:"categories"`
|
||||
Attachments []models.Attachment `json:"attachments"`
|
||||
PublishedAt *time.Time `json:"published_at"`
|
||||
RealmID *uint `json:"realm_id"`
|
||||
RepostTo uint `json:"repost_to"`
|
||||
ReplyTo uint `json:"reply_to"`
|
||||
}
|
||||
|
||||
if err := BindAndValidate(c, &data); err != nil {
|
||||
return err
|
||||
} else if len(data.Alias) == 0 {
|
||||
data.Alias = strings.ReplaceAll(uuid.NewString(), "-", "")
|
||||
}
|
||||
|
||||
var repostTo *uint = nil
|
||||
var replyTo *uint = nil
|
||||
var relatedCount int64
|
||||
if data.RepostTo > 0 {
|
||||
if err := database.C.Where(&models.Post{
|
||||
BaseModel: models.BaseModel{ID: data.RepostTo},
|
||||
}).Model(&models.Post{}).Count(&relatedCount).Error; err != nil {
|
||||
return fiber.NewError(fiber.StatusBadRequest, err.Error())
|
||||
} else if relatedCount <= 0 {
|
||||
return fiber.NewError(fiber.StatusNotFound, "related post was not found")
|
||||
} else {
|
||||
repostTo = &data.RepostTo
|
||||
}
|
||||
} else if data.ReplyTo > 0 {
|
||||
if err := database.C.Where(&models.Post{
|
||||
BaseModel: models.BaseModel{ID: data.ReplyTo},
|
||||
}).Model(&models.Post{}).Count(&relatedCount).Error; err != nil {
|
||||
return fiber.NewError(fiber.StatusBadRequest, err.Error())
|
||||
} else if relatedCount <= 0 {
|
||||
return fiber.NewError(fiber.StatusNotFound, "related post was not found")
|
||||
} else {
|
||||
replyTo = &data.ReplyTo
|
||||
}
|
||||
}
|
||||
|
||||
var realm *models.Realm
|
||||
if data.RealmID != nil {
|
||||
if err := database.C.Where(&models.Realm{
|
||||
BaseModel: models.BaseModel{ID: *data.RealmID},
|
||||
}).First(&realm).Error; err != nil {
|
||||
return fiber.NewError(fiber.StatusBadRequest, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
post, err := services.NewPost(
|
||||
user,
|
||||
realm,
|
||||
data.Alias,
|
||||
data.Title,
|
||||
data.Content,
|
||||
data.Attachments,
|
||||
data.Categories,
|
||||
data.Tags,
|
||||
data.PublishedAt,
|
||||
replyTo,
|
||||
repostTo,
|
||||
)
|
||||
if err != nil {
|
||||
return fiber.NewError(fiber.StatusBadRequest, err.Error())
|
||||
}
|
||||
|
||||
return c.JSON(post)
|
||||
}
|
||||
|
||||
func editPost(c *fiber.Ctx) error {
|
||||
user := c.Locals("principal").(models.Account)
|
||||
id, _ := c.ParamsInt("postId", 0)
|
||||
|
||||
var data struct {
|
||||
Alias string `json:"alias" validate:"required"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content" validate:"required"`
|
||||
PublishedAt *time.Time `json:"published_at"`
|
||||
Tags []models.Tag `json:"tags"`
|
||||
Categories []models.Category `json:"categories"`
|
||||
Attachments []models.Attachment `json:"attachments"`
|
||||
}
|
||||
|
||||
if err := BindAndValidate(c, &data); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var post models.Post
|
||||
if err := database.C.Where(&models.Post{
|
||||
BaseModel: models.BaseModel{ID: uint(id)},
|
||||
AuthorID: user.ID,
|
||||
}).First(&post).Error; err != nil {
|
||||
return fiber.NewError(fiber.StatusNotFound, err.Error())
|
||||
}
|
||||
|
||||
post, err := services.EditPost(
|
||||
post,
|
||||
data.Alias,
|
||||
data.Title,
|
||||
data.Content,
|
||||
data.PublishedAt,
|
||||
data.Categories,
|
||||
data.Tags,
|
||||
data.Attachments,
|
||||
)
|
||||
if err != nil {
|
||||
return fiber.NewError(fiber.StatusBadRequest, err.Error())
|
||||
}
|
||||
|
||||
return c.JSON(post)
|
||||
}
|
||||
|
||||
func reactPost(c *fiber.Ctx) error {
|
||||
user := c.Locals("principal").(models.Account)
|
||||
id, _ := c.ParamsInt("postId", 0)
|
||||
|
||||
var post models.Post
|
||||
if err := database.C.Where(&models.Post{
|
||||
BaseModel: models.BaseModel{ID: uint(id)},
|
||||
}).First(&post).Error; err != nil {
|
||||
return fiber.NewError(fiber.StatusNotFound, err.Error())
|
||||
var data struct {
|
||||
Symbol string `json:"symbol" form:"symbol" validate:"required"`
|
||||
Attitude models.ReactionAttitude `json:"attitude" form:"attitude" validate:"required"`
|
||||
}
|
||||
|
||||
switch strings.ToLower(c.Params("reactType")) {
|
||||
case "like":
|
||||
if positive, err := services.LikePost(user, post); err != nil {
|
||||
return fiber.NewError(fiber.StatusBadRequest, err.Error())
|
||||
} else {
|
||||
return c.SendStatus(lo.Ternary(positive, fiber.StatusCreated, fiber.StatusNoContent))
|
||||
if err := BindAndValidate(c, &data); err != nil {
|
||||
return err
|
||||
}
|
||||
case "dislike":
|
||||
if positive, err := services.DislikePost(user, post); err != nil {
|
||||
return fiber.NewError(fiber.StatusBadRequest, err.Error())
|
||||
} else {
|
||||
return c.SendStatus(lo.Ternary(positive, fiber.StatusCreated, fiber.StatusNoContent))
|
||||
|
||||
mx := c.Locals(postContextKey).(*services.PostTypeContext)
|
||||
|
||||
reaction := models.Reaction{
|
||||
Symbol: data.Symbol,
|
||||
Attitude: data.Attitude,
|
||||
AccountID: user.ID,
|
||||
}
|
||||
|
||||
postType := c.Params("postType")
|
||||
alias := c.Params("postId")
|
||||
|
||||
var err error
|
||||
var res models.Feed
|
||||
|
||||
switch postType {
|
||||
case "moments":
|
||||
err = database.C.Model(&models.Moment{}).Where("id = ?", alias).Select("id").First(&res).Error
|
||||
case "articles":
|
||||
err = database.C.Model(&models.Article{}).Where("id = ?", alias).Select("id").First(&res).Error
|
||||
case "comments":
|
||||
err = database.C.Model(&models.Comment{}).Where("id = ?", alias).Select("id").First(&res).Error
|
||||
default:
|
||||
return fiber.NewError(fiber.StatusBadRequest, "unsupported reaction")
|
||||
return fiber.NewError(fiber.StatusBadRequest, "comment must belongs to a resource")
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("belongs to resource was not found: %v", err))
|
||||
} else {
|
||||
switch postType {
|
||||
case "moments":
|
||||
reaction.MomentID = &res.ID
|
||||
case "articles":
|
||||
reaction.ArticleID = &res.ID
|
||||
case "comments":
|
||||
reaction.CommentID = &res.ID
|
||||
}
|
||||
}
|
||||
|
||||
func deletePost(c *fiber.Ctx) error {
|
||||
user := c.Locals("principal").(models.Account)
|
||||
id, _ := c.ParamsInt("postId", 0)
|
||||
|
||||
var post models.Post
|
||||
if err := database.C.Where(&models.Post{
|
||||
BaseModel: models.BaseModel{ID: uint(id)},
|
||||
AuthorID: user.ID,
|
||||
}).First(&post).Error; err != nil {
|
||||
return fiber.NewError(fiber.StatusNotFound, err.Error())
|
||||
}
|
||||
|
||||
if err := services.DeletePost(post); err != nil {
|
||||
if positive, reaction, err := mx.React(reaction); err != nil {
|
||||
return fiber.NewError(fiber.StatusBadRequest, err.Error())
|
||||
} else {
|
||||
return c.Status(lo.Ternary(positive, fiber.StatusCreated, fiber.StatusNoContent)).JSON(reaction)
|
||||
}
|
||||
|
||||
return c.SendStatus(fiber.StatusOK)
|
||||
}
|
||||
|
@ -5,7 +5,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"code.smartsheep.studio/hydrogen/interactive/pkg/view"
|
||||
"code.smartsheep.studio/hydrogen/interactive/pkg/views"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/gofiber/fiber/v2/middleware/cache"
|
||||
"github.com/gofiber/fiber/v2/middleware/cors"
|
||||
@ -69,21 +69,42 @@ func NewServer() {
|
||||
}), openAttachment)
|
||||
api.Post("/attachments", authMiddleware, uploadAttachment)
|
||||
|
||||
api.Get("/posts", listPost)
|
||||
api.Get("/posts/:postId", getPost)
|
||||
api.Post("/posts", authMiddleware, createPost)
|
||||
api.Post("/posts/:postId/react/:reactType", authMiddleware, reactPost)
|
||||
api.Put("/posts/:postId", authMiddleware, editPost)
|
||||
api.Delete("/posts/:postId", authMiddleware, deletePost)
|
||||
api.Get("/feed", listFeed)
|
||||
|
||||
api.Get("/categories", listCategroies)
|
||||
posts := api.Group("/p/:postType").Use(useDynamicContext).Name("Dataset Universal API")
|
||||
{
|
||||
posts.Get("/", listPost)
|
||||
posts.Get("/:postId", getPost)
|
||||
posts.Post("/:postId/react", authMiddleware, reactPost)
|
||||
posts.Get("/:postId/comments", listComment)
|
||||
posts.Post("/:postId/comments", authMiddleware, createComment)
|
||||
}
|
||||
|
||||
moments := api.Group("/p/moments").Name("Moments API")
|
||||
{
|
||||
moments.Post("/", authMiddleware, createMoment)
|
||||
moments.Put("/:momentId", authMiddleware, editMoment)
|
||||
moments.Delete("/:momentId", authMiddleware, deleteMoment)
|
||||
}
|
||||
|
||||
articles := api.Group("/p/articles").Name("Articles API")
|
||||
{
|
||||
articles.Post("/", authMiddleware, createArticle)
|
||||
articles.Put("/:articleId", authMiddleware, editArticle)
|
||||
articles.Delete("/:articleId", authMiddleware, deleteArticle)
|
||||
}
|
||||
|
||||
comments := api.Group("/p/comments").Name("Comments API")
|
||||
{
|
||||
comments.Put("/:commentId", authMiddleware, editComment)
|
||||
comments.Delete("/:commentId", authMiddleware, deleteComment)
|
||||
}
|
||||
|
||||
api.Get("/categories", listCategories)
|
||||
api.Post("/categories", authMiddleware, newCategory)
|
||||
api.Put("/categories/:categoryId", authMiddleware, editCategory)
|
||||
api.Delete("/categories/:categoryId", authMiddleware, deleteCategory)
|
||||
|
||||
api.Get("/creators/posts", authMiddleware, listOwnPost)
|
||||
api.Get("/creators/posts/:postId", authMiddleware, getOwnPost)
|
||||
|
||||
api.Get("/realms", listRealm)
|
||||
api.Get("/realms/me", authMiddleware, listOwnedRealm)
|
||||
api.Get("/realms/me/available", authMiddleware, listAvailableRealm)
|
||||
@ -99,7 +120,7 @@ func NewServer() {
|
||||
Expiration: 24 * time.Hour,
|
||||
CacheControl: true,
|
||||
}), filesystem.New(filesystem.Config{
|
||||
Root: http.FS(view.FS),
|
||||
Root: http.FS(views.FS),
|
||||
PathPrefix: "dist",
|
||||
Index: "index.html",
|
||||
NotFoundFile: "dist/index.html",
|
||||
|
@ -1,11 +1,13 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"code.smartsheep.studio/hydrogen/interactive/pkg/database"
|
||||
"code.smartsheep.studio/hydrogen/interactive/pkg/models"
|
||||
"github.com/google/uuid"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func NewAttachment(user models.Account, header *multipart.FileHeader) (models.Attachment, error) {
|
||||
@ -14,7 +16,7 @@ func NewAttachment(user models.Account, header *multipart.FileHeader) (models.At
|
||||
Filesize: header.Size,
|
||||
Filename: header.Filename,
|
||||
Mimetype: "unknown/unknown",
|
||||
PostID: nil,
|
||||
Type: models.AttachmentOthers,
|
||||
AuthorID: user.ID,
|
||||
}
|
||||
|
||||
@ -33,6 +35,17 @@ func NewAttachment(user models.Account, header *multipart.FileHeader) (models.At
|
||||
}
|
||||
attachment.Mimetype = http.DetectContentType(fileHeader)
|
||||
|
||||
switch strings.Split(attachment.Mimetype, "/")[0] {
|
||||
case "image":
|
||||
attachment.Type = models.AttachmentPhoto
|
||||
case "video":
|
||||
attachment.Type = models.AttachmentVideo
|
||||
case "audio":
|
||||
attachment.Type = models.AttachmentAudio
|
||||
default:
|
||||
attachment.Type = models.AttachmentOthers
|
||||
}
|
||||
|
||||
// Save into database
|
||||
err = database.C.Save(&attachment).Error
|
||||
|
||||
|
@ -7,12 +7,16 @@ import (
|
||||
"code.smartsheep.studio/hydrogen/interactive/pkg/models"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"gorm.io/gorm"
|
||||
"time"
|
||||
)
|
||||
|
||||
func LinkAccount(userinfo *proto.Userinfo) (models.Account, error) {
|
||||
var account models.Account
|
||||
if userinfo == nil {
|
||||
return account, fmt.Errorf("remote userinfo was not found")
|
||||
}
|
||||
if err := database.C.Where(&models.Account{
|
||||
ExternalID: uint(userinfo.Id),
|
||||
}).First(&account).Error; err != nil {
|
||||
|
82
pkg/services/comments.go
Normal file
82
pkg/services/comments.go
Normal file
@ -0,0 +1,82 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"code.smartsheep.studio/hydrogen/interactive/pkg/database"
|
||||
"code.smartsheep.studio/hydrogen/interactive/pkg/models"
|
||||
"github.com/samber/lo"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
func (v *PostTypeContext) ListComment(id uint, take int, offset int, noReact ...bool) ([]*models.Feed, error) {
|
||||
if take > 20 {
|
||||
take = 20
|
||||
}
|
||||
|
||||
var items []*models.Feed
|
||||
table := viper.GetString("database.prefix") + "comments"
|
||||
userTable := viper.GetString("database.prefix") + "accounts"
|
||||
if err := v.Tx.
|
||||
Table(table).
|
||||
Select("*, ? as model_type", "comment").
|
||||
Where(v.ColumnName+"_id = ?", id).
|
||||
Joins(fmt.Sprintf("INNER JOIN %s as author ON author_id = author.id", userTable)).
|
||||
Limit(take).Offset(offset).Find(&items).Error; err != nil {
|
||||
return items, err
|
||||
}
|
||||
|
||||
idx := lo.Map(items, func(item *models.Feed, index int) uint {
|
||||
return item.ID
|
||||
})
|
||||
|
||||
if len(noReact) <= 0 || !noReact[0] {
|
||||
var reactions []struct {
|
||||
PostID uint
|
||||
Symbol string
|
||||
Count int64
|
||||
}
|
||||
|
||||
if err := database.C.Model(&models.Reaction{}).
|
||||
Select("comment_id as post_id, symbol, COUNT(id) as count").
|
||||
Where("comment_id IN (?)", idx).
|
||||
Group("post_id, symbol").
|
||||
Scan(&reactions).Error; err != nil {
|
||||
return items, err
|
||||
}
|
||||
|
||||
itemMap := lo.SliceToMap(items, func(item *models.Feed) (uint, *models.Feed) {
|
||||
return item.ID, item
|
||||
})
|
||||
|
||||
list := map[uint]map[string]int64{}
|
||||
for _, info := range reactions {
|
||||
if _, ok := list[info.PostID]; !ok {
|
||||
list[info.PostID] = make(map[string]int64)
|
||||
}
|
||||
list[info.PostID][info.Symbol] = info.Count
|
||||
}
|
||||
|
||||
for k, v := range list {
|
||||
if post, ok := itemMap[k]; ok {
|
||||
post.ReactionList = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (v *PostTypeContext) CountComment(id uint) (int64, error) {
|
||||
var count int64
|
||||
if err := database.C.
|
||||
Model(&models.Comment{}).
|
||||
Where(v.ColumnName+"_id = ?", id).
|
||||
Where("published_at <= ?", time.Now()).
|
||||
Count(&count).Error; err != nil {
|
||||
return count, err
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
1
pkg/services/moments.go
Normal file
1
pkg/services/moments.go
Normal file
@ -0,0 +1 @@
|
||||
package services
|
@ -1,235 +1,304 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"code.smartsheep.studio/hydrogen/identity/pkg/grpc/proto"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
"code.smartsheep.studio/hydrogen/identity/pkg/grpc/proto"
|
||||
"code.smartsheep.studio/hydrogen/interactive/pkg/database"
|
||||
"code.smartsheep.studio/hydrogen/interactive/pkg/models"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/samber/lo"
|
||||
"github.com/spf13/viper"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func PreloadRelatedPost(tx *gorm.DB) *gorm.DB {
|
||||
return tx.
|
||||
Preload("Author").
|
||||
Preload("Attachments").
|
||||
Preload("Categories").
|
||||
Preload("Tags").
|
||||
Preload("RepostTo").
|
||||
Preload("ReplyTo").
|
||||
Preload("RepostTo.Author").
|
||||
Preload("ReplyTo.Author").
|
||||
Preload("RepostTo.Attachments").
|
||||
Preload("ReplyTo.Attachments").
|
||||
Preload("RepostTo.Categories").
|
||||
Preload("ReplyTo.Categories").
|
||||
Preload("RepostTo.Tags").
|
||||
Preload("ReplyTo.Tags")
|
||||
type PostTypeContext struct {
|
||||
Tx *gorm.DB
|
||||
|
||||
TableName string
|
||||
ColumnName string
|
||||
CanReply bool
|
||||
CanRepost bool
|
||||
}
|
||||
|
||||
func FilterPostWithCategory(tx *gorm.DB, alias string) *gorm.DB {
|
||||
prefix := viper.GetString("database.prefix")
|
||||
return tx.Joins(fmt.Sprintf("JOIN %spost_categories ON %sposts.id = %spost_categories.post_id", prefix, prefix, prefix)).
|
||||
Joins(fmt.Sprintf("JOIN %scategories ON %scategories.id = %spost_categories.category_id", prefix, prefix, prefix)).
|
||||
Where(fmt.Sprintf("%scategories.alias = ?", prefix), alias)
|
||||
func (v *PostTypeContext) FilterWithCategory(alias string) *PostTypeContext {
|
||||
name := v.ColumnName
|
||||
v.Tx.Joins(fmt.Sprintf("JOIN %s_categories ON %s.id = %s_categories.%s_id", name, v.TableName, name, name)).
|
||||
Joins(fmt.Sprintf("JOIN %s_categories ON %s_categories.id = %s_categories.category_id", name, name, name)).
|
||||
Where(name+"_categories.alias = ?", alias)
|
||||
return v
|
||||
}
|
||||
|
||||
func FilterPostWithTag(tx *gorm.DB, alias string) *gorm.DB {
|
||||
prefix := viper.GetString("database.prefix")
|
||||
return tx.Joins(fmt.Sprintf("JOIN %spost_tags ON %sposts.id = %spost_tags.post_id", prefix, prefix, prefix)).
|
||||
Joins(fmt.Sprintf("JOIN %stags ON %stags.id = %spost_tags.tag_id", prefix, prefix, prefix)).
|
||||
Where(fmt.Sprintf("%stags.alias = ?", prefix), alias)
|
||||
func (v *PostTypeContext) FilterWithTag(alias string) *PostTypeContext {
|
||||
name := v.ColumnName
|
||||
v.Tx.Joins(fmt.Sprintf("JOIN %s_tags ON %s.id = %s_tags.%s_id", name, v.TableName, name, name)).
|
||||
Joins(fmt.Sprintf("JOIN %s_tags ON %s_tags.id = %s_tags.category_id", name, name, name)).
|
||||
Where(name+"_tags.alias = ?", alias)
|
||||
return v
|
||||
}
|
||||
|
||||
func GetPost(tx *gorm.DB) (*models.Post, error) {
|
||||
var post *models.Post
|
||||
if err := PreloadRelatedPost(tx).First(&post).Error; err != nil {
|
||||
return post, err
|
||||
func (v *PostTypeContext) FilterPublishedAt(date time.Time) *PostTypeContext {
|
||||
v.Tx.Where("published_at <= ? AND published_at IS NULL", date)
|
||||
return v
|
||||
}
|
||||
|
||||
var reactInfo struct {
|
||||
PostID uint `json:"post_id"`
|
||||
LikeCount int64 `json:"like_count"`
|
||||
DislikeCount int64 `json:"dislike_count"`
|
||||
ReplyCount int64 `json:"reply_count"`
|
||||
RepostCount int64 `json:"repost_count"`
|
||||
func (v *PostTypeContext) FilterRealm(id uint) *PostTypeContext {
|
||||
if id > 0 {
|
||||
v.Tx = v.Tx.Where("realm_id = ?", id)
|
||||
} else {
|
||||
v.Tx = v.Tx.Where("realm_id IS NULL")
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
prefix := viper.GetString("database.prefix")
|
||||
database.C.Raw(fmt.Sprintf(`
|
||||
SELECT t.id as post_id,
|
||||
COALESCE(l.like_count, 0) AS like_count,
|
||||
COALESCE(d.dislike_count, 0) AS dislike_count,
|
||||
COALESCE(r.reply_count, 0) AS reply_count,
|
||||
COALESCE(rp.repost_count, 0) AS repost_count
|
||||
FROM %sposts t
|
||||
LEFT JOIN (SELECT post_id, COUNT(*) AS like_count
|
||||
FROM %spost_likes
|
||||
GROUP BY post_id) l ON t.id = l.post_id
|
||||
LEFT JOIN (SELECT post_id, COUNT(*) AS dislike_count
|
||||
FROM %spost_dislikes
|
||||
GROUP BY post_id) d ON t.id = d.post_id
|
||||
LEFT JOIN (SELECT reply_id, COUNT(*) AS reply_count
|
||||
FROM %sposts
|
||||
WHERE reply_id IS NOT NULL
|
||||
GROUP BY reply_id) r ON t.id = r.reply_id
|
||||
LEFT JOIN (SELECT repost_id, COUNT(*) AS repost_count
|
||||
FROM %sposts
|
||||
WHERE repost_id IS NOT NULL
|
||||
GROUP BY repost_id) rp ON t.id = rp.repost_id
|
||||
WHERE t.id = ?`, prefix, prefix, prefix, prefix, prefix), post.ID).Scan(&reactInfo)
|
||||
|
||||
post.LikeCount = reactInfo.LikeCount
|
||||
post.DislikeCount = reactInfo.DislikeCount
|
||||
post.ReplyCount = reactInfo.ReplyCount
|
||||
post.RepostCount = reactInfo.RepostCount
|
||||
|
||||
return post, nil
|
||||
func (v *PostTypeContext) FilterAuthor(id uint) *PostTypeContext {
|
||||
v.Tx = v.Tx.Where("author_id = ?", id)
|
||||
return v
|
||||
}
|
||||
|
||||
func ListPost(tx *gorm.DB, take int, offset int) ([]*models.Post, error) {
|
||||
func (v *PostTypeContext) FilterReply(condition bool) *PostTypeContext {
|
||||
if condition {
|
||||
v.Tx = v.Tx.Where("reply_id IS NOT NULL")
|
||||
} else {
|
||||
v.Tx = v.Tx.Where("reply_id IS NULL")
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func (v *PostTypeContext) SortCreatedAt(order string) *PostTypeContext {
|
||||
v.Tx.Order(fmt.Sprintf("created_at %s", order))
|
||||
return v
|
||||
}
|
||||
|
||||
func (v *PostTypeContext) GetViaAlias(alias string) (models.Feed, error) {
|
||||
var item models.Feed
|
||||
table := viper.GetString("database.prefix") + v.TableName
|
||||
userTable := viper.GetString("database.prefix") + "accounts"
|
||||
if err := v.Tx.
|
||||
Table(table).
|
||||
Select("*, ? as model_type", v.ColumnName).
|
||||
Joins(fmt.Sprintf("INNER JOIN %s AS author ON author_id = author.id", userTable)).
|
||||
Where("alias = ?", alias).
|
||||
First(&item).Error; err != nil {
|
||||
return item, err
|
||||
}
|
||||
|
||||
var attachments []models.Attachment
|
||||
if err := database.C.
|
||||
Model(&models.Attachment{}).
|
||||
Where(v.ColumnName+"_id = ?", item.ID).
|
||||
Scan(&attachments).Error; err != nil {
|
||||
return item, err
|
||||
} else {
|
||||
item.Attachments = attachments
|
||||
}
|
||||
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (v *PostTypeContext) Get(id uint, noComments ...bool) (models.Feed, error) {
|
||||
var item models.Feed
|
||||
table := viper.GetString("database.prefix") + v.TableName
|
||||
userTable := viper.GetString("database.prefix") + "accounts"
|
||||
if err := v.Tx.
|
||||
Table(table).
|
||||
Select("*, ? as model_type", v.ColumnName).
|
||||
Joins(fmt.Sprintf("INNER JOIN %s AS author ON author_id = author.id", userTable)).
|
||||
Where("id = ?", id).First(&item).Error; err != nil {
|
||||
return item, err
|
||||
}
|
||||
|
||||
var attachments []models.Attachment
|
||||
if err := database.C.
|
||||
Model(&models.Attachment{}).
|
||||
Where(v.ColumnName+"_id = ?", id).
|
||||
Scan(&attachments).Error; err != nil {
|
||||
return item, err
|
||||
} else {
|
||||
item.Attachments = attachments
|
||||
}
|
||||
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (v *PostTypeContext) Count() (int64, error) {
|
||||
var count int64
|
||||
table := viper.GetString("database.prefix") + v.TableName
|
||||
if err := v.Tx.Table(table).Count(&count).Error; err != nil {
|
||||
return count, err
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (v *PostTypeContext) CountReactions(id uint) (map[string]int64, error) {
|
||||
var reactions []struct {
|
||||
Symbol string
|
||||
Count int64
|
||||
}
|
||||
|
||||
if err := database.C.Model(&models.Reaction{}).
|
||||
Select("symbol, COUNT(id) as count").
|
||||
Where(v.ColumnName+"_id = ?", id).
|
||||
Group("symbol").
|
||||
Scan(&reactions).Error; err != nil {
|
||||
return map[string]int64{}, err
|
||||
}
|
||||
|
||||
return lo.SliceToMap(reactions, func(item struct {
|
||||
Symbol string
|
||||
Count int64
|
||||
},
|
||||
) (string, int64) {
|
||||
return item.Symbol, item.Count
|
||||
}), nil
|
||||
}
|
||||
|
||||
func (v *PostTypeContext) List(take int, offset int, noReact ...bool) ([]*models.Feed, error) {
|
||||
if take > 20 {
|
||||
take = 20
|
||||
}
|
||||
|
||||
var posts []*models.Post
|
||||
if err := PreloadRelatedPost(tx).
|
||||
Limit(take).
|
||||
Offset(offset).
|
||||
Find(&posts).Error; err != nil {
|
||||
return posts, err
|
||||
var items []*models.Feed
|
||||
table := viper.GetString("database.prefix") + v.TableName
|
||||
if err := v.Tx.
|
||||
Table(table).
|
||||
Select("*, ? as model_type", v.ColumnName).
|
||||
Limit(take).Offset(offset).Find(&items).Error; err != nil {
|
||||
return items, err
|
||||
}
|
||||
|
||||
postIds := lo.Map(posts, func(item *models.Post, _ int) uint {
|
||||
idx := lo.Map(items, func(item *models.Feed, index int) uint {
|
||||
return item.ID
|
||||
})
|
||||
|
||||
var reactInfo []struct {
|
||||
PostID uint `json:"post_id"`
|
||||
LikeCount int64 `json:"like_count"`
|
||||
DislikeCount int64 `json:"dislike_count"`
|
||||
ReplyCount int64 `json:"reply_count"`
|
||||
RepostCount int64 `json:"repost_count"`
|
||||
if len(noReact) <= 0 || !noReact[0] {
|
||||
var reactions []struct {
|
||||
PostID uint
|
||||
Symbol string
|
||||
Count int64
|
||||
}
|
||||
|
||||
prefix := viper.GetString("database.prefix")
|
||||
database.C.Raw(fmt.Sprintf(`
|
||||
SELECT t.id as post_id,
|
||||
COALESCE(l.like_count, 0) AS like_count,
|
||||
COALESCE(d.dislike_count, 0) AS dislike_count,
|
||||
COALESCE(r.reply_count, 0) AS reply_count,
|
||||
COALESCE(rp.repost_count, 0) AS repost_count
|
||||
FROM %sposts t
|
||||
LEFT JOIN (SELECT post_id, COUNT(*) AS like_count
|
||||
FROM %spost_likes
|
||||
GROUP BY post_id) l ON t.id = l.post_id
|
||||
LEFT JOIN (SELECT post_id, COUNT(*) AS dislike_count
|
||||
FROM %spost_dislikes
|
||||
GROUP BY post_id) d ON t.id = d.post_id
|
||||
LEFT JOIN (SELECT reply_id, COUNT(*) AS reply_count
|
||||
FROM %sposts
|
||||
WHERE reply_id IS NOT NULL
|
||||
GROUP BY reply_id) r ON t.id = r.reply_id
|
||||
LEFT JOIN (SELECT repost_id, COUNT(*) AS repost_count
|
||||
FROM %sposts
|
||||
WHERE repost_id IS NOT NULL
|
||||
GROUP BY repost_id) rp ON t.id = rp.repost_id
|
||||
WHERE t.id IN ?`, prefix, prefix, prefix, prefix, prefix), postIds).Scan(&reactInfo)
|
||||
if err := database.C.Model(&models.Reaction{}).
|
||||
Select(v.ColumnName+"_id as post_id, symbol, COUNT(id) as count").
|
||||
Where(v.ColumnName+"_id IN (?)", idx).
|
||||
Group("post_id, symbol").
|
||||
Scan(&reactions).Error; err != nil {
|
||||
return items, err
|
||||
}
|
||||
|
||||
postMap := lo.SliceToMap(posts, func(item *models.Post) (uint, *models.Post) {
|
||||
itemMap := lo.SliceToMap(items, func(item *models.Feed) (uint, *models.Feed) {
|
||||
return item.ID, item
|
||||
})
|
||||
|
||||
for _, info := range reactInfo {
|
||||
if post, ok := postMap[info.PostID]; ok {
|
||||
post.LikeCount = info.LikeCount
|
||||
post.DislikeCount = info.DislikeCount
|
||||
post.ReplyCount = info.ReplyCount
|
||||
post.RepostCount = info.RepostCount
|
||||
list := map[uint]map[string]int64{}
|
||||
for _, info := range reactions {
|
||||
if _, ok := list[info.PostID]; !ok {
|
||||
list[info.PostID] = make(map[string]int64)
|
||||
}
|
||||
list[info.PostID][info.Symbol] = info.Count
|
||||
}
|
||||
|
||||
for k, v := range list {
|
||||
if post, ok := itemMap[k]; ok {
|
||||
post.ReactionList = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return posts, nil
|
||||
{
|
||||
var attachments []struct {
|
||||
models.Attachment
|
||||
|
||||
PostID uint `json:"post_id"`
|
||||
}
|
||||
|
||||
func NewPost(
|
||||
user models.Account,
|
||||
realm *models.Realm,
|
||||
alias, title, content string,
|
||||
attachments []models.Attachment,
|
||||
categories []models.Category,
|
||||
tags []models.Tag,
|
||||
publishedAt *time.Time,
|
||||
replyTo, repostTo *uint,
|
||||
) (models.Post, error) {
|
||||
itemMap := lo.SliceToMap(items, func(item *models.Feed) (uint, *models.Feed) {
|
||||
return item.ID, item
|
||||
})
|
||||
|
||||
idx := lo.Map(items, func(item *models.Feed, index int) uint {
|
||||
return item.ID
|
||||
})
|
||||
|
||||
if err := database.C.
|
||||
Model(&models.Attachment{}).
|
||||
Select(v.ColumnName+"_id as post_id, *").
|
||||
Where(v.ColumnName+"_id IN (?)", idx).
|
||||
Scan(&attachments).Error; err != nil {
|
||||
return items, err
|
||||
}
|
||||
|
||||
list := map[uint][]models.Attachment{}
|
||||
for _, info := range attachments {
|
||||
list[info.PostID] = append(list[info.PostID], info.Attachment)
|
||||
}
|
||||
|
||||
for k, v := range list {
|
||||
if post, ok := itemMap[k]; ok {
|
||||
post.Attachments = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func MapCategoriesAndTags[T models.PostInterface](item T) (T, error) {
|
||||
var err error
|
||||
var post models.Post
|
||||
categories := item.GetCategories()
|
||||
for idx, category := range categories {
|
||||
categories[idx], err = GetCategory(category.Alias)
|
||||
if err != nil {
|
||||
return post, err
|
||||
return item, err
|
||||
}
|
||||
}
|
||||
item.SetCategories(categories)
|
||||
tags := item.GetHashtags()
|
||||
for idx, tag := range tags {
|
||||
tags[idx], err = GetTagOrCreate(tag.Alias, tag.Name)
|
||||
if err != nil {
|
||||
return post, err
|
||||
return item, err
|
||||
}
|
||||
}
|
||||
item.SetHashtags(tags)
|
||||
return item, nil
|
||||
}
|
||||
|
||||
var realmId *uint
|
||||
if realm != nil {
|
||||
if !realm.IsPublic {
|
||||
func NewPost[T models.PostInterface](item T) (T, error) {
|
||||
item, err := MapCategoriesAndTags(item)
|
||||
if err != nil {
|
||||
return item, err
|
||||
}
|
||||
|
||||
if item.GetRealm() != nil {
|
||||
if !item.GetRealm().IsPublic {
|
||||
var member models.RealmMember
|
||||
if err := database.C.Where(&models.RealmMember{
|
||||
RealmID: realm.ID,
|
||||
AccountID: user.ID,
|
||||
RealmID: item.GetRealm().ID,
|
||||
AccountID: item.GetAuthor().ID,
|
||||
}).First(&member).Error; err != nil {
|
||||
return post, fmt.Errorf("you aren't a part of that realm")
|
||||
return item, fmt.Errorf("you aren't a part of that realm")
|
||||
}
|
||||
}
|
||||
realmId = &realm.ID
|
||||
}
|
||||
|
||||
if publishedAt == nil {
|
||||
publishedAt = lo.ToPtr(time.Now())
|
||||
if err := database.C.Save(&item).Error; err != nil {
|
||||
return item, err
|
||||
}
|
||||
|
||||
post = models.Post{
|
||||
Alias: alias,
|
||||
Title: title,
|
||||
Content: content,
|
||||
Attachments: attachments,
|
||||
Tags: tags,
|
||||
Categories: categories,
|
||||
AuthorID: user.ID,
|
||||
RealmID: realmId,
|
||||
PublishedAt: *publishedAt,
|
||||
RepostID: repostTo,
|
||||
ReplyID: replyTo,
|
||||
}
|
||||
|
||||
if err := database.C.Save(&post).Error; err != nil {
|
||||
return post, err
|
||||
}
|
||||
|
||||
if post.ReplyID != nil {
|
||||
var op models.Post
|
||||
if err := database.C.Where(&models.Post{
|
||||
BaseModel: models.BaseModel{ID: *post.ReplyID},
|
||||
}).Preload("Author").First(&op).Error; err == nil {
|
||||
if op.Author.ID != user.ID {
|
||||
postUrl := fmt.Sprintf("https://%s/posts/%s", viper.GetString("domain"), post.Alias)
|
||||
if item.GetReplyTo() != nil {
|
||||
go func() {
|
||||
var op models.Moment
|
||||
if err := database.C.Where("id = ?", item.GetReplyTo()).Preload("Author").First(&op).Error; err == nil {
|
||||
if op.Author.ID != item.GetAuthor().ID {
|
||||
postUrl := fmt.Sprintf("https://%s/posts/%d", viper.GetString("domain"), item.GetID())
|
||||
err := NotifyAccount(
|
||||
op.Author,
|
||||
fmt.Sprintf("%s replied you", user.Name),
|
||||
fmt.Sprintf("%s replied your post. Check it out!", user.Name),
|
||||
fmt.Sprintf("%s replied you", item.GetAuthor().Name),
|
||||
fmt.Sprintf("%s replied your post. Check it out!", item.GetAuthor().Name),
|
||||
&proto.NotifyLink{Label: "Related post", Url: postUrl},
|
||||
)
|
||||
if err != nil {
|
||||
@ -237,25 +306,23 @@ func NewPost(
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
go func() {
|
||||
var subscribers []models.AccountMembership
|
||||
if err := database.C.Where(&models.AccountMembership{
|
||||
FollowingID: user.ID,
|
||||
}).Preload("Follower").Find(&subscribers).Error; err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
FollowingID: item.GetAuthor().ID,
|
||||
}).Preload("Follower").Find(&subscribers).Error; err == nil && len(subscribers) > 0 {
|
||||
go func() {
|
||||
accounts := lo.Map(subscribers, func(item models.AccountMembership, index int) models.Account {
|
||||
return item.Follower
|
||||
})
|
||||
|
||||
for _, account := range accounts {
|
||||
postUrl := fmt.Sprintf("https://%s/posts/%s", viper.GetString("domain"), post.Alias)
|
||||
postUrl := fmt.Sprintf("https://%s/posts/%d", viper.GetString("domain"), item.GetID())
|
||||
err := NotifyAccount(
|
||||
account,
|
||||
fmt.Sprintf("%s just posted a post", user.Name),
|
||||
fmt.Sprintf("%s just posted a post", item.GetAuthor().Name),
|
||||
"Account you followed post a brand new post. Check it out!",
|
||||
&proto.NotifyLink{Label: "Related post", Url: postUrl},
|
||||
)
|
||||
@ -264,87 +331,34 @@ func NewPost(
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return post, nil
|
||||
}
|
||||
|
||||
func EditPost(
|
||||
post models.Post,
|
||||
alias, title, content string,
|
||||
publishedAt *time.Time,
|
||||
categories []models.Category,
|
||||
tags []models.Tag,
|
||||
attachments []models.Attachment,
|
||||
) (models.Post, error) {
|
||||
var err error
|
||||
for idx, category := range categories {
|
||||
categories[idx], err = GetCategory(category.Alias)
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func EditPost[T models.PostInterface](item T) (T, error) {
|
||||
item, err := MapCategoriesAndTags(item)
|
||||
if err != nil {
|
||||
return post, err
|
||||
}
|
||||
}
|
||||
for idx, tag := range tags {
|
||||
tags[idx], err = GetTagOrCreate(tag.Alias, tag.Name)
|
||||
if err != nil {
|
||||
return post, err
|
||||
}
|
||||
return item, err
|
||||
}
|
||||
|
||||
if publishedAt == nil {
|
||||
publishedAt = lo.ToPtr(time.Now())
|
||||
err = database.C.Save(&item).Error
|
||||
|
||||
return item, err
|
||||
}
|
||||
|
||||
post.Alias = alias
|
||||
post.Title = title
|
||||
post.Content = content
|
||||
post.PublishedAt = *publishedAt
|
||||
post.Tags = tags
|
||||
post.Categories = categories
|
||||
post.Attachments = attachments
|
||||
|
||||
err = database.C.Save(&post).Error
|
||||
|
||||
return post, err
|
||||
func DeletePost[T models.PostInterface](item T) error {
|
||||
return database.C.Delete(&item).Error
|
||||
}
|
||||
|
||||
func LikePost(user models.Account, post models.Post) (bool, error) {
|
||||
var like models.PostLike
|
||||
if err := database.C.Where(&models.PostLike{
|
||||
AccountID: user.ID,
|
||||
PostID: post.ID,
|
||||
}).First(&like).Error; err != nil {
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return true, err
|
||||
}
|
||||
like = models.PostLike{
|
||||
AccountID: user.ID,
|
||||
PostID: post.ID,
|
||||
}
|
||||
return true, database.C.Save(&like).Error
|
||||
func (v *PostTypeContext) React(reaction models.Reaction) (bool, models.Reaction, error) {
|
||||
if err := database.C.Where(reaction).First(&reaction).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return true, reaction, database.C.Save(&reaction).Error
|
||||
} else {
|
||||
return false, database.C.Delete(&like).Error
|
||||
return true, reaction, err
|
||||
}
|
||||
}
|
||||
|
||||
func DislikePost(user models.Account, post models.Post) (bool, error) {
|
||||
var dislike models.PostDislike
|
||||
if err := database.C.Where(&models.PostDislike{
|
||||
AccountID: user.ID,
|
||||
PostID: post.ID,
|
||||
}).First(&dislike).Error; err != nil {
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return true, err
|
||||
}
|
||||
dislike = models.PostDislike{
|
||||
AccountID: user.ID,
|
||||
PostID: post.ID,
|
||||
}
|
||||
return true, database.C.Save(&dislike).Error
|
||||
} else {
|
||||
return false, database.C.Delete(&dislike).Error
|
||||
return false, reaction, database.C.Delete(&reaction).Error
|
||||
}
|
||||
}
|
||||
|
||||
func DeletePost(post models.Post) error {
|
||||
return database.C.Delete(&post).Error
|
||||
}
|
||||
|
7
pkg/view/.gitignore
vendored
7
pkg/view/.gitignore
vendored
@ -1,7 +0,0 @@
|
||||
/dist
|
||||
/node_modules
|
||||
|
||||
.DS_Store
|
||||
|
||||
package-lock.json
|
||||
yarn.lock
|
@ -1,28 +0,0 @@
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
$ npm install # or pnpm install or yarn install
|
||||
```
|
||||
|
||||
### Learn more on the [Solid Website](https://solidjs.com) and come chat with us on our [Discord](https://discord.com/invite/solidjs)
|
||||
|
||||
## Available Scripts
|
||||
|
||||
In the project directory, you can run:
|
||||
|
||||
### `npm run dev`
|
||||
|
||||
Runs the app in the development mode.<br>
|
||||
Open [http://localhost:5173](http://localhost:5173) to view it in the browser.
|
||||
|
||||
### `npm run build`
|
||||
|
||||
Builds the app for production to the `dist` folder.<br>
|
||||
It correctly bundles Solid in production mode and optimizes the build for the best performance.
|
||||
|
||||
The build is minified and the filenames include the hashes.<br>
|
||||
Your app is ready to be deployed!
|
||||
|
||||
## Deployment
|
||||
|
||||
Learn more about deploying your application with the [documentations](https://vitejs.dev/guide/static-deploy.html)
|
@ -1,27 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport"
|
||||
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
||||
<title>Embedded Interactive</title>
|
||||
|
||||
<style>
|
||||
body, html {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
iframe {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
display: block;
|
||||
border: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<iframe src="http://localhost:8445/realms/1?noTitle=1"></iframe>
|
||||
</body>
|
||||
</html>
|
@ -1,13 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Goatplaza</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
@ -1,36 +0,0 @@
|
||||
{
|
||||
"name": "@hydrogen/interactive-web",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@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",
|
||||
"marked": "^12.0.0",
|
||||
"medium-zoom": "^1.1.0",
|
||||
"solid-js": "^1.8.7",
|
||||
"universal-cookie": "^7.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/typography": "^0.5.10",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"autoprefixer": "^10.4.17",
|
||||
"daisyui": "^4.6.1",
|
||||
"postcss": "^8.4.33",
|
||||
"solid-devtools": "^0.29.3",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.0.8",
|
||||
"vite-plugin-solid": "^2.8.0"
|
||||
}
|
||||
}
|
@ -1,6 +0,0 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
File diff suppressed because one or more lines are too long
Before Width: | Height: | Size: 12 KiB |
@ -1,5 +0,0 @@
|
||||
{
|
||||
"printWidth": 120,
|
||||
"tabWidth": 2,
|
||||
"singleQuote": false
|
||||
}
|
@ -1,184 +0,0 @@
|
||||
:root {
|
||||
--bs-body-font-family: "IBM Plex Sans", "Noto Serif SC", sans-serif !important;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
font-family: var(--bs-body-font-family);
|
||||
}
|
||||
|
||||
/* ibm-plex-sans-100 - latin */
|
||||
@font-face {
|
||||
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
|
||||
font-family: 'IBM Plex Sans';
|
||||
font-style: normal;
|
||||
font-weight: 100;
|
||||
src: url('./ibm-plex-sans-v19-latin-100.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
/* ibm-plex-sans-100italic - latin */
|
||||
@font-face {
|
||||
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
|
||||
font-family: 'IBM Plex Sans';
|
||||
font-style: italic;
|
||||
font-weight: 100;
|
||||
src: url('./ibm-plex-sans-v19-latin-100italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
/* ibm-plex-sans-200 - latin */
|
||||
@font-face {
|
||||
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
|
||||
font-family: 'IBM Plex Sans';
|
||||
font-style: normal;
|
||||
font-weight: 200;
|
||||
src: url('./ibm-plex-sans-v19-latin-200.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
/* ibm-plex-sans-200italic - latin */
|
||||
@font-face {
|
||||
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
|
||||
font-family: 'IBM Plex Sans';
|
||||
font-style: italic;
|
||||
font-weight: 200;
|
||||
src: url('./ibm-plex-sans-v19-latin-200italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
/* ibm-plex-sans-300 - latin */
|
||||
@font-face {
|
||||
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
|
||||
font-family: 'IBM Plex Sans';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
src: url('./ibm-plex-sans-v19-latin-300.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
/* ibm-plex-sans-300italic - latin */
|
||||
@font-face {
|
||||
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
|
||||
font-family: 'IBM Plex Sans';
|
||||
font-style: italic;
|
||||
font-weight: 300;
|
||||
src: url('./ibm-plex-sans-v19-latin-300italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
/* ibm-plex-sans-regular - latin */
|
||||
@font-face {
|
||||
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
|
||||
font-family: 'IBM Plex Sans';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: url('./ibm-plex-sans-v19-latin-regular.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
/* ibm-plex-sans-italic - latin */
|
||||
@font-face {
|
||||
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
|
||||
font-family: 'IBM Plex Sans';
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
src: url('./ibm-plex-sans-v19-latin-italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
/* ibm-plex-sans-500 - latin */
|
||||
@font-face {
|
||||
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
|
||||
font-family: 'IBM Plex Sans';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
src: url('./ibm-plex-sans-v19-latin-500.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
/* ibm-plex-sans-500italic - latin */
|
||||
@font-face {
|
||||
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
|
||||
font-family: 'IBM Plex Sans';
|
||||
font-style: italic;
|
||||
font-weight: 500;
|
||||
src: url('./ibm-plex-sans-v19-latin-500italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
/* ibm-plex-sans-600 - latin */
|
||||
@font-face {
|
||||
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
|
||||
font-family: 'IBM Plex Sans';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
src: url('./ibm-plex-sans-v19-latin-600.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
/* ibm-plex-sans-600italic - latin */
|
||||
@font-face {
|
||||
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
|
||||
font-family: 'IBM Plex Sans';
|
||||
font-style: italic;
|
||||
font-weight: 600;
|
||||
src: url('./ibm-plex-sans-v19-latin-600italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
/* ibm-plex-sans-700 - latin */
|
||||
@font-face {
|
||||
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
|
||||
font-family: 'IBM Plex Sans';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
src: url('./ibm-plex-sans-v19-latin-700.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
/* ibm-plex-sans-700italic - latin */
|
||||
@font-face {
|
||||
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
|
||||
font-family: 'IBM Plex Sans';
|
||||
font-style: italic;
|
||||
font-weight: 700;
|
||||
src: url('./ibm-plex-sans-v19-latin-700italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
|
||||
/* noto-serif-sc-200 - chinese-simplified */
|
||||
@font-face {
|
||||
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
|
||||
font-family: "Noto Serif SC";
|
||||
font-style: normal;
|
||||
font-weight: 200;
|
||||
src: url("./noto-serif-sc-v22-chinese-simplified-200.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
|
||||
/* noto-serif-sc-300 - chinese-simplified */
|
||||
@font-face {
|
||||
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
|
||||
font-family: "Noto Serif SC";
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
src: url("./noto-serif-sc-v22-chinese-simplified-300.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
|
||||
/* noto-serif-sc-regular - chinese-simplified */
|
||||
@font-face {
|
||||
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
|
||||
font-family: "Noto Serif SC";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: url("./noto-serif-sc-v22-chinese-simplified-regular.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
|
||||
/* noto-serif-sc-500 - chinese-simplified */
|
||||
@font-face {
|
||||
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
|
||||
font-family: "Noto Serif SC";
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
src: url("./noto-serif-sc-v22-chinese-simplified-500.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
|
||||
/* noto-serif-sc-600 - chinese-simplified */
|
||||
@font-face {
|
||||
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
|
||||
font-family: "Noto Serif SC";
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
src: url("./noto-serif-sc-v22-chinese-simplified-600.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
|
||||
/* noto-serif-sc-700 - chinese-simplified */
|
||||
@font-face {
|
||||
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
|
||||
font-family: "Noto Serif SC";
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
src: url("./noto-serif-sc-v22-chinese-simplified-700.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
|
||||
/* noto-serif-sc-900 - chinese-simplified */
|
||||
@font-face {
|
||||
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
|
||||
font-family: "Noto Serif SC";
|
||||
font-style: normal;
|
||||
font-weight: 900;
|
||||
src: url("./noto-serif-sc-v22-chinese-simplified-900.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1,22 +0,0 @@
|
||||
import { Show } from "solid-js";
|
||||
|
||||
export default function Avatar(props: { user: any }) {
|
||||
return (
|
||||
<Show
|
||||
when={props.user?.avatar}
|
||||
fallback={
|
||||
<div class="avatar placeholder">
|
||||
<div class="w-12 h-12 bg-neutral text-neutral-content">
|
||||
<span class="text-xl uppercase">{props.user?.name?.substring(0, 1)}</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="avatar">
|
||||
<div class="w-12">
|
||||
<img alt="avatar" src={props.user?.avatar} />
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
);
|
||||
}
|
@ -1,8 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
@ -1,3 +0,0 @@
|
||||
.description {
|
||||
color: var(--fallback-bc, oklch(var(--bc)/.8));
|
||||
}
|
@ -1,98 +0,0 @@
|
||||
import { createSignal, Show } from "solid-js";
|
||||
|
||||
import styles from "./NameCard.module.css";
|
||||
import { getAtk } from "../stores/userinfo.tsx";
|
||||
import { request } from "../scripts/request.ts";
|
||||
|
||||
export default function NameCard(props: { accountId: string, onError: (messasge: string | null) => void }) {
|
||||
const [info, setInfo] = createSignal<any>(null);
|
||||
const [isFollowing, setIsFollowing] = createSignal(false);
|
||||
|
||||
const [_, setLoading] = createSignal(true);
|
||||
const [submitting, setSubmitting] = createSignal(false);
|
||||
|
||||
async function readInfo() {
|
||||
setLoading(true);
|
||||
const res = await request(`/api/users/${props.accountId}`);
|
||||
if (res.status !== 200) {
|
||||
props.onError(await res.text());
|
||||
} else {
|
||||
setInfo(await res.json());
|
||||
props.onError(null);
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
async function readIsFollowing() {
|
||||
setLoading(true);
|
||||
const res = await request(`/api/users/${props.accountId}/follow`, {
|
||||
method: "GET",
|
||||
headers: { Authorization: `Bearer ${getAtk()}` }
|
||||
});
|
||||
if (res.status === 200) {
|
||||
const data = await res.json();
|
||||
setIsFollowing(data["is_followed"]);
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
async function follow() {
|
||||
setSubmitting(true);
|
||||
const res = await request(`/api/users/${props.accountId}/follow`, {
|
||||
method: "POST",
|
||||
headers: { "Authorization": `Bearer ${getAtk()}` }
|
||||
});
|
||||
if (res.status !== 201 && res.status !== 204) {
|
||||
props.onError(await res.text());
|
||||
} else {
|
||||
await readIsFollowing();
|
||||
props.onError(null);
|
||||
}
|
||||
setSubmitting(false);
|
||||
}
|
||||
|
||||
readInfo();
|
||||
readIsFollowing();
|
||||
|
||||
return (
|
||||
<div class="relative">
|
||||
<figure id="banner">
|
||||
<img class="object-cover w-full h-36" src="https://images.unsplash.com/photo-1464822759023-fed622ff2c3b"
|
||||
alt="banner" />
|
||||
</figure>
|
||||
|
||||
<div id="avatar" class="avatar absolute border-4 border-base-200 left-[20px] top-[4.5rem]">
|
||||
<div class="w-24">
|
||||
<img src={info()?.avatar} alt="avatar" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="actions" class="flex justify-end">
|
||||
<div>
|
||||
<Show when={isFollowing()} fallback={
|
||||
<button type="button" class="btn btn-primary" disabled={submitting()} onClick={() => follow()}>
|
||||
<i class="fa-solid fa-plus"></i>
|
||||
Follow
|
||||
</button>
|
||||
}>
|
||||
<button type="button" class="btn btn-accent" disabled={submitting()} onClick={() => follow()}>
|
||||
<i class="fa-solid fa-check"></i>
|
||||
Followed
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="description" class="px-6 pb-7">
|
||||
<h2 class="text-2xl font-bold">{info()?.name}</h2>
|
||||
<p class="text-md">{info()?.description}</p>
|
||||
<div class={`mt-2 ${styles.description}`}>
|
||||
<p class="text-xs">
|
||||
<i class="fa-solid fa-calendar-days me-2"></i>
|
||||
Joined at {new Date(info()?.created_at).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,3 +0,0 @@
|
||||
.attachmentsControl {
|
||||
background-color: transparent !important;
|
||||
}
|
@ -1,158 +0,0 @@
|
||||
import { createEffect, createMemo, createSignal, Match, Switch } from "solid-js";
|
||||
import mediumZoom from "medium-zoom";
|
||||
|
||||
import styles from "./PostAttachments.module.css";
|
||||
|
||||
import Artplayer from "artplayer";
|
||||
import HlsJs from "hls.js";
|
||||
import FlvJs from "flv.js";
|
||||
|
||||
function Video({ url, ...rest }: any) {
|
||||
let container: any;
|
||||
|
||||
function playM3u8(video: HTMLVideoElement, url: string, art: Artplayer) {
|
||||
if (HlsJs.isSupported()) {
|
||||
if (art.hls) art.hls.destroy();
|
||||
const hls = new HlsJs();
|
||||
hls.loadSource(url);
|
||||
hls.attachMedia(video);
|
||||
art.hls = hls;
|
||||
art.on("destroy", () => hls.destroy());
|
||||
} else if (video.canPlayType("application/vnd.apple.mpegurl")) {
|
||||
video.src = url;
|
||||
} else {
|
||||
art.notice.show = "Unsupported playback format: m3u8";
|
||||
}
|
||||
}
|
||||
|
||||
function playFlv(video: HTMLVideoElement, url: string, art: Artplayer) {
|
||||
if (FlvJs.isSupported()) {
|
||||
if (art.flv) art.flv.destroy();
|
||||
const flv = FlvJs.createPlayer({ type: "flv", url });
|
||||
flv.attachMediaElement(video);
|
||||
flv.load();
|
||||
art.flv = flv;
|
||||
art.on("destroy", () => flv.destroy());
|
||||
} else {
|
||||
art.notice.show = "Unsupported playback format: flv";
|
||||
}
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
new Artplayer({
|
||||
container: container as HTMLDivElement,
|
||||
url: url,
|
||||
setting: true,
|
||||
flip: true,
|
||||
loop: true,
|
||||
playbackRate: true,
|
||||
aspectRatio: true,
|
||||
subtitleOffset: true,
|
||||
fullscreen: true,
|
||||
fullscreenWeb: true,
|
||||
theme: "#49509e",
|
||||
customType: {
|
||||
m3u8: playM3u8,
|
||||
flv: playFlv
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<div ref={container} {...rest}></div>
|
||||
);
|
||||
}
|
||||
|
||||
function Audio({ url, caption, ...rest }: any) {
|
||||
|
||||
return (
|
||||
<figure {...rest}>
|
||||
<figcaption>{caption}</figcaption>
|
||||
<audio controls src={url} />
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export default function PostAttachments(props: { attachments: any[] }) {
|
||||
if (props.attachments.length <= 0) return null;
|
||||
|
||||
const [focus, setFocus] = createSignal(0);
|
||||
const item = createMemo(() => props.attachments[focus()]);
|
||||
|
||||
function getRenderType(item: any): string {
|
||||
return item.mimetype.split("/")[0];
|
||||
}
|
||||
|
||||
function getUrl(item: any): string {
|
||||
return item.external_url ? item.external_url : `/api/attachments/o/${item.file_id}`;
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
mediumZoom(document.querySelectorAll(".attachment-image img"), {
|
||||
background: "var(--fallback-b1,oklch(var(--b1)/1))"
|
||||
});
|
||||
}, [focus()]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<p class="text-xs mt-3 mb-2">
|
||||
<i class="fa-solid fa-paperclip me-2"></i>
|
||||
Attached {props.attachments.length} file{props.attachments.length > 1 ? "s" : null}
|
||||
</p>
|
||||
<div class="border border-base-200">
|
||||
<Switch fallback={
|
||||
<div class="py-16 flex justify-center items-center">
|
||||
<div class="text-center">
|
||||
<i class="fa-solid fa-circle-question text-3xl"></i>
|
||||
<p class="mt-3">{item().filename}</p>
|
||||
|
||||
<div class="flex gap-2 w-full">
|
||||
<p class="text-sm">{item().filesize <= 0 ? "Unknown" : item().filesize} Bytes</p>
|
||||
<p class="text-sm">{item().mimetype}</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-5">
|
||||
<a class="link" href={getUrl(item())} target="_blank">Open in browser</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}>
|
||||
<Match when={getRenderType(item()) === "image"}>
|
||||
<figure class="attachment-image">
|
||||
<img class="object-cover" src={getUrl(item())} alt={item().filename} />
|
||||
</figure>
|
||||
</Match>
|
||||
<Match when={getRenderType(item()) === "audio"}>
|
||||
<Audio class="p-5 flex flex-col items-center justify-center gap-2 w-full" url={getUrl(item())}
|
||||
caption={item().filename} />
|
||||
</Match>
|
||||
<Match when={getRenderType(item()) === "video"}>
|
||||
<Video class="h-[360px] w-full" url={getUrl(item())} caption={item().filename} />
|
||||
</Match>
|
||||
</Switch>
|
||||
|
||||
<div id="attachments-control" class="flex justify-between border-t border-base-200">
|
||||
<div class="flex">
|
||||
<button class={`w-12 h-12 btn btn-ghost ${styles.attachmentsControl}`}
|
||||
disabled={focus() - 1 < 0}
|
||||
onClick={() => setFocus(focus() - 1)}>
|
||||
<i class="fa-solid fa-caret-left"></i>
|
||||
</button>
|
||||
<button class={`w-12 h-12 btn btn-ghost ${styles.attachmentsControl}`}
|
||||
disabled={focus() + 1 >= props.attachments.length}
|
||||
onClick={() => setFocus(focus() + 1)}>
|
||||
<i class="fa-solid fa-caret-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="h-12 px-5 py-3.5 text-sm">
|
||||
File {focus() + 1}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
@ -1,399 +0,0 @@
|
||||
import { closeModel, openModel } from "../../scripts/modals.ts";
|
||||
import { createSignal, For, Match, Show, Switch } from "solid-js";
|
||||
import { getAtk, useUserinfo } from "../../stores/userinfo.tsx";
|
||||
import { request } from "../../scripts/request.ts";
|
||||
|
||||
import styles from "./PostPublish.module.css";
|
||||
|
||||
export default function PostEditActions(props: {
|
||||
editing?: any;
|
||||
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;
|
||||
}) {
|
||||
const userinfo = useUserinfo();
|
||||
|
||||
const [uploading, setUploading] = createSignal(false);
|
||||
|
||||
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 [availableCategories, setAvailableCategories] = createSignal<any[]>([]);
|
||||
const [attachmentMode, setAttachmentMode] = createSignal(0);
|
||||
|
||||
async function readCategories() {
|
||||
const res = await request("/api/categories");
|
||||
if (res.status === 200) {
|
||||
setAvailableCategories(await res.json());
|
||||
}
|
||||
}
|
||||
|
||||
readCategories();
|
||||
|
||||
async function uploadAttachment(evt: SubmitEvent) {
|
||||
evt.preventDefault();
|
||||
|
||||
const form = evt.target as HTMLFormElement;
|
||||
const data = new FormData(form);
|
||||
if (!data.get("attachment")) return;
|
||||
|
||||
setUploading(true);
|
||||
const res = await request("/api/attachments", {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${getAtk()}` },
|
||||
body: data,
|
||||
});
|
||||
if (res.status !== 200) {
|
||||
props.onError(await res.text());
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setAttachments(attachments().concat([data.info]));
|
||||
props.onInputAttachments(attachments());
|
||||
props.onError(null);
|
||||
form.reset();
|
||||
}
|
||||
setUploading(false);
|
||||
}
|
||||
|
||||
function addAttachment(evt: SubmitEvent) {
|
||||
evt.preventDefault();
|
||||
|
||||
const form = evt.target as HTMLFormElement;
|
||||
const data = Object.fromEntries(new FormData(form));
|
||||
|
||||
setAttachments(
|
||||
attachments().concat([
|
||||
{
|
||||
...data,
|
||||
author_id: userinfo?.profiles?.id,
|
||||
},
|
||||
]),
|
||||
);
|
||||
props.onInputAttachments(attachments());
|
||||
form.reset();
|
||||
}
|
||||
|
||||
function removeAttachment(idx: number) {
|
||||
const data = attachments().slice();
|
||||
data.splice(idx, 1);
|
||||
setAttachments(data);
|
||||
props.onInputAttachments(attachments());
|
||||
}
|
||||
|
||||
function addCategory(evt: SubmitEvent) {
|
||||
evt.preventDefault();
|
||||
|
||||
const form = evt.target as HTMLFormElement;
|
||||
const data = Object.fromEntries(new FormData(form));
|
||||
if (!data.category) return;
|
||||
|
||||
const item = availableCategories().find((item) => item.alias === data.category);
|
||||
|
||||
setCategories(categories().concat([item]));
|
||||
props.onInputCategories(categories());
|
||||
form.reset();
|
||||
}
|
||||
|
||||
function removeCategory(idx: number) {
|
||||
const data = categories().slice();
|
||||
data.splice(idx, 1);
|
||||
setCategories(data);
|
||||
props.onInputCategories(categories());
|
||||
}
|
||||
|
||||
function addTag(evt: SubmitEvent) {
|
||||
evt.preventDefault();
|
||||
|
||||
const form = evt.target as HTMLFormElement;
|
||||
const data = Object.fromEntries(new FormData(evt.target as HTMLFormElement));
|
||||
if (!data.alias) data.alias = crypto.randomUUID().replace(/-/g, "");
|
||||
if (!data.name) return;
|
||||
|
||||
setTags(tags().concat([data as any]));
|
||||
props.onInputTags(tags());
|
||||
form.reset();
|
||||
}
|
||||
|
||||
function removeTag(idx: number) {
|
||||
const data = tags().slice();
|
||||
data.splice(idx, 1);
|
||||
setTags(data);
|
||||
props.onInputTags(tags());
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<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>
|
||||
<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>
|
||||
</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"
|
||||
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>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<dialog id="attachments" class="modal">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg mx-1">Attachments</h3>
|
||||
|
||||
<div role="tablist" class="tabs tabs-boxed mt-3">
|
||||
<input
|
||||
type="radio"
|
||||
name="attachment"
|
||||
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>
|
||||
|
||||
<Switch>
|
||||
<Match when={attachmentMode() === 0}>
|
||||
<form class="w-full mt-2" onSubmit={uploadAttachment}>
|
||||
<label class="form-control">
|
||||
<div class="label">
|
||||
<span class="label-text">Pick a file</span>
|
||||
</div>
|
||||
<div class="join">
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
<div class="label">
|
||||
<span class="label-text-alt">Click upload to add this file into list</span>
|
||||
</div>
|
||||
</label>
|
||||
</form>
|
||||
</Match>
|
||||
<Match when={attachmentMode() === 1}>
|
||||
<form class="w-full mt-2" onSubmit={addAttachment}>
|
||||
<label class="form-control">
|
||||
<div class="label">
|
||||
<span class="label-text">Attach an external file</span>
|
||||
</div>
|
||||
<div class="join">
|
||||
<input
|
||||
required
|
||||
type="text"
|
||||
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 class="join">
|
||||
<input
|
||||
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">
|
||||
<i class="fa-solid fa-plus"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="label">
|
||||
<span class="label-text-alt">Click add button to add it into list</span>
|
||||
</div>
|
||||
</label>
|
||||
</form>
|
||||
</Match>
|
||||
</Switch>
|
||||
|
||||
<Show when={attachments().length > 0}>
|
||||
<h3 class="font-bold mt-3 mx-1">Attachment list</h3>
|
||||
<ol class="mt-2 mx-1 text-sm">
|
||||
<For each={attachments()}>
|
||||
{(item, idx) => (
|
||||
<li>
|
||||
<i class="fa-regular fa-file me-2"></i>
|
||||
{item.filename}
|
||||
<button class="ml-2" onClick={() => removeAttachment(idx())}>
|
||||
<i class="fa-solid fa-delete-left"></i>
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ol>
|
||||
</Show>
|
||||
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" onClick={() => closeModel("#attachments")}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<dialog id="categories-and-tags" class="modal">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg mx-1">Categories & Tags</h3>
|
||||
<form class="w-full mt-3" onSubmit={addCategory}>
|
||||
<label class="form-control">
|
||||
<div class="label">
|
||||
<span class="label-text">Add a category</span>
|
||||
</div>
|
||||
<div class="join">
|
||||
<select name="category" class="join-item select select-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">
|
||||
<i class="fa-solid fa-plus"></i>
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
</form>
|
||||
|
||||
<Show when={categories().length > 0}>
|
||||
<h3 class="font-bold mt-3 mx-1">Category list</h3>
|
||||
<ol class="mt-2 mx-1 text-sm">
|
||||
<For each={categories()}>
|
||||
{(item, idx) => (
|
||||
<li>
|
||||
<i class="fa-solid fa-layer-group me-2"></i>
|
||||
{item.name} <span class={styles.description}>#{item.alias}</span>
|
||||
<button class="ml-2" onClick={() => removeCategory(idx())}>
|
||||
<i class="fa-solid fa-delete-left"></i>
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ol>
|
||||
</Show>
|
||||
|
||||
<form class="w-full mt-3" onSubmit={addTag}>
|
||||
<label class="form-control">
|
||||
<div class="label">
|
||||
<span class="label-text">Add a tag</span>
|
||||
</div>
|
||||
<div class="join">
|
||||
<input type="text" name="alias" placeholder="Alias" class="join-item input input-bordered w-full" />
|
||||
<input type="text" name="name" placeholder="Name" class="join-item input input-bordered w-full" />
|
||||
<button type="submit" class="join-item btn btn-primary">
|
||||
<i class="fa-solid fa-plus"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="label">
|
||||
<span class="label-text-alt">
|
||||
Alias is the url key of this tag. Lowercase only, required length 4-24. Leave blank for auto generate.
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
</form>
|
||||
|
||||
<Show when={tags().length > 0}>
|
||||
<h3 class="font-bold mt-3 mx-1">Category list</h3>
|
||||
<ol class="mt-2 mx-1 text-sm">
|
||||
<For each={tags()}>
|
||||
{(item, idx) => (
|
||||
<li>
|
||||
<i class="fa-solid fa-tag me-2"></i>
|
||||
{item.name} <span class={styles.description}>#{item.alias}</span>
|
||||
<button class="ml-2" onClick={() => removeTag(idx())}>
|
||||
<i class="fa-solid fa-delete-left"></i>
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ol>
|
||||
</Show>
|
||||
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" onClick={() => closeModel("#categories-and-tags")}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</>
|
||||
);
|
||||
}
|
@ -1,223 +0,0 @@
|
||||
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 { request } from "../../scripts/request.ts";
|
||||
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 ?? []);
|
||||
editor()?.setValue(props.editing?.content);
|
||||
}, [props.editing]);
|
||||
|
||||
async function listRealm() {
|
||||
const res = await request("/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 request("/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 request(`/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
|
||||
editing={props.editing}
|
||||
onInputAlias={setAlias}
|
||||
onInputPublish={setPublishedAt}
|
||||
onInputAttachments={setAttachments}
|
||||
onInputCategories={setCategories}
|
||||
onInputTags={setTags}
|
||||
onError={props.onError}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="pt-3 pb-7 px-7">
|
||||
<Show when={!props.editing} fallback={
|
||||
<label class="form-control w-full mb-3">
|
||||
<div class="label">
|
||||
<span class="label-text">Publish region</span>
|
||||
</div>
|
||||
<input readonly type="text" class="input input-bordered"
|
||||
value={`You published this post in realm #${props.editing?.realm_id ?? "global"}`} />
|
||||
</label>
|
||||
}>
|
||||
<label class="form-control w-full">
|
||||
<div class="label">
|
||||
<span class="label-text">Publish region</span>
|
||||
</div>
|
||||
<select name="realm" class="select select-bordered">
|
||||
<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>
|
||||
</Show>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
@ -1,215 +0,0 @@
|
||||
import { createSignal, For, Show } from "solid-js";
|
||||
import { getAtk, useUserinfo } from "../../stores/userinfo.tsx";
|
||||
import { request } from "../../scripts/request.ts";
|
||||
import PostAttachments from "./PostAttachments.tsx";
|
||||
import * as marked from "marked";
|
||||
import DOMPurify from "dompurify";
|
||||
import Avatar from "../Avatar.tsx";
|
||||
|
||||
export default function PostItem(props: {
|
||||
post: any;
|
||||
noClick?: boolean;
|
||||
noAuthor?: boolean;
|
||||
noControl?: boolean;
|
||||
noRelated?: boolean;
|
||||
noContent?: boolean;
|
||||
onRepost?: (post: any) => void;
|
||||
onReply?: (post: any) => void;
|
||||
onEdit?: (post: any) => void;
|
||||
onDelete?: (post: any) => void;
|
||||
onSearch?: (filter: any) => void;
|
||||
onError: (message: string | null) => void;
|
||||
onReact: () => void;
|
||||
}) {
|
||||
const [reacting, setReacting] = createSignal(false);
|
||||
|
||||
const userinfo = useUserinfo();
|
||||
|
||||
async function reactPost(item: any, type: string) {
|
||||
setReacting(true);
|
||||
const res = await request(`/api/posts/${item.id}/react/${type}`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${getAtk()}` }
|
||||
});
|
||||
if (res.status !== 201 && res.status !== 204) {
|
||||
props.onError(await res.text());
|
||||
} else {
|
||||
props.onReact();
|
||||
props.onError(null);
|
||||
}
|
||||
setReacting(false);
|
||||
}
|
||||
|
||||
const content = <article class="prose" innerHTML={DOMPurify.sanitize(marked.parse(props.post.content) as string)} />;
|
||||
|
||||
return (
|
||||
<div class="post-item">
|
||||
<Show when={!props.noAuthor}>
|
||||
<a href={`/accounts/${props.post.author.name}`}>
|
||||
<div class="flex bg-base-200">
|
||||
<div class="pl-[20px]">
|
||||
<Avatar user={props.post.author} />
|
||||
</div>
|
||||
<div class="flex items-center px-5">
|
||||
<div>
|
||||
<h3 class="font-bold text-sm">{props.post.author.nick}</h3>
|
||||
<p class="text-xs">{props.post.author.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</Show>
|
||||
|
||||
<Show when={!props.noContent}>
|
||||
<div class="px-7 py-5">
|
||||
<h2 class="card-title">{props.post.title}</h2>
|
||||
<Show when={!props.noClick} fallback={content}>
|
||||
<a href={`/posts/${props.post.alias}`}>{content}</a>
|
||||
</Show>
|
||||
|
||||
<div class="mt-2 flex gap-2">
|
||||
<For each={props.post.categories}>
|
||||
{(item) => (
|
||||
<a href={`/search?category=${item.alias}`} class="badge badge-primary">
|
||||
<i class="fa-solid fa-layer-group me-1.5"></i>
|
||||
{item.name}
|
||||
</a>
|
||||
)}
|
||||
</For>
|
||||
<For each={props.post.tags}>
|
||||
{(item) => (
|
||||
<a href={`/search?tag=${item.alias}`} class="badge badge-accent">
|
||||
<i class="fa-solid fa-tag me-1.5"></i>
|
||||
{item.name}
|
||||
</a>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<Show when={props.post.attachments?.length > 0}>
|
||||
<div>
|
||||
<PostAttachments attachments={props.post.attachments ?? []} />
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!props.noRelated && props.post.repost_to}>
|
||||
<p class="text-xs mt-3 mb-2">
|
||||
<i class="fa-solid fa-retweet me-2"></i>
|
||||
Reposted a post
|
||||
</p>
|
||||
<div class="border border-base-200 mb-5">
|
||||
<PostItem noControl post={props.post.repost_to} onError={props.onError} onReact={props.onReact} />
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={!props.noRelated && props.post.reply_to}>
|
||||
<p class="text-xs mt-3 mb-2">
|
||||
<i class="fa-solid fa-reply me-2"></i>
|
||||
Replied a post
|
||||
</p>
|
||||
<div class="border border-base-200 mb-5">
|
||||
<PostItem noControl post={props.post.reply_to} onError={props.onError} onReact={props.onReact} />
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!props.noControl}>
|
||||
<div class="relative">
|
||||
<Show when={!userinfo?.isLoggedIn}>
|
||||
<div
|
||||
class="px-7 py-2.5 h-12 w-full opacity-0 transition-opacity hover:opacity-100 bg-base-100 border-t border-base-200 z-[1] absolute top-0 left-0">
|
||||
<b>Login!</b> To access entire platform.
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="grid grid-cols-3 border-y border-base-200">
|
||||
<div class="max-md:col-span-2 md:col-span-1 grid grid-cols-2">
|
||||
<div class="tooltip" data-tip="Daisuki">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-block"
|
||||
disabled={reacting()}
|
||||
onClick={() => reactPost(props.post, "like")}
|
||||
>
|
||||
<i class="fa-solid fa-thumbs-up"></i>
|
||||
<code class="font-mono">{props.post.like_count}</code>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="tooltip" data-tip="Daikirai">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-block"
|
||||
disabled={reacting()}
|
||||
onClick={() => reactPost(props.post, "dislike")}
|
||||
>
|
||||
<i class="fa-solid fa-thumbs-down"></i>
|
||||
<code class="font-mono">{props.post.dislike_count}</code>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="max-md:col-span-1 md:col-span-2 flex justify-end">
|
||||
<section class="max-md:hidden">
|
||||
<div class="tooltip" data-tip="Reply">
|
||||
<button
|
||||
type="button"
|
||||
class="indicator btn btn-ghost btn-block"
|
||||
onClick={() => props.onReply && props.onReply(props.post)}
|
||||
>
|
||||
<span class="indicator-item badge badge-sm badge-primary">{props.post.reply_count}</span>
|
||||
<i class="fa-solid fa-reply"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="tooltip" data-tip="Repost">
|
||||
<button
|
||||
type="button"
|
||||
class="indicator btn btn-ghost btn-block"
|
||||
onClick={() => props.onRepost && props.onRepost(props.post)}
|
||||
>
|
||||
<span class="indicator-item badge badge-sm badge-secondary">{props.post.repost_count}</span>
|
||||
<i class="fa-solid fa-retweet"></i>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="dropdown dropdown-end">
|
||||
<div tabIndex="0" role="button" class="btn btn-ghost w-12">
|
||||
<i class="fa-solid fa-ellipsis-vertical"></i>
|
||||
</div>
|
||||
<ul tabIndex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52">
|
||||
<li class="md:hidden">
|
||||
<a class="flex justify-between" onClick={() => props.onReply && props.onReply(props.post)}>
|
||||
<span>Reply</span>
|
||||
<span class="badge badge-primary">{props.post.reply_count}</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="md:hidden">
|
||||
<a class="flex justify-between" onClick={() => props.onRepost && props.onRepost(props.post)}>
|
||||
<span>Repost</span>
|
||||
<span class="badge badge-secondary">{props.post.repost_count}</span>
|
||||
</a>
|
||||
</li>
|
||||
<Show when={userinfo?.profiles?.id === props.post.author_id}>
|
||||
<li>
|
||||
<a onClick={() => props.onDelete && props.onDelete(props.post)}>Delete</a>
|
||||
</li>
|
||||
</Show>
|
||||
<Show when={userinfo?.profiles?.id === props.post.author_id}>
|
||||
<li>
|
||||
<a onClick={() => props.onEdit && props.onEdit(props.post)}>Edit</a>
|
||||
</li>
|
||||
</Show>
|
||||
<li>
|
||||
<a>Report</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,3 +0,0 @@
|
||||
.paginationControl {
|
||||
background-color: transparent !important;
|
||||
}
|
@ -1,96 +0,0 @@
|
||||
import { createMemo, createSignal, For, Show } from "solid-js";
|
||||
|
||||
import styles from "./PostList.module.css";
|
||||
import PostItem from "./PostItem.tsx";
|
||||
import LoadingAnimation from "../LoadingAnimation.tsx";
|
||||
import { getAtk } from "../../stores/userinfo.tsx";
|
||||
import { request } from "../../scripts/request.ts";
|
||||
|
||||
export default function PostList(props: {
|
||||
noRelated?: boolean,
|
||||
info: { data: any[], count: number } | null,
|
||||
onRepost?: (post: any) => void,
|
||||
onReply?: (post: any) => void,
|
||||
onEdit?: (post: any) => void,
|
||||
onUpdate: (pn: number, filter?: any) => Promise<void>,
|
||||
onError: (message: string | null) => void
|
||||
}) {
|
||||
const [loading, setLoading] = createSignal(true);
|
||||
|
||||
const posts = createMemo(() => props.info?.data);
|
||||
const postCount = createMemo<number>(() => props.info?.count ?? 0);
|
||||
|
||||
const [page, setPage] = createSignal(1);
|
||||
const pageCount = createMemo(() => Math.ceil(postCount() / 10));
|
||||
|
||||
async function readPosts(filter?: any) {
|
||||
setLoading(true);
|
||||
await props.onUpdate(page(), filter);
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
readPosts();
|
||||
|
||||
async function deletePost(item: any) {
|
||||
if (!confirm(`Are you sure to delete post#${item.id}?`)) return;
|
||||
|
||||
setLoading(true);
|
||||
const res = await request(`/api/posts/${item.id}`, {
|
||||
method: "DELETE",
|
||||
headers: { "Authorization": `Bearer ${getAtk()}` }
|
||||
});
|
||||
if (res.status !== 200) {
|
||||
props.onError(await res.text());
|
||||
} else {
|
||||
await readPosts();
|
||||
props.onError(null);
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
function changePage(pn: number) {
|
||||
setPage(pn);
|
||||
readPosts().then(() => {
|
||||
setTimeout(() => window.scrollTo({ top: 0, behavior: "smooth" }), 16);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div id="post-list">
|
||||
<div id="posts">
|
||||
<For each={posts()}>
|
||||
{item =>
|
||||
<PostItem
|
||||
post={item}
|
||||
noRelated={props.noRelated}
|
||||
onRepost={props.onRepost}
|
||||
onReply={props.onReply}
|
||||
onEdit={props.onEdit}
|
||||
onDelete={deletePost}
|
||||
onReact={() => readPosts()}
|
||||
onError={props.onError}
|
||||
/>
|
||||
}
|
||||
</For>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,8 +0,0 @@
|
||||
.publishInput {
|
||||
outline-style: none !important;
|
||||
outline-width: 0 !important;
|
||||
}
|
||||
|
||||
.description {
|
||||
color: var(--fallback-bc, oklch(var(--bc)/.8));
|
||||
}
|
@ -1,210 +0,0 @@
|
||||
import { createEffect, createSignal, Show } from "solid-js";
|
||||
import { getAtk, useUserinfo } from "../../stores/userinfo.tsx";
|
||||
import { request } from "../../scripts/request.ts";
|
||||
|
||||
import styles from "./PostPublish.module.css";
|
||||
import PostEditActions from "./PostEditActions.tsx";
|
||||
import Avatar from "../Avatar.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 request("/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 request(`/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="pl-[20px]">
|
||||
<Avatar user={userinfo?.profiles} />
|
||||
</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
|
||||
editing={props.editing}
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
@ -1,39 +0,0 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
html, body {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.medium-zoom-image--opened {
|
||||
z-index: 15;
|
||||
}
|
||||
|
||||
.medium-zoom-overlay {
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.scrollbar-hidden {
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.scrollbar-hidden::-webkit-scrollbar {
|
||||
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;
|
||||
}
|
||||
}
|
@ -1,79 +0,0 @@
|
||||
import "solid-devtools";
|
||||
|
||||
/* @refresh reload */
|
||||
import { render } from "solid-js/web";
|
||||
|
||||
import "./index.css";
|
||||
import "./assets/fonts/fonts.css";
|
||||
import { lazy } from "solid-js";
|
||||
import { Route, Router } from "@solidjs/router";
|
||||
|
||||
import "@fortawesome/fontawesome-free/css/all.css";
|
||||
|
||||
import RootLayout from "./layouts/RootLayout.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";
|
||||
|
||||
const root = document.getElementById("root");
|
||||
|
||||
const router = (basename?: string) => (
|
||||
<WellKnownProvider>
|
||||
<UserinfoProvider>
|
||||
<Router root={RootLayout} base={basename}>
|
||||
<Route path="/" component={FeedView}>
|
||||
<Route path="/" component={Global} />
|
||||
<Route path="/posts/:postId" component={PostReference} />
|
||||
<Route path="/search" component={lazy(() => import("./pages/search.tsx"))} />
|
||||
<Route path="/realms" component={lazy(() => import("./pages/realms"))} />
|
||||
<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>
|
||||
</Router>
|
||||
</UserinfoProvider>
|
||||
</WellKnownProvider>
|
||||
);
|
||||
|
||||
declare const __GARFISH_EXPORTS__: {
|
||||
provider: Object;
|
||||
registerProvider?: (provider: any) => void;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__GARFISH__: boolean;
|
||||
__LAUNCHPAD_TARGET__?: string;
|
||||
}
|
||||
}
|
||||
|
||||
export const provider = () => ({
|
||||
render: ({ dom, basename }: { dom: any, basename: string }) => {
|
||||
render(
|
||||
() => router(basename),
|
||||
dom.querySelector("#root")
|
||||
);
|
||||
},
|
||||
destroy: () => {
|
||||
}
|
||||
});
|
||||
|
||||
if (!window.__GARFISH__) {
|
||||
console.log("Running directly!")
|
||||
render(router, root!);
|
||||
} else if (typeof __GARFISH_EXPORTS__ !== "undefined") {
|
||||
console.log("Running in launchpad container!")
|
||||
console.log("Launchpad target:", window.__LAUNCHPAD_TARGET__)
|
||||
if (__GARFISH_EXPORTS__.registerProvider) {
|
||||
__GARFISH_EXPORTS__.registerProvider(provider);
|
||||
} else {
|
||||
__GARFISH_EXPORTS__.provider = provider;
|
||||
}
|
||||
}
|
@ -1,63 +0,0 @@
|
||||
import Navigator from "./shared/Navigator.tsx";
|
||||
import { readProfiles, useUserinfo } from "../stores/userinfo.tsx";
|
||||
import { createEffect, createMemo, createSignal, Show } from "solid-js";
|
||||
import { readWellKnown } from "../stores/wellKnown.tsx";
|
||||
import { BeforeLeaveEventArgs, useLocation, useNavigate, useSearchParams } from "@solidjs/router";
|
||||
|
||||
export default function RootLayout(props: any) {
|
||||
const [ready, setReady] = createSignal(false);
|
||||
|
||||
Promise.all([readWellKnown(), readProfiles()]).then(() => setReady(true));
|
||||
|
||||
const navigate = useNavigate();
|
||||
const userinfo = useUserinfo();
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
const location = useLocation();
|
||||
|
||||
createEffect(() => {
|
||||
if (ready()) {
|
||||
keepGate(location.pathname + location.search, searchParams["embedded"] != null);
|
||||
}
|
||||
}, [ready, userinfo]);
|
||||
|
||||
function keepGate(path: string, embedded: boolean, e?: BeforeLeaveEventArgs) {
|
||||
const blacklist = ["/creators"];
|
||||
|
||||
if (!userinfo?.isLoggedIn && blacklist.includes(path)) {
|
||||
if (!e?.defaultPrevented) e?.preventDefault();
|
||||
if (embedded) {
|
||||
navigate(`/auth?redirect_uri=${path}&embedded=${location.query["embedded"]}`);
|
||||
} else {
|
||||
navigate(`/auth?redirect_uri=${path}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const mainContentStyles = createMemo(() => {
|
||||
if (!searchParams["embedded"]) {
|
||||
return "h-[calc(100vh-64px)] max-md:mb-[64px] md:mt-[64px]";
|
||||
} else {
|
||||
return "h-[100vh]";
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Show
|
||||
when={ready()}
|
||||
fallback={
|
||||
<div class="h-screen w-screen flex justify-center items-center">
|
||||
<div>
|
||||
<span class="loading loading-lg loading-infinity"></span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show when={!searchParams["embedded"]}>
|
||||
<Navigator />
|
||||
</Show>
|
||||
|
||||
<main class={`${mainContentStyles()} scrollbar-hidden`}>{props.children}</main>
|
||||
</Show>
|
||||
);
|
||||
}
|
@ -1,80 +0,0 @@
|
||||
import { createMemo, For, Match, Switch } from "solid-js";
|
||||
import { clearUserinfo, useUserinfo } from "../../stores/userinfo.tsx";
|
||||
import { useNavigate } from "@solidjs/router";
|
||||
import { useWellKnown } from "../../stores/wellKnown.tsx";
|
||||
|
||||
interface MenuItem {
|
||||
icon: string;
|
||||
label: string;
|
||||
href?: string;
|
||||
}
|
||||
|
||||
export default function Navigator() {
|
||||
const nav: MenuItem[] = [
|
||||
{ icon: "fa-solid fa-pen-nib", label: "Creators", href: "/creators" },
|
||||
{ icon: "fa-solid fa-newspaper", label: "Feed", href: "/" },
|
||||
{ icon: "fa-solid fa-people-group", label: "Realms", href: "/realms" },
|
||||
];
|
||||
|
||||
const wellKnown = useWellKnown();
|
||||
const userinfo = useUserinfo();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const endpoint = createMemo(() => wellKnown?.components?.identity)
|
||||
|
||||
function logout() {
|
||||
clearUserinfo();
|
||||
navigate("/");
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div class="max-md:hidden navbar bg-base-100 shadow-md px-5 z-10 h-[64px] fixed top-0">
|
||||
<div class="navbar-start">
|
||||
<a href="/" class="btn btn-ghost text-xl">
|
||||
{wellKnown?.name ?? "Interactive"}
|
||||
</a>
|
||||
</div>
|
||||
<div class="navbar-center hidden md:flex">
|
||||
<ul class="menu menu-horizontal px-1">
|
||||
<For each={nav}>
|
||||
{(item) => (
|
||||
<li class="tooltip tooltip-bottom" data-tip={item.label}>
|
||||
<a href={item.href}>
|
||||
<i class={item.icon}></i>
|
||||
</a>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="navbar-end pe-5">
|
||||
<Switch>
|
||||
<Match when={userinfo?.isLoggedIn}>
|
||||
<button type="button" class="btn btn-sm btn-ghost" onClick={() => logout()}>
|
||||
Logout
|
||||
</button>
|
||||
</Match>
|
||||
<Match when={!userinfo?.isLoggedIn}>
|
||||
<a href={`${endpoint()}/auth/login?redirect_uri=${window.location}`} class="btn btn-sm btn-primary">
|
||||
Login
|
||||
</a>
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="md:hidden btm-nav fixed bottom-0 bg-base-100 border-t border-base-200 z-10 h-[64px]">
|
||||
<For each={nav}>
|
||||
{(item) => (
|
||||
<a href={item.href}>
|
||||
<div class="tooltip" data-tip={item.label}>
|
||||
<i class={item.icon}></i>
|
||||
</div>
|
||||
</a>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
@ -1,110 +0,0 @@
|
||||
import { createEffect, createSignal, Show } from "solid-js";
|
||||
import { useParams } from "@solidjs/router";
|
||||
import { useSearchParams } from "@solidjs/router";
|
||||
import { createStore } from "solid-js/store";
|
||||
import { closeModel, openModel } from "../scripts/modals.ts";
|
||||
import { request } from "../scripts/request.ts";
|
||||
|
||||
import PostList from "../components/posts/PostList.tsx";
|
||||
import NameCard from "../components/NameCard.tsx";
|
||||
import PostPublish from "../components/posts/PostPublish.tsx";
|
||||
|
||||
export default function AccountPage() {
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
|
||||
const [page, setPage] = createSignal(0);
|
||||
const [info, setInfo] = createSignal<any>(null);
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const params = useParams();
|
||||
|
||||
createEffect(() => {
|
||||
setPage(parseInt(searchParams["page"] ?? "1"));
|
||||
}, [searchParams]);
|
||||
|
||||
async function readPosts(pn?: number) {
|
||||
if (pn) setSearchParams({ page: pn });
|
||||
const res = await request(
|
||||
"/api/posts?" +
|
||||
new URLSearchParams({
|
||||
take: searchParams["take"] ? searchParams["take"] : (10).toString(),
|
||||
offset: searchParams["offset"] ? searchParams["offset"] : ((page() - 1) * 10).toString(),
|
||||
authorId: params["accountId"],
|
||||
}),
|
||||
);
|
||||
if (res.status !== 200) {
|
||||
setError(await res.text());
|
||||
} else {
|
||||
setError(null);
|
||||
setInfo(await res.json());
|
||||
}
|
||||
}
|
||||
|
||||
function setMeta(data: any, field: string, open = true) {
|
||||
const meta: { [id: string]: any } = {
|
||||
reposting: null,
|
||||
replying: null,
|
||||
editing: null,
|
||||
};
|
||||
meta[field] = data;
|
||||
setPublishMeta(meta);
|
||||
|
||||
if (open) openModel("#post-publish");
|
||||
else closeModel("#post-publish");
|
||||
}
|
||||
|
||||
const [publishMeta, setPublishMeta] = createStore<any>({
|
||||
replying: null,
|
||||
reposting: null,
|
||||
editing: null,
|
||||
});
|
||||
|
||||
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>
|
||||
|
||||
<NameCard accountId={params["accountId"]} onError={setError} />
|
||||
|
||||
<dialog id="post-publish" class="modal">
|
||||
<div class="modal-box p-0 w-[540px]">
|
||||
<PostPublish
|
||||
reposting={publishMeta.reposting}
|
||||
replying={publishMeta.replying}
|
||||
editing={publishMeta.editing}
|
||||
onReset={() => setMeta(null, "none", false)}
|
||||
onError={setError}
|
||||
onPost={() => readPosts()}
|
||||
/>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<PostList
|
||||
info={info()}
|
||||
onUpdate={readPosts}
|
||||
onError={setError}
|
||||
onRepost={(item) => setMeta(item, "reposting")}
|
||||
onReply={(item) => setMeta(item, "replying")}
|
||||
onEdit={(item) => setMeta(item, "editing")}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
@ -1,58 +0,0 @@
|
||||
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";
|
||||
import { request } from "../../scripts/request.ts";
|
||||
|
||||
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 request(`/api/creators/posts/${params["postId"]}`, {
|
||||
headers: { "Authorization": `Bearer ${getAtk()}` }
|
||||
});
|
||||
if (res.status === 200) {
|
||||
setPost((await res.json())["data"]);
|
||||
} 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>Edit「{post()?.title ? post()?.title : "Untitled"}」</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")}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
@ -1,121 +0,0 @@
|
||||
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";
|
||||
import { request } from "../../scripts/request.ts";
|
||||
|
||||
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 request("/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>
|
||||
</>
|
||||
);
|
||||
}
|
@ -1,40 +0,0 @@
|
||||
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")}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
@ -1,13 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
}
|
@ -1,28 +0,0 @@
|
||||
import { createMemo } from "solid-js";
|
||||
import { useSearchParams } from "@solidjs/router";
|
||||
|
||||
import styles from "./view.module.css";
|
||||
|
||||
export default function CreatorView(props: any) {
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const scrollContentStyles = createMemo(() => {
|
||||
if (!searchParams["embedded"]) {
|
||||
return "max-md:mb-[64px]";
|
||||
} else {
|
||||
return "h-[100vh]";
|
||||
}
|
||||
});
|
||||
|
||||
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={`${scrollContentStyles()} card shadow-xl`}>
|
||||
{props.children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,99 +0,0 @@
|
||||
import { createEffect, createSignal, Show } from "solid-js";
|
||||
import { createStore } from "solid-js/store";
|
||||
import { useSearchParams } from "@solidjs/router";
|
||||
import { request } from "../scripts/request.ts";
|
||||
|
||||
import PostList from "../components/posts/PostList.tsx";
|
||||
import PostPublish from "../components/posts/PostPublish.tsx";
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
|
||||
const [page, setPage] = createSignal(0);
|
||||
const [info, setInfo] = createSignal<any>(null);
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
createEffect(() => {
|
||||
setPage(parseInt(searchParams["page"] ?? "1"));
|
||||
}, [searchParams]);
|
||||
|
||||
async function readPosts(pn?: number) {
|
||||
if (pn) setSearchParams({ page: pn });
|
||||
const res = await request(
|
||||
"/api/posts?" +
|
||||
new URLSearchParams({
|
||||
take: searchParams["take"] ? searchParams["take"] : (10).toString(),
|
||||
offset: searchParams["offset"] ? searchParams["offset"] : ((page() - 1) * 10).toString(),
|
||||
reply: false.toString(),
|
||||
}),
|
||||
);
|
||||
if (res.status !== 200) {
|
||||
setError(await res.text());
|
||||
} else {
|
||||
setError(null);
|
||||
setInfo(await res.json());
|
||||
}
|
||||
}
|
||||
|
||||
function setMeta(data: any, field: string, scroll = true) {
|
||||
const meta: { [id: string]: any } = {
|
||||
reposting: null,
|
||||
replying: null,
|
||||
editing: null,
|
||||
};
|
||||
meta[field] = data;
|
||||
setPublishMeta(meta);
|
||||
|
||||
if (scroll) window.scroll({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
|
||||
const [publishMeta, setPublishMeta] = createStore<any>({
|
||||
replying: null,
|
||||
reposting: null,
|
||||
editing: null,
|
||||
});
|
||||
|
||||
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>
|
||||
|
||||
<PostPublish
|
||||
replying={publishMeta.replying}
|
||||
reposting={publishMeta.reposting}
|
||||
editing={publishMeta.editing}
|
||||
onReset={() => setMeta(null, "none", false)}
|
||||
onPost={() => readPosts()}
|
||||
onError={setError}
|
||||
/>
|
||||
|
||||
<PostList
|
||||
info={info()}
|
||||
onUpdate={readPosts}
|
||||
onError={setError}
|
||||
onRepost={(item) => setMeta(item, "reposting")}
|
||||
onReply={(item) => setMeta(item, "replying")}
|
||||
onEdit={(item) => setMeta(item, "editing")}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
@ -1,161 +0,0 @@
|
||||
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 { getAtk } from "../stores/userinfo.tsx";
|
||||
import { request } from "../scripts/request.ts";
|
||||
import PostPublish from "../components/posts/PostPublish.tsx";
|
||||
import PostList from "../components/posts/PostList.tsx";
|
||||
import PostItem from "../components/posts/PostItem.tsx";
|
||||
|
||||
export default function PostPage() {
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
|
||||
const [page, setPage] = createSignal(0);
|
||||
const [related, setRelated] = createSignal<any>(null);
|
||||
const [info, setInfo] = createSignal<any>(null);
|
||||
|
||||
const params = useParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
async function readPost(pn?: number) {
|
||||
if (pn) setPage(pn);
|
||||
const res = await request(`/api/posts/${params["postId"]}?` + new URLSearchParams({
|
||||
take: (10).toString(),
|
||||
offset: ((page() - 1) * 10).toString()
|
||||
}));
|
||||
if (res.status !== 200) {
|
||||
setError(await res.text());
|
||||
} else {
|
||||
setError(null);
|
||||
const data = await res.json();
|
||||
setInfo(data["data"]);
|
||||
setRelated({
|
||||
count: data["count"],
|
||||
data: data["related"]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
readPost();
|
||||
|
||||
async function deletePost(item: any) {
|
||||
if (!confirm(`Are you sure to delete post#${item.id}?`)) return;
|
||||
|
||||
const res = await request(`/api/posts/${item.id}`, {
|
||||
method: "DELETE",
|
||||
headers: { "Authorization": `Bearer ${getAtk()}` }
|
||||
});
|
||||
if (res.status !== 200) {
|
||||
setError(await res.text());
|
||||
} else {
|
||||
back();
|
||||
setError(null);
|
||||
}
|
||||
}
|
||||
|
||||
function setMeta(data: any, field: string, open = true) {
|
||||
const meta: { [id: string]: any } = {
|
||||
reposting: null,
|
||||
replying: null,
|
||||
editing: null
|
||||
};
|
||||
meta[field] = data;
|
||||
setPublishMeta(meta);
|
||||
|
||||
if (open) openModel("#post-publish");
|
||||
else closeModel("#post-publish");
|
||||
}
|
||||
|
||||
const [publishMeta, setPublishMeta] = createStore<any>({
|
||||
replying: null,
|
||||
reposting: null,
|
||||
editing: null
|
||||
});
|
||||
|
||||
function back() {
|
||||
if (window.history.length > 0) {
|
||||
window.history.back();
|
||||
} else {
|
||||
navigate("/");
|
||||
}
|
||||
}
|
||||
|
||||
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="flex pt-1">
|
||||
<Show when={searchParams["embedded"]} fallback={
|
||||
<button class="btn btn-ghost ml-[20px] w-12 h-12" onClick={() => back()}>
|
||||
<i class="fa-solid fa-angle-left"></i>
|
||||
</button>
|
||||
}>
|
||||
<div class="w-12 h-12 ml-[20px] flex justify-center items-center">
|
||||
<i class="fa-solid fa-comments mb-1"></i>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="px-5 flex items-center">
|
||||
<p>{searchParams["title"] ?? "Post details"}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dialog id="post-publish" class="modal">
|
||||
<div class="modal-box p-0 w-[540px]">
|
||||
<PostPublish
|
||||
reposting={publishMeta.reposting}
|
||||
replying={publishMeta.replying}
|
||||
editing={publishMeta.editing}
|
||||
onReset={() => setMeta(null, "none", false)}
|
||||
onError={setError}
|
||||
onPost={() => readPost()}
|
||||
/>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<Show when={info()} fallback={
|
||||
<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>
|
||||
}>
|
||||
<PostItem
|
||||
noClick
|
||||
post={info()}
|
||||
onError={setError}
|
||||
onReact={readPost}
|
||||
onDelete={deletePost}
|
||||
noAuthor={searchParams["noAuthor"] != null}
|
||||
noContent={searchParams["noContent"] != null}
|
||||
noControl={searchParams["noControl"] != null}
|
||||
onRepost={(item) => setMeta(item, "reposting")}
|
||||
onReply={(item) => setMeta(item, "replying")}
|
||||
onEdit={(item) => setMeta(item, "editing")}
|
||||
/>
|
||||
|
||||
<PostList
|
||||
noRelated
|
||||
info={related()}
|
||||
onUpdate={readPost}
|
||||
onError={setError}
|
||||
onRepost={(item) => setMeta(item, "reposting")}
|
||||
onReply={(item) => setMeta(item, "replying")}
|
||||
onEdit={(item) => setMeta(item, "editing")}
|
||||
/>
|
||||
</Show>
|
||||
</>
|
||||
);
|
||||
}
|
@ -1,115 +0,0 @@
|
||||
import { createSignal, For, Show } from "solid-js";
|
||||
import { closeModel, openModel } from "../../scripts/modals.ts";
|
||||
import { getAtk } from "../../stores/userinfo.tsx";
|
||||
import { request } from "../../scripts/request.ts";
|
||||
|
||||
export default function RealmDirectoryPage() {
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
const [submitting, setSubmitting] = createSignal(false);
|
||||
|
||||
const [realms, setRealms] = createSignal<any>(null);
|
||||
|
||||
async function readRealms() {
|
||||
const res = await request(`/api/realms`);
|
||||
if (res.status !== 200) {
|
||||
setError(await res.text());
|
||||
} else {
|
||||
setRealms(await res.json());
|
||||
}
|
||||
}
|
||||
|
||||
readRealms();
|
||||
|
||||
async function createRealm(evt: SubmitEvent) {
|
||||
evt.preventDefault();
|
||||
|
||||
const form = evt.target as HTMLFormElement;
|
||||
const data = Object.fromEntries(new FormData(form));
|
||||
|
||||
setSubmitting(true);
|
||||
const res = await request("/api/realms", {
|
||||
method: "POST",
|
||||
headers: { "Authorization": `Bearer ${getAtk()}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
is_public: data.is_public != null
|
||||
})
|
||||
});
|
||||
if (res.status !== 200) {
|
||||
setError(await res.text());
|
||||
} else {
|
||||
await readRealms();
|
||||
closeModel("#create-realm");
|
||||
form.reset();
|
||||
}
|
||||
setSubmitting(false);
|
||||
}
|
||||
|
||||
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">
|
||||
<h3 class="py-3 font-bold">Realms directory</h3>
|
||||
<button type="button" class="btn btn-primary" onClick={() => openModel("#create-realm")}>
|
||||
<i class="fa-solid fa-plus"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<For each={realms()}>
|
||||
{item => <div class="px-7 pt-7 pb-5 border-t border-base-200">
|
||||
<h2 class="text-xl font-bold">{item.name}</h2>
|
||||
<p>{item.description}</p>
|
||||
|
||||
<div class="mt-2">
|
||||
<a href={`/realms/${item.id}`} class="link">Jump in</a>
|
||||
</div>
|
||||
</div>}
|
||||
</For>
|
||||
|
||||
<dialog id="create-realm" class="modal">
|
||||
<div class="modal-box">
|
||||
<h2 class="card-title px-1">Create a realm</h2>
|
||||
<form class="mt-2" onSubmit={createRealm}>
|
||||
<label class="form-control w-full">
|
||||
<div class="label">
|
||||
<span class="label-text">Realm name</span>
|
||||
</div>
|
||||
<input name="name" 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">Realm description</span>
|
||||
</div>
|
||||
<textarea name="description" placeholder="Type here" class="textarea textarea-bordered w-full" />
|
||||
</label>
|
||||
<div class="form-control mt-2">
|
||||
<label class="label cursor-pointer">
|
||||
<span class="label-text">Make it public</span>
|
||||
<input type="checkbox" name="is_public" class="checkbox checkbox-primary" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary mt-2" disabled={submitting()}>
|
||||
<Show when={submitting()} fallback={"Submit"}>
|
||||
<span class="loading"></span>
|
||||
</Show>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</dialog>
|
||||
</>
|
||||
);
|
||||
}
|
@ -1,3 +0,0 @@
|
||||
.description {
|
||||
color: var(--fallback-bc, oklch(var(--bc)/.8));
|
||||
}
|
@ -1,291 +0,0 @@
|
||||
import { createSignal, Show } from "solid-js";
|
||||
import { createStore } from "solid-js/store";
|
||||
import { useNavigate, useParams } from "@solidjs/router";
|
||||
import { request } from "../../scripts/request.ts";
|
||||
|
||||
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";
|
||||
import { closeModel, openModel } from "../../scripts/modals.ts";
|
||||
|
||||
export default function RealmPage() {
|
||||
const userinfo = useUserinfo();
|
||||
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
const [submitting, setSubmitting] = createSignal(false);
|
||||
|
||||
const [realm, setRealm] = createSignal<any>(null);
|
||||
const [page, setPage] = createSignal(0);
|
||||
const [info, setInfo] = createSignal<any>(null);
|
||||
|
||||
const params = useParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
async function readRealm() {
|
||||
const res = await request(`/api/realms/${params["realmId"]}`);
|
||||
if (res.status !== 200) {
|
||||
setError(await res.text());
|
||||
} else {
|
||||
setRealm(await res.json());
|
||||
}
|
||||
}
|
||||
|
||||
readRealm();
|
||||
|
||||
async function readPosts(pn?: number) {
|
||||
if (pn) setPage(pn);
|
||||
const res = await request(`/api/posts?` + new URLSearchParams({
|
||||
take: (10).toString(),
|
||||
offset: ((page() - 1) * 10).toString(),
|
||||
realmId: params["realmId"]
|
||||
}));
|
||||
if (res.status !== 200) {
|
||||
setError(await res.text());
|
||||
} else {
|
||||
setError(null);
|
||||
setInfo(await res.json());
|
||||
}
|
||||
}
|
||||
|
||||
async function editRealm(evt: SubmitEvent) {
|
||||
evt.preventDefault();
|
||||
|
||||
const form = evt.target as HTMLFormElement;
|
||||
const data = Object.fromEntries(new FormData(form));
|
||||
|
||||
setSubmitting(true);
|
||||
const res = await request(`/api/realms/${params["realmId"]}`, {
|
||||
method: "PUT",
|
||||
headers: { "Authorization": `Bearer ${getAtk()}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
is_public: data.is_public != null
|
||||
})
|
||||
});
|
||||
if (res.status !== 200) {
|
||||
setError(await res.text());
|
||||
} else {
|
||||
await readRealm();
|
||||
closeModel("#edit-realm");
|
||||
form.reset();
|
||||
}
|
||||
setSubmitting(false);
|
||||
}
|
||||
|
||||
async function inviteMember(evt: SubmitEvent) {
|
||||
evt.preventDefault();
|
||||
|
||||
const form = evt.target as HTMLFormElement;
|
||||
const data = Object.fromEntries(new FormData(form));
|
||||
|
||||
setSubmitting(true);
|
||||
const res = await request(`/api/realms/${params["realmId"]}/invite`, {
|
||||
method: "POST",
|
||||
headers: { "Authorization": `Bearer ${getAtk()}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
if (res.status !== 200) {
|
||||
setError(await res.text());
|
||||
} else {
|
||||
await readRealm();
|
||||
closeModel("#invite-member");
|
||||
form.reset();
|
||||
}
|
||||
setSubmitting(false);
|
||||
}
|
||||
|
||||
async function kickMember(evt: SubmitEvent) {
|
||||
evt.preventDefault();
|
||||
|
||||
const form = evt.target as HTMLFormElement;
|
||||
const data = Object.fromEntries(new FormData(form));
|
||||
|
||||
setSubmitting(true);
|
||||
const res = await request(`/api/realms/${params["realmId"]}/kick`, {
|
||||
method: "POST",
|
||||
headers: { "Authorization": `Bearer ${getAtk()}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
if (res.status !== 200) {
|
||||
setError(await res.text());
|
||||
} else {
|
||||
await readRealm();
|
||||
closeModel("#kick-member");
|
||||
form.reset();
|
||||
}
|
||||
setSubmitting(false);
|
||||
}
|
||||
|
||||
async function breakRealm() {
|
||||
if (!confirm("Are you sure about that? All posts in this realm will disappear forever.")) return;
|
||||
|
||||
const res = await request(`/api/realms/${params["realmId"]}`, {
|
||||
method: "DELETE",
|
||||
headers: { "Authorization": `Bearer ${getAtk()}` }
|
||||
});
|
||||
if (res.status !== 200) {
|
||||
setError(await res.text());
|
||||
} else {
|
||||
navigate("/realms");
|
||||
}
|
||||
}
|
||||
|
||||
function setMeta(data: any, field: string, scroll = true) {
|
||||
const meta: { [id: string]: any } = {
|
||||
reposting: null,
|
||||
replying: null,
|
||||
editing: null
|
||||
};
|
||||
meta[field] = data;
|
||||
setPublishMeta(meta);
|
||||
|
||||
if (scroll) window.scroll({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
|
||||
const [publishMeta, setPublishMeta] = createStore<any>({
|
||||
replying: null,
|
||||
reposting: null,
|
||||
editing: null
|
||||
});
|
||||
|
||||
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="px-7 pt-7 pb-5">
|
||||
<h2 class="text-2xl font-bold">{realm()?.name}</h2>
|
||||
<p>{realm()?.description}</p>
|
||||
|
||||
<div class={`${styles.description} text-sm mt-3`}>
|
||||
<p>Realm #{realm()?.id}</p>
|
||||
<Show when={realm()?.account_id === userinfo?.profiles?.id}>
|
||||
<div class="flex gap-2">
|
||||
<button class="link" onClick={() => openModel("#edit-realm")}>Edit</button>
|
||||
<button class="link" onClick={() => openModel("#invite-member")}>Invite</button>
|
||||
<button class="link" onClick={() => openModel("#kick-member")}>Kick</button>
|
||||
<button class="link" onClick={() => breakRealm()}>Break-up</button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PostPublish
|
||||
realmId={parseInt(params["realmId"])}
|
||||
replying={publishMeta.replying}
|
||||
reposting={publishMeta.reposting}
|
||||
editing={publishMeta.editing}
|
||||
onReset={() => setMeta(null, "none", false)}
|
||||
onPost={() => readPosts()}
|
||||
onError={setError}
|
||||
/>
|
||||
|
||||
<PostList
|
||||
info={info()}
|
||||
onUpdate={readPosts}
|
||||
onError={setError}
|
||||
onRepost={(item) => setMeta(item, "reposting")}
|
||||
onReply={(item) => setMeta(item, "replying")}
|
||||
onEdit={(item) => setMeta(item, "editing")}
|
||||
/>
|
||||
|
||||
<dialog id="edit-realm" class="modal">
|
||||
<div class="modal-box">
|
||||
<h2 class="card-title px-1">Edit your realm</h2>
|
||||
<form class="mt-2" onSubmit={editRealm}>
|
||||
<label class="form-control w-full">
|
||||
<div class="label">
|
||||
<span class="label-text">Realm name</span>
|
||||
</div>
|
||||
<input value={realm()?.name} name="name" 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">Realm description</span>
|
||||
</div>
|
||||
<textarea value={realm()?.description} name="description" placeholder="Type here"
|
||||
class="textarea textarea-bordered w-full" />
|
||||
</label>
|
||||
<div class="form-control mt-2">
|
||||
<label class="label cursor-pointer">
|
||||
<span class="label-text">Make it public</span>
|
||||
<input checked={realm()?.is_public} type="checkbox" name="is_public"
|
||||
class="checkbox checkbox-primary" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary mt-2" disabled={submitting()}>
|
||||
<Show when={submitting()} fallback={"Submit"}>
|
||||
<span class="loading"></span>
|
||||
</Show>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<dialog id="invite-member" class="modal">
|
||||
<div class="modal-box">
|
||||
<h2 class="card-title px-1">Invite someone as a member</h2>
|
||||
<form class="mt-2" onSubmit={inviteMember}>
|
||||
<label class="form-control w-full">
|
||||
<div class="label">
|
||||
<span class="label-text">Username</span>
|
||||
</div>
|
||||
<input name="account_name" type="text" placeholder="Type here" class="input input-bordered w-full" />
|
||||
<div class="label">
|
||||
<span class="label-text-alt">
|
||||
Invite someone via their username so that they can publish content in non-public realm.
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<button type="submit" class="btn btn-primary mt-2" disabled={submitting()}>
|
||||
<Show when={submitting()} fallback={"Submit"}>
|
||||
<span class="loading"></span>
|
||||
</Show>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<dialog id="kick-member" class="modal">
|
||||
<div class="modal-box">
|
||||
<h2 class="card-title px-1">Kick someone out of your realm</h2>
|
||||
<form class="mt-2" onSubmit={kickMember}>
|
||||
<label class="form-control w-full">
|
||||
<div class="label">
|
||||
<span class="label-text">Username</span>
|
||||
</div>
|
||||
<input name="account_name" type="text" placeholder="Type here" class="input input-bordered w-full" />
|
||||
<div class="label">
|
||||
<span class="label-text-alt">
|
||||
Remove someone out of your realm.
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<button type="submit" class="btn btn-primary mt-2" disabled={submitting()}>
|
||||
<Show when={submitting()} fallback={"Submit"}>
|
||||
<span class="loading"></span>
|
||||
</Show>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</dialog>
|
||||
</>
|
||||
);
|
||||
}
|
@ -1,124 +0,0 @@
|
||||
import { useNavigate, useSearchParams } from "@solidjs/router";
|
||||
import { createSignal, Show } from "solid-js";
|
||||
import { createStore } from "solid-js/store";
|
||||
import { closeModel, openModel } from "../scripts/modals.ts";
|
||||
import { request } from "../scripts/request.ts";
|
||||
import PostPublish from "../components/posts/PostPublish.tsx";
|
||||
import PostList from "../components/posts/PostList.tsx";
|
||||
|
||||
export default function SearchPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
|
||||
const [page, setPage] = createSignal(0);
|
||||
const [info, setInfo] = createSignal<any>(null);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
async function readPosts(pn?: number) {
|
||||
if (pn) setPage(pn);
|
||||
const res = await request("/api/posts?" + new URLSearchParams({
|
||||
take: (10).toString(),
|
||||
offset: ((page() - 1) * 10).toString(),
|
||||
...searchParams
|
||||
}));
|
||||
if (res.status !== 200) {
|
||||
setError(await res.text());
|
||||
} else {
|
||||
setError(null);
|
||||
setInfo(await res.json());
|
||||
}
|
||||
}
|
||||
|
||||
function setMeta(data: any, field: string, open = true) {
|
||||
const meta: { [id: string]: any } = {
|
||||
reposting: null,
|
||||
replying: null,
|
||||
editing: null
|
||||
};
|
||||
meta[field] = data;
|
||||
setPublishMeta(meta);
|
||||
|
||||
if (open) openModel("#post-publish");
|
||||
else closeModel("#post-publish");
|
||||
}
|
||||
|
||||
const [publishMeta, setPublishMeta] = createStore<any>({
|
||||
replying: null,
|
||||
reposting: null,
|
||||
editing: null
|
||||
});
|
||||
|
||||
function getDescribe() {
|
||||
let builder = [];
|
||||
if (searchParams["category"]) {
|
||||
builder.push("category is #" + searchParams["category"]);
|
||||
} else if (searchParams["tag"]) {
|
||||
builder.push("tag is #" + searchParams["tag"]);
|
||||
}
|
||||
|
||||
return builder.join(" and ");
|
||||
}
|
||||
|
||||
function back() {
|
||||
if (window.history.length > 0) {
|
||||
window.history.back();
|
||||
} else {
|
||||
navigate("/");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div class="flex pt-1">
|
||||
<button class="btn btn-ghost ml-[20px] w-12 h-12" onClick={() => back()}>
|
||||
<i class="fa-solid fa-angle-left"></i>
|
||||
</button>
|
||||
<div class="px-5 flex items-center">
|
||||
<p>Search</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>
|
||||
|
||||
<div role="alert" class="alert alert-info px-[20px]">
|
||||
<i class="fa-solid fa-magnifying-glass pl-[13px]"></i>
|
||||
<span>You will only see posts with <b>{getDescribe()}</b></span>
|
||||
</div>
|
||||
|
||||
<dialog id="post-publish" class="modal">
|
||||
<div class="modal-box p-0 w-[540px]">
|
||||
<PostPublish
|
||||
reposting={publishMeta.reposting}
|
||||
replying={publishMeta.replying}
|
||||
editing={publishMeta.editing}
|
||||
onReset={() => setMeta(null, "none", false)}
|
||||
onError={setError}
|
||||
onPost={() => readPosts()}
|
||||
/>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<PostList
|
||||
info={info()}
|
||||
onUpdate={readPosts}
|
||||
onError={setError}
|
||||
onRepost={(item) => setMeta(item, "reposting")}
|
||||
onReply={(item) => setMeta(item, "replying")}
|
||||
onEdit={(item) => setMeta(item, "editing")}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
@ -1,11 +0,0 @@
|
||||
.wrapper {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
column-gap: 20px;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.wrapper {
|
||||
grid-template-columns: 1fr 2fr 1fr;
|
||||
}
|
||||
}
|
@ -1,28 +0,0 @@
|
||||
import { createMemo } from "solid-js";
|
||||
import { useSearchParams } from "@solidjs/router";
|
||||
|
||||
import styles from "./view.module.css";
|
||||
|
||||
export default function FeedView(props: any) {
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const scrollContentStyles = createMemo(() => {
|
||||
if (!searchParams["embedded"]) {
|
||||
return "max-md:mb-[64px]";
|
||||
} else {
|
||||
return "h-[100vh]";
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div class={`${styles.wrapper} container mx-auto`}>
|
||||
<div id="trending" class="card shadow-xl h-fit"></div>
|
||||
|
||||
<div id="content" class={`${scrollContentStyles()} card shadow-xl`}>
|
||||
{props.children}
|
||||
</div>
|
||||
|
||||
<div id="well-known" class="card shadow-xl h-fit"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,7 +0,0 @@
|
||||
export function openModel(selector: string) {
|
||||
document.querySelector<HTMLDialogElement>(selector)?.showModal()
|
||||
}
|
||||
|
||||
export function closeModel(selector: string) {
|
||||
document.querySelector<HTMLDialogElement>(selector)?.close()
|
||||
}
|
@ -1,4 +0,0 @@
|
||||
export async function request(input: string, init?: RequestInit) {
|
||||
const prefix = window.__LAUNCHPAD_TARGET__ ?? "";
|
||||
return await fetch(prefix + input, init)
|
||||
}
|
@ -1,73 +0,0 @@
|
||||
import Cookie from "universal-cookie";
|
||||
import { createContext, useContext } from "solid-js";
|
||||
import { createStore } from "solid-js/store";
|
||||
import { request } from "../scripts/request.ts";
|
||||
|
||||
export interface Userinfo {
|
||||
isLoggedIn: boolean,
|
||||
displayName: string,
|
||||
profiles: any,
|
||||
}
|
||||
|
||||
const UserinfoContext = createContext<Userinfo>();
|
||||
|
||||
const defaultUserinfo: Userinfo = {
|
||||
isLoggedIn: false,
|
||||
displayName: "Citizen",
|
||||
profiles: null
|
||||
};
|
||||
|
||||
const [userinfo, setUserinfo] = createStore<Userinfo>(structuredClone(defaultUserinfo));
|
||||
|
||||
export function getAtk(): string {
|
||||
return new Cookie().get("identity_auth_key");
|
||||
}
|
||||
|
||||
function checkLoggedIn(): boolean {
|
||||
return new Cookie().get("identity_auth_key");
|
||||
}
|
||||
|
||||
export async function readProfiles() {
|
||||
if (!checkLoggedIn()) return;
|
||||
|
||||
const res = await request("/api/users/me", {
|
||||
headers: { "Authorization": `Bearer ${getAtk()}` }
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
clearUserinfo();
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
setUserinfo({
|
||||
isLoggedIn: true,
|
||||
displayName: data["name"],
|
||||
profiles: data
|
||||
});
|
||||
}
|
||||
|
||||
export function clearUserinfo() {
|
||||
const cookies = document.cookie.split(";");
|
||||
for (let i = 0; i < cookies.length; i++) {
|
||||
const cookie = cookies[i];
|
||||
const eqPos = cookie.indexOf("=");
|
||||
const name = eqPos > -1 ? cookie.substring(0, eqPos) : cookie;
|
||||
document.cookie = name + "=;expires=Thu, 01 Jan 1970 00:00:00 GMT";
|
||||
}
|
||||
|
||||
setUserinfo(defaultUserinfo);
|
||||
}
|
||||
|
||||
export function UserinfoProvider(props: any) {
|
||||
return (
|
||||
<UserinfoContext.Provider value={userinfo}>
|
||||
{props.children}
|
||||
</UserinfoContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useUserinfo() {
|
||||
return useContext(UserinfoContext);
|
||||
}
|
@ -1,24 +0,0 @@
|
||||
import { createContext, useContext } from "solid-js";
|
||||
import { createStore } from "solid-js/store";
|
||||
import { request } from "../scripts/request.ts";
|
||||
|
||||
const WellKnownContext = createContext<any>();
|
||||
|
||||
const [wellKnown, setWellKnown] = createStore<any>(null);
|
||||
|
||||
export async function readWellKnown() {
|
||||
const res = await request("/.well-known")
|
||||
setWellKnown(await res.json())
|
||||
}
|
||||
|
||||
export function WellKnownProvider(props: any) {
|
||||
return (
|
||||
<WellKnownContext.Provider value={wellKnown}>
|
||||
{props.children}
|
||||
</WellKnownContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useWellKnown() {
|
||||
return useContext(WellKnownContext);
|
||||
}
|
@ -1,44 +0,0 @@
|
||||
/** @type {import("tailwindcss").Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./src/**/*.{js,jsx,ts,tsx}"
|
||||
],
|
||||
daisyui: {
|
||||
themes: [
|
||||
{
|
||||
light: {
|
||||
...require("daisyui/src/theming/themes")["light"],
|
||||
primary: "#4750a3",
|
||||
secondary: "#93c5fd",
|
||||
accent: "#0f766e",
|
||||
info: "#67e8f9",
|
||||
success: "#15803d",
|
||||
warning: "#f97316",
|
||||
error: "#dc2626",
|
||||
"--rounded-box": "0",
|
||||
"--rounded-btn": "0",
|
||||
"--rounded-badge": "0",
|
||||
"--tab-radius": "0"
|
||||
}
|
||||
},
|
||||
{
|
||||
dark: {
|
||||
...require("daisyui/src/theming/themes")["dark"],
|
||||
primary: "#4750a3",
|
||||
secondary: "#93c5fd",
|
||||
accent: "#0f766e",
|
||||
info: "#67e8f9",
|
||||
success: "#15803d",
|
||||
warning: "#f97316",
|
||||
error: "#dc2626",
|
||||
"--rounded-box": "0",
|
||||
"--rounded-btn": "0",
|
||||
"--rounded-badge": "0",
|
||||
"--tab-radius": "0"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
plugins: [require("daisyui"), require("@tailwindcss/typography")]
|
||||
};
|
||||
|
@ -1,26 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "ES2015", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
"jsxImportSource": "solid-js",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
@ -1,10 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user