Compare commits

..

No commits in common. "bc2517dcdabfd2563cce976fcb74b5a647a70b5c" and "1e04f2029f23228c9a082d9c6b955ff4e206ae90" have entirely different histories.

144 changed files with 4090 additions and 3499 deletions
.air.toml
.github/workflows
.gitignore
.idea
Dockerfilego.modgo.sum
pkg
database
models
server
services
view
.gitignoreREADME.mdembed.goembed.htmlindex.htmlpackage.jsonpostcss.config.js
public
src
tailwind.config.js

@ -1,46 +0,0 @@
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

@ -25,4 +25,4 @@ jobs:
context: .
file: ./Dockerfile
push: true
tags: xsheep2010/interactive:v2
tags: xsheep2010/interactive:nightly

5
.gitignore vendored

@ -1,4 +1 @@
/uploads
/dist
.DS_Store
/uploads

@ -19,6 +19,10 @@
<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>

3
.idea/sqldialects.xml generated

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

@ -5,7 +5,7 @@ RUN apk add nodejs npm
WORKDIR /source
COPY . .
WORKDIR /source/pkg/views
WORKDIR /source/pkg/view
RUN npm install
RUN npm run build
WORKDIR /source
@ -18,4 +18,4 @@ COPY --from=interactive-server /dist /interactive/server
EXPOSE 8445
CMD ["/interactive/server"]
CMD ["/interactive/server"]

1
go.mod

@ -24,7 +24,6 @@ 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

@ -17,8 +17,6 @@ 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,10 +13,9 @@ func RunMigration(source *gorm.DB) error {
&models.RealmMember{},
&models.Category{},
&models.Tag{},
&models.Moment{},
&models.Article{},
&models.Comment{},
&models.Reaction{},
&models.Post{},
&models.PostLike{},
&models.PostDislike{},
&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"`
Moments []Moment `json:"moments" gorm:"foreignKey:AuthorID"`
Articles []Article `json:"articles" gorm:"foreignKey:AuthorID"`
Posts []Post `json:"posts" gorm:"foreignKey:AuthorID"`
Attachments []Attachment `json:"attachments" gorm:"foreignKey:AuthorID"`
Reactions []Reaction `json:"reactions"`
LikedPosts []PostLike `json:"liked_posts"`
DislikedPosts []PostDislike `json:"disliked_posts"`
RealmIdentities []RealmMember `json:"identities"`
Realms []Realm `json:"realms"`
ExternalID uint `json:"external_id"`

@ -1,41 +0,0 @@
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,34 +2,22 @@ package models
import (
"fmt"
"path/filepath"
"github.com/spf13/viper"
)
type AttachmentType = uint8
const (
AttachmentOthers = AttachmentType(iota)
AttachmentPhoto
AttachmentVideo
AttachmentAudio
"path/filepath"
)
type Attachment struct {
BaseModel
FileID string `json:"file_id"`
Filesize int64 `json:"filesize"`
Filename string `json:"filename"`
Mimetype string `json:"mimetype"`
Type AttachmentType `json:"type"`
ExternalUrl string `json:"external_url"`
Author Account `json:"author"`
ArticleID *uint `json:"article_id"`
MomentID *uint `json:"moment_id"`
CommentID *uint `json:"comment_id"`
AuthorID uint `json:"author_id"`
FileID string `json:"file_id"`
Filesize int64 `json:"filesize"`
Filename string `json:"filename"`
Mimetype string `json:"mimetype"`
ExternalUrl string `json:"external_url"`
Post *Post `json:"post"`
Author Account `json:"author"`
PostID *uint `json:"post_id"`
AuthorID uint `json:"author_id"`
}
func (v Attachment) GetStoragePath() string {

@ -3,21 +3,17 @@ package models
type Tag struct {
BaseModel
Alias string `json:"alias" gorm:"uniqueIndex" validate:"lowercase,alphanum,min=4,max=24"`
Name string `json:"name"`
Description string `json:"description"`
Articles []Article `json:"articles" gorm:"many2many:article_tags"`
Moments []Moment `json:"moments" gorm:"many2many:moment_tags"`
Comments []Comment `json:"comments" gorm:"many2many:comment_tags"`
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"`
}
type Category struct {
BaseModel
Alias string `json:"alias" gorm:"uniqueIndex" validate:"lowercase,alphanum,min=4,max=24"`
Name string `json:"name"`
Description string `json:"description"`
Articles []Article `json:"articles" gorm:"many2many:article_categories"`
Moments []Moment `json:"moments" gorm:"many2many:moment_categories"`
Comments []Comment `json:"comments" gorm:"many2many:comment_categories"`
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"`
}

@ -1,37 +0,0 @@
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
}

@ -1,22 +0,0 @@
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:"-"`
}

@ -1,41 +0,0 @@
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,64 +1,32 @@
package models
import (
"time"
)
import "time"
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 {
type Post struct {
BaseModel
Alias string `json:"alias" gorm:"uniqueIndex"`
PublishedAt *time.Time `json:"published_at"`
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"`
AuthorID uint `json:"author_id"`
Author Account `json:"author"`
AuthorID uint `json:"author_id"`
Author Account `json:"author"`
// 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)
// 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:"-"`
}

@ -1,27 +1,19 @@
package models
import (
"time"
)
import "time"
type ReactionAttitude = uint8
const (
AttitudeNeutral = ReactionAttitude(iota)
AttitudePositive
AttitudeNegative
)
type Reaction struct {
type PostLike struct {
ID uint `json:"id" gorm:"primaryKey"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
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"`
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"`
AccountID uint `json:"account_id"`
}

@ -5,8 +5,7 @@ type Realm struct {
Name string `json:"name"`
Description string `json:"description"`
Articles []Article `json:"article"`
Moments []Moment `json:"moments"`
Posts []Post `json:"posts"`
Members []RealmMember `json:"members"`
IsPublic bool `json:"is_public"`
AccountID uint `json:"account_id"`

@ -1,140 +0,0 @@
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 listCategories(c *fiber.Ctx) error {
func listCategroies(c *fiber.Ctx) error {
categories, err := services.ListCategory()
if err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())

@ -1,187 +0,0 @@
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)
}

@ -0,0 +1,93 @@
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,
})
}

@ -1,191 +0,0 @@
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,
})
}

@ -1,146 +0,0 @@
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,151 +1,271 @@
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"
"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 {
alias := c.Params("postId")
id := c.Params("postId")
take := c.QueryInt("take", 0)
offset := c.QueryInt("offset", 0)
mx := c.Locals(postContextKey).(*services.PostTypeContext).
FilterPublishedAt(time.Now())
tx := database.C.Where(&models.Post{
Alias: id,
}).Where("published_at <= ? OR published_at IS NULL", time.Now())
item, err := mx.GetViaAlias(alias)
post, err := services.GetPost(tx)
if err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
}
item.ReactionList, err = mx.CountReactions(item.ID)
if err != nil {
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())
}
return c.JSON(item)
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 listPost(c *fiber.Ctx) error {
take := c.QueryInt("take", 0)
offset := c.QueryInt("offset", 0)
realmId := c.QueryInt("realmId", 0)
mx := c.Locals(postContextKey).(*services.PostTypeContext).
FilterPublishedAt(time.Now()).
FilterRealm(uint(realmId)).
SortCreatedAt("desc")
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")
}
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())
}
mx = mx.FilterAuthor(author.ID)
tx = tx.Where(&models.Post{AuthorID: author.ID})
}
if len(c.Query("category")) > 0 {
mx = mx.FilterWithCategory(c.Query("category"))
tx = services.FilterPostWithCategory(tx, c.Query("category"))
}
if len(c.Query("tag")) > 0 {
mx = mx.FilterWithTag(c.Query("tag"))
tx = services.FilterPostWithTag(tx, c.Query("tag"))
}
if !c.QueryBool("reply", true) {
mx = mx.FilterReply(true)
tx = tx.Where("reply_id IS NULL")
}
count, err := mx.Count()
if err != nil {
var count int64
if err := tx.
Model(&models.Post{}).
Count(&count).Error; err != nil {
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
items, err := mx.List(take, offset)
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": items,
"data": posts,
})
}
func reactPost(c *fiber.Ctx) error {
func createPost(c *fiber.Ctx) error {
user := c.Locals("principal").(models.Account)
var data struct {
Symbol string `json:"symbol" form:"symbol" validate:"required"`
Attitude models.ReactionAttitude `json:"attitude" form:"attitude" validate:"required"`
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
}
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, "comment must belongs to a resource")
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, 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
}
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
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.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())
}
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))
}
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))
}
default:
return fiber.NewError(fiber.StatusBadRequest, "unsupported reaction")
}
}
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 {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
return c.SendStatus(fiber.StatusOK)
}

@ -5,7 +5,7 @@ import (
"strings"
"time"
"code.smartsheep.studio/hydrogen/interactive/pkg/views"
"code.smartsheep.studio/hydrogen/interactive/pkg/view"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/cache"
"github.com/gofiber/fiber/v2/middleware/cors"
@ -69,42 +69,21 @@ func NewServer() {
}), openAttachment)
api.Post("/attachments", authMiddleware, uploadAttachment)
api.Get("/feed", listFeed)
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)
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.Get("/categories", listCategroies)
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)
@ -120,7 +99,7 @@ func NewServer() {
Expiration: 24 * time.Hour,
CacheControl: true,
}), filesystem.New(filesystem.Config{
Root: http.FS(views.FS),
Root: http.FS(view.FS),
PathPrefix: "dist",
Index: "index.html",
NotFoundFile: "dist/index.html",

@ -1,13 +1,11 @@
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) {
@ -16,7 +14,7 @@ func NewAttachment(user models.Account, header *multipart.FileHeader) (models.At
Filesize: header.Size,
Filename: header.Filename,
Mimetype: "unknown/unknown",
Type: models.AttachmentOthers,
PostID: nil,
AuthorID: user.ID,
}
@ -35,17 +33,6 @@ 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,16 +7,12 @@ 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 {

@ -1,82 +0,0 @@
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 +0,0 @@
package services

@ -1,364 +1,350 @@
package services
import (
"code.smartsheep.studio/hydrogen/identity/pkg/grpc/proto"
"errors"
"fmt"
"time"
"code.smartsheep.studio/hydrogen/identity/pkg/grpc/proto"
"github.com/rs/zerolog/log"
"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"
)
type PostTypeContext struct {
Tx *gorm.DB
TableName string
ColumnName string
CanReply bool
CanRepost bool
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")
}
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 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) 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 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) FilterPublishedAt(date time.Time) *PostTypeContext {
v.Tx.Where("published_at <= ? AND published_at IS NULL", date)
return v
}
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
}
func (v *PostTypeContext) FilterAuthor(id uint) *PostTypeContext {
v.Tx = v.Tx.Where("author_id = ?", id)
return v
}
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
func GetPost(tx *gorm.DB) (*models.Post, error) {
var post *models.Post
if err := PreloadRelatedPost(tx).First(&post).Error; err != nil {
return post, 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
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"`
}
return item, nil
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) 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) {
func ListPost(tx *gorm.DB, take int, offset int) ([]*models.Post, error) {
if take > 20 {
take = 20
}
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
var posts []*models.Post
if err := PreloadRelatedPost(tx).
Limit(take).
Offset(offset).
Find(&posts).Error; err != nil {
return posts, err
}
idx := lo.Map(items, func(item *models.Feed, index int) uint {
postIds := lo.Map(posts, func(item *models.Post, _ int) uint {
return item.ID
})
if len(noReact) <= 0 || !noReact[0] {
var reactions []struct {
PostID uint
Symbol string
Count int64
}
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 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
}
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)
itemMap := lo.SliceToMap(items, func(item *models.Feed) (uint, *models.Feed) {
return item.ID, item
})
postMap := lo.SliceToMap(posts, func(item *models.Post) (uint, *models.Post) {
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
}
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
}
}
{
var attachments []struct {
models.Attachment
PostID uint `json:"post_id"`
}
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
return posts, nil
}
func MapCategoriesAndTags[T models.PostInterface](item T) (T, error) {
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) {
var err error
categories := item.GetCategories()
var post models.Post
for idx, category := range categories {
categories[idx], err = GetCategory(category.Alias)
if err != nil {
return item, err
return post, err
}
}
item.SetCategories(categories)
tags := item.GetHashtags()
for idx, tag := range tags {
tags[idx], err = GetTagOrCreate(tag.Alias, tag.Name)
if err != nil {
return item, err
return post, err
}
}
item.SetHashtags(tags)
return item, nil
}
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 realmId *uint
if realm != nil {
if !realm.IsPublic {
var member models.RealmMember
if err := database.C.Where(&models.RealmMember{
RealmID: item.GetRealm().ID,
AccountID: item.GetAuthor().ID,
RealmID: realm.ID,
AccountID: user.ID,
}).First(&member).Error; err != nil {
return item, fmt.Errorf("you aren't a part of that realm")
return post, fmt.Errorf("you aren't a part of that realm")
}
}
realmId = &realm.ID
}
if err := database.C.Save(&item).Error; err != nil {
return item, err
if publishedAt == nil {
publishedAt = lo.ToPtr(time.Now())
}
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", 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 {
log.Error().Err(err).Msg("An error occurred when notifying user...")
}
}
}
}()
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,
}
var subscribers []models.AccountMembership
if err := database.C.Where(&models.AccountMembership{
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
})
if err := database.C.Save(&post).Error; err != nil {
return post, err
}
for _, account := range accounts {
postUrl := fmt.Sprintf("https://%s/posts/%d", viper.GetString("domain"), item.GetID())
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)
err := NotifyAccount(
account,
fmt.Sprintf("%s just posted a post", item.GetAuthor().Name),
"Account you followed post a brand new post. Check it out!",
op.Author,
fmt.Sprintf("%s replied you", user.Name),
fmt.Sprintf("%s replied your post. Check it out!", user.Name),
&proto.NotifyLink{Label: "Related post", Url: postUrl},
)
if err != nil {
log.Error().Err(err).Msg("An error occurred when notifying user...")
}
}
}()
}
return item, nil
}
func EditPost[T models.PostInterface](item T) (T, error) {
item, err := MapCategoriesAndTags(item)
if err != nil {
return item, err
}
err = database.C.Save(&item).Error
return item, err
}
func DeletePost[T models.PostInterface](item T) error {
return database.C.Delete(&item).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 true, reaction, err
}
}
go func() {
var subscribers []models.AccountMembership
if err := database.C.Where(&models.AccountMembership{
FollowingID: user.ID,
}).Preload("Follower").Find(&subscribers).Error; err != nil {
return
}
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)
err := NotifyAccount(
account,
fmt.Sprintf("%s just posted a post", user.Name),
"Account you followed post a brand new post. Check it out!",
&proto.NotifyLink{Label: "Related post", Url: postUrl},
)
if err != nil {
log.Error().Err(err).Msg("An error occurred when notifying user...")
}
}
}()
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)
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
}
}
if publishedAt == nil {
publishedAt = lo.ToPtr(time.Now())
}
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 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
} else {
return false, reaction, database.C.Delete(&reaction).Error
return false, database.C.Delete(&like).Error
}
}
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
}
}
func DeletePost(post models.Post) error {
return database.C.Delete(&post).Error
}

7
pkg/view/.gitignore vendored Normal file

@ -0,0 +1,7 @@
/dist
/node_modules
.DS_Store
package-lock.json
yarn.lock

28
pkg/view/README.md Normal file

@ -0,0 +1,28 @@
## 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,4 +1,4 @@
package views
package view
import "embed"

27
pkg/view/embed.html Normal file

@ -0,0 +1,27 @@
<!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>

13
pkg/view/index.html Normal file

@ -0,0 +1,13 @@
<!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>

36
pkg/view/package.json Normal file

@ -0,0 +1,36 @@
{
"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"
}
}

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

File diff suppressed because one or more lines are too long

After

(image error) Size: 12 KiB

5
pkg/view/src/.prettierrc Normal file

@ -0,0 +1,5 @@
{
"printWidth": 120,
"tabWidth": 2,
"singleQuote": false
}

@ -0,0 +1,184 @@
: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.

@ -0,0 +1,22 @@
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>
);
}

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

@ -0,0 +1,3 @@
.description {
color: var(--fallback-bc, oklch(var(--bc)/.8));
}

@ -0,0 +1,98 @@
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>
);
}

@ -0,0 +1,3 @@
.attachmentsControl {
background-color: transparent !important;
}

@ -0,0 +1,158 @@
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>
</>
);
}

@ -0,0 +1,399 @@
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>
</>
);
}

@ -0,0 +1,223 @@
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>
);
}

@ -0,0 +1,215 @@
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>
);
}

@ -0,0 +1,3 @@
.paginationControl {
background-color: transparent !important;
}

@ -0,0 +1,96 @@
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>
);
}

@ -0,0 +1,8 @@
.publishInput {
outline-style: none !important;
outline-width: 0 !important;
}
.description {
color: var(--fallback-bc, oklch(var(--bc)/.8));
}

@ -0,0 +1,210 @@
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>
</>
);
}

39
pkg/view/src/index.css Normal file

@ -0,0 +1,39 @@
@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;
}
}

79
pkg/view/src/index.tsx Normal file

@ -0,0 +1,79 @@
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;
}
}

@ -0,0 +1,63 @@
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>
);
}

@ -0,0 +1,80 @@
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>
</>
);
}

@ -0,0 +1,110 @@
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")}
/>
</>
);
}

@ -0,0 +1,58 @@
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")}
/>
</>
);
}

@ -0,0 +1,121 @@
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>
</>
);
}

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

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

@ -0,0 +1,28 @@
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>
);
}

@ -0,0 +1,99 @@
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")}
/>
</>
);
}

161
pkg/view/src/pages/post.tsx Normal file

@ -0,0 +1,161 @@
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>
</>
);
}

@ -0,0 +1,115 @@
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>
</>
);
}

@ -0,0 +1,3 @@
.description {
color: var(--fallback-bc, oklch(var(--bc)/.8));
}

@ -0,0 +1,291 @@
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>
</>
);
}

@ -0,0 +1,124 @@
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")}
/>
</>
);
}

@ -0,0 +1,11 @@
.wrapper {
display: grid;
grid-template-columns: 1fr;
column-gap: 20px;
}
@media (min-width: 1024px) {
.wrapper {
grid-template-columns: 1fr 2fr 1fr;
}
}

@ -0,0 +1,28 @@
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>
);
}

@ -0,0 +1,7 @@
export function openModel(selector: string) {
document.querySelector<HTMLDialogElement>(selector)?.showModal()
}
export function closeModel(selector: string) {
document.querySelector<HTMLDialogElement>(selector)?.close()
}

@ -0,0 +1,4 @@
export async function request(input: string, init?: RequestInit) {
const prefix = window.__LAUNCHPAD_TARGET__ ?? "";
return await fetch(prefix + input, init)
}

@ -0,0 +1,73 @@
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);
}

@ -0,0 +1,24 @@
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);
}

@ -0,0 +1,44 @@
/** @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")]
};

Some files were not shown because too many files have changed in this diff Show More