♻️ Interactive v2 #1

Merged
LittleSheep merged 30 commits from refactor/v2 into master 2024-03-16 08:22:25 +00:00
144 changed files with 3494 additions and 4085 deletions

46
.air.toml Normal file
View File

@ -0,0 +1,46 @@
root = "."
testdata_dir = "testdata"
tmp_dir = "dist"
[build]
args_bin = []
bin = "./dist/server"
cmd = "go build -o ./dist/server ./pkg/cmd/main.go"
delay = 1000
exclude_dir = ["assets", "tmp", "vendor", "testdata", "pkg/views"]
exclude_file = []
exclude_regex = ["_test.go"]
exclude_unchanged = false
follow_symlink = false
full_bin = ""
include_dir = []
include_ext = ["go", "tpl", "tmpl", "html"]
include_file = []
kill_delay = "0s"
log = "build-errors.log"
poll = false
poll_interval = 0
post_cmd = []
pre_cmd = []
rerun = false
rerun_delay = 500
send_interrupt = false
stop_on_error = false
[color]
app = ""
build = "yellow"
main = "magenta"
runner = "green"
watcher = "cyan"
[log]
main_only = false
time = false
[misc]
clean_on_exit = false
[screen]
clear_on_rebuild = false
keep_scroll = true

View File

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

5
.gitignore vendored
View File

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

View File

@ -19,10 +19,6 @@
<option name="SPACES_WITHIN_OBJECT_LITERAL_BRACES" value="true" />
<option name="SPACES_WITHIN_IMPORTS" value="true" />
</TypeScriptCodeStyleSettings>
<VueCodeStyleSettings>
<option name="INTERPOLATION_NEW_LINE_AFTER_START_DELIMITER" value="false" />
<option name="INTERPOLATION_NEW_LINE_BEFORE_END_DELIMITER" value="false" />
</VueCodeStyleSettings>
<codeStyleSettings language="HTML">
<option name="SOFT_MARGINS" value="120" />
<indentOptions>

View File

@ -1,6 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="SqlDialectMappings">
<file url="file://$PROJECT_DIR$/pkg/server/posts_api.go" dialect="PostgreSQL" />
<file url="file://$PROJECT_DIR$/pkg/server/moments_api.go" dialect="PostgreSQL" />
<file url="file://$APPLICATION_CONFIG_DIR$/consoles/db/2e2101b2-4037-47ee-88ed-456dc2cb4423/console.sql" dialect="PostgreSQL" />
</component>
</project>

View File

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

1
go.mod
View File

@ -24,6 +24,7 @@ require (
github.com/andybalholm/brotli v1.0.5 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/gertd/go-pluralize v0.2.1 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-sql-driver/mysql v1.7.1 // indirect

2
go.sum
View File

@ -17,6 +17,8 @@ github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nos
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
github.com/gertd/go-pluralize v0.2.1 h1:M3uASbVjMnTsPb0PNqg+E/24Vwigyo/tvyMTtAlLgiA=
github.com/gertd/go-pluralize v0.2.1/go.mod h1:rbYaKDbsXxmRfr8uygAEKhOWsjyrrqrkHVpZvoOp8zk=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=

View File

@ -13,9 +13,10 @@ func RunMigration(source *gorm.DB) error {
&models.RealmMember{},
&models.Category{},
&models.Tag{},
&models.Post{},
&models.PostLike{},
&models.PostDislike{},
&models.Moment{},
&models.Article{},
&models.Comment{},
&models.Reaction{},
&models.Attachment{},
); err != nil {
return err

View File

@ -14,10 +14,10 @@ type Account struct {
Description string `json:"description"`
EmailAddress string `json:"email_address"`
PowerLevel int `json:"power_level"`
Posts []Post `json:"posts" gorm:"foreignKey:AuthorID"`
Moments []Moment `json:"moments" gorm:"foreignKey:AuthorID"`
Articles []Article `json:"articles" gorm:"foreignKey:AuthorID"`
Attachments []Attachment `json:"attachments" gorm:"foreignKey:AuthorID"`
LikedPosts []PostLike `json:"liked_posts"`
DislikedPosts []PostDislike `json:"disliked_posts"`
Reactions []Reaction `json:"reactions"`
RealmIdentities []RealmMember `json:"identities"`
Realms []Realm `json:"realms"`
ExternalID uint `json:"external_id"`

41
pkg/models/articles.go Normal file
View File

@ -0,0 +1,41 @@
package models
type Article struct {
PostBase
Title string `json:"title"`
Hashtags []Tag `json:"tags" gorm:"many2many:article_tags"`
Categories []Category `json:"categories" gorm:"many2many:article_categories"`
Reactions []Reaction `json:"reactions"`
Attachments []Attachment `json:"attachments"`
Description string `json:"description"`
Content string `json:"content"`
RealmID *uint `json:"realm_id"`
Realm *Realm `json:"realm"`
Comments []Comment `json:"comments" gorm:"foreignKey:ArticleID"`
}
func (p *Article) GetReplyTo() PostInterface {
return nil
}
func (p *Article) GetRepostTo() PostInterface {
return nil
}
func (p *Article) GetHashtags() []Tag {
return p.Hashtags
}
func (p *Article) GetCategories() []Category {
return p.Categories
}
func (p *Article) SetHashtags(tags []Tag) {
p.Hashtags = tags
}
func (p *Article) SetCategories(categories []Category) {
p.Categories = categories
}

View File

@ -2,22 +2,34 @@ package models
import (
"fmt"
"github.com/spf13/viper"
"path/filepath"
"github.com/spf13/viper"
)
type AttachmentType = uint8
const (
AttachmentOthers = AttachmentType(iota)
AttachmentPhoto
AttachmentVideo
AttachmentAudio
)
type Attachment struct {
BaseModel
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"`
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"`
}
func (v Attachment) GetStoragePath() string {

View File

@ -3,17 +3,21 @@ 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"`
Posts []Post `json:"posts" gorm:"many2many:post_tags"`
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"`
}
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"`
Posts []Post `json:"categories" gorm:"many2many:post_categories"`
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"`
}

37
pkg/models/comments.go Normal file
View File

@ -0,0 +1,37 @@
package models
type Comment struct {
PostBase
Content string `json:"content"`
Hashtags []Tag `json:"tags" gorm:"many2many:comment_tags"`
Categories []Category `json:"categories" gorm:"many2many:comment_categories"`
Reactions []Reaction `json:"reactions"`
ReplyID *uint `json:"reply_id"`
ReplyTo *Comment `json:"reply_to" gorm:"foreignKey:ReplyID"`
ArticleID *uint `json:"article_id"`
MomentID *uint `json:"moment_id"`
Article *Article `json:"article"`
Moment *Moment `json:"moment"`
}
func (p *Comment) GetReplyTo() PostInterface {
return p.ReplyTo
}
func (p *Comment) GetHashtags() []Tag {
return p.Hashtags
}
func (p *Comment) GetCategories() []Category {
return p.Categories
}
func (p *Comment) SetHashtags(tags []Tag) {
p.Hashtags = tags
}
func (p *Comment) SetCategories(categories []Category) {
p.Categories = categories
}

22
pkg/models/feed.go Normal file
View File

@ -0,0 +1,22 @@
package models
type Feed struct {
BaseModel
Alias string `json:"alias"`
Title string `json:"title"`
Description string `json:"description"`
Content string `json:"content"`
ModelType string `json:"model_type"`
CommentCount int64 `json:"comment_count"`
ReactionCount int64 `json:"reaction_count"`
AuthorID uint `json:"author_id"`
RealmID *uint `json:"realm_id"`
Author Account `json:"author" gorm:"embedded"`
Attachments []Attachment `json:"attachments" gorm:"-"`
ReactionList map[string]int64 `json:"reaction_list" gorm:"-"`
}

41
pkg/models/moments.go Normal file
View File

@ -0,0 +1,41 @@
package models
type Moment struct {
PostBase
Content string `json:"content"`
Hashtags []Tag `json:"tags" gorm:"many2many:moment_tags"`
Categories []Category `json:"categories" gorm:"many2many:moment_categories"`
Reactions []Reaction `json:"reactions"`
Attachments []Attachment `json:"attachments"`
RealmID *uint `json:"realm_id"`
RepostID *uint `json:"repost_id"`
Realm *Realm `json:"realm"`
RepostTo *Moment `json:"repost_to" gorm:"foreignKey:RepostID"`
Comments []Comment `json:"comments" gorm:"foreignKey:MomentID"`
}
func (p *Moment) GetRepostTo() PostInterface {
return p.RepostTo
}
func (p *Moment) GetRealm() *Realm {
return p.Realm
}
func (p *Moment) GetHashtags() []Tag {
return p.Hashtags
}
func (p *Moment) GetCategories() []Category {
return p.Categories
}
func (p *Moment) SetHashtags(tags []Tag) {
p.Hashtags = tags
}
func (p *Moment) SetCategories(categories []Category) {
p.Categories = categories
}

View File

@ -1,32 +1,64 @@
package models
import "time"
import (
"time"
)
type Post struct {
type PostReactInfo struct {
PostID uint `json:"post_id"`
LikeCount int64 `json:"like_count"`
DislikeCount int64 `json:"dislike_count"`
ReplyCount int64 `json:"reply_count"`
RepostCount int64 `json:"repost_count"`
}
type PostBase struct {
BaseModel
Alias string `json:"alias" gorm:"uniqueIndex"`
Title string `json:"title"`
Content string `json:"content"`
Tags []Tag `json:"tags" gorm:"many2many:post_tags"`
Categories []Category `json:"categories" gorm:"many2many:post_categories"`
Attachments []Attachment `json:"attachments"`
LikedAccounts []PostLike `json:"liked_accounts"`
DislikedAccounts []PostDislike `json:"disliked_accounts"`
RepostTo *Post `json:"repost_to" gorm:"foreignKey:RepostID"`
ReplyTo *Post `json:"reply_to" gorm:"foreignKey:ReplyID"`
PinnedAt *time.Time `json:"pinned_at"`
EditedAt *time.Time `json:"edited_at"`
PublishedAt time.Time `json:"published_at"`
RepostID *uint `json:"repost_id"`
ReplyID *uint `json:"reply_id"`
RealmID *uint `json:"realm_id"`
AuthorID uint `json:"author_id"`
Author Account `json:"author"`
Alias string `json:"alias" gorm:"uniqueIndex"`
PublishedAt *time.Time `json:"published_at"`
// 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:"-"`
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)
}

View File

@ -1,19 +1,27 @@
package models
import "time"
import (
"time"
)
type PostLike struct {
type ReactionAttitude = uint8
const (
AttitudeNeutral = ReactionAttitude(iota)
AttitudePositive
AttitudeNegative
)
type Reaction struct {
ID uint `json:"id" gorm:"primaryKey"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
PostID uint `json:"post_id"`
AccountID uint `json:"account_id"`
}
type PostDislike struct {
ID uint `json:"id" gorm:"primaryKey"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
PostID uint `json:"post_id"`
AccountID uint `json:"account_id"`
Symbol string `json:"symbol"`
Attitude ReactionAttitude `json:"attitude"`
ArticleID *uint `json:"article_id"`
MomentID *uint `json:"moment_id"`
CommentID *uint `json:"comment_id"`
AccountID uint `json:"account_id"`
}

View File

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

140
pkg/server/articles_api.go Normal file
View File

@ -0,0 +1,140 @@
package server
import (
"strings"
"time"
"code.smartsheep.studio/hydrogen/interactive/pkg/database"
"code.smartsheep.studio/hydrogen/interactive/pkg/models"
"code.smartsheep.studio/hydrogen/interactive/pkg/services"
"github.com/gofiber/fiber/v2"
"github.com/google/uuid"
)
func contextArticle() *services.PostTypeContext {
return &services.PostTypeContext{
Tx: database.C,
TableName: "articles",
ColumnName: "article",
CanReply: false,
CanRepost: false,
}
}
func createArticle(c *fiber.Ctx) error {
user := c.Locals("principal").(models.Account)
var data struct {
Alias string `json:"alias" form:"alias"`
Title string `json:"title" form:"title" validate:"required"`
Description string `json:"description" form:"description"`
Content string `json:"content" form:"content" validate:"required"`
Hashtags []models.Tag `json:"hashtags" form:"hashtags"`
Categories []models.Category `json:"categories" form:"categories"`
Attachments []models.Attachment `json:"attachments" form:"attachments"`
PublishedAt *time.Time `json:"published_at" form:"published_at"`
RealmID *uint `json:"realm_id" form:"realm_id"`
}
if err := BindAndValidate(c, &data); err != nil {
return err
} else if len(data.Alias) == 0 {
data.Alias = strings.ReplaceAll(uuid.NewString(), "-", "")
}
item := &models.Article{
PostBase: models.PostBase{
Alias: data.Alias,
PublishedAt: data.PublishedAt,
AuthorID: user.ID,
},
Hashtags: data.Hashtags,
Categories: data.Categories,
Attachments: data.Attachments,
Title: data.Title,
Description: data.Description,
Content: data.Content,
RealmID: data.RealmID,
}
var realm *models.Realm
if data.RealmID != nil {
if err := database.C.Where(&models.Realm{
BaseModel: models.BaseModel{ID: *data.RealmID},
}).First(&realm).Error; err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
}
if item, err := services.NewPost(item); err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
} else {
return c.JSON(item)
}
}
func editArticle(c *fiber.Ctx) error {
user := c.Locals("principal").(models.Account)
id, _ := c.ParamsInt("articleId", 0)
var data struct {
Alias string `json:"alias" form:"alias" validate:"required"`
Title string `json:"title" form:"title" validate:"required"`
Description string `json:"description" form:"description"`
Content string `json:"content" form:"content" validate:"required"`
PublishedAt *time.Time `json:"published_at" form:"published_at"`
Hashtags []models.Tag `json:"hashtags" form:"hashtags"`
Categories []models.Category `json:"categories" form:"categories"`
Attachments []models.Attachment `json:"attachments" form:"attachments"`
}
if err := BindAndValidate(c, &data); err != nil {
return err
}
var item *models.Article
if err := database.C.Where(models.Article{
PostBase: models.PostBase{
BaseModel: models.BaseModel{ID: uint(id)},
AuthorID: user.ID,
},
}).First(&item).Error; err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
}
item.Alias = data.Alias
item.Title = data.Title
item.Description = data.Description
item.Content = data.Content
item.PublishedAt = data.PublishedAt
item.Hashtags = data.Hashtags
item.Categories = data.Categories
item.Attachments = data.Attachments
if item, err := services.EditPost(item); err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
} else {
return c.JSON(item)
}
}
func deleteArticle(c *fiber.Ctx) error {
user := c.Locals("principal").(models.Account)
id, _ := c.ParamsInt("articleId", 0)
var item *models.Article
if err := database.C.Where(models.Article{
PostBase: models.PostBase{
BaseModel: models.BaseModel{ID: uint(id)},
AuthorID: user.ID,
},
}).First(&item).Error; err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
}
if err := services.DeletePost(item); err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
return c.SendStatus(fiber.StatusOK)
}

View File

@ -6,7 +6,7 @@ import (
"github.com/gofiber/fiber/v2"
)
func listCategroies(c *fiber.Ctx) error {
func listCategories(c *fiber.Ctx) error {
categories, err := services.ListCategory()
if err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())

187
pkg/server/comments_api.go Normal file
View File

@ -0,0 +1,187 @@
package server
import (
"fmt"
"strings"
"time"
"code.smartsheep.studio/hydrogen/interactive/pkg/database"
"code.smartsheep.studio/hydrogen/interactive/pkg/models"
"code.smartsheep.studio/hydrogen/interactive/pkg/services"
"github.com/gofiber/fiber/v2"
"github.com/google/uuid"
)
func contextComment() *services.PostTypeContext {
return &services.PostTypeContext{
Tx: database.C,
TableName: "comments",
ColumnName: "comment",
CanReply: false,
CanRepost: true,
}
}
func listComment(c *fiber.Ctx) error {
take := c.QueryInt("take", 0)
offset := c.QueryInt("offset", 0)
alias := c.Params("postId")
mx := c.Locals(postContextKey).(*services.PostTypeContext).
FilterPublishedAt(time.Now())
item, err := mx.GetViaAlias(alias)
if err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
}
data, err := mx.ListComment(item.ID, take, offset)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
count, err := mx.CountComment(item.ID)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
return c.JSON(fiber.Map{
"count": count,
"data": data,
})
}
func createComment(c *fiber.Ctx) error {
user := c.Locals("principal").(models.Account)
var data struct {
Alias string `json:"alias" form:"alias"`
Content string `json:"content" form:"content" validate:"required"`
PublishedAt *time.Time `json:"published_at" form:"published_at"`
Hashtags []models.Tag `json:"hashtags" form:"hashtags"`
Categories []models.Category `json:"categories" form:"categories"`
ReplyTo uint `json:"reply_to" form:"reply_to"`
}
if err := BindAndValidate(c, &data); err != nil {
return err
} else if len(data.Alias) == 0 {
data.Alias = strings.ReplaceAll(uuid.NewString(), "-", "")
}
item := &models.Comment{
PostBase: models.PostBase{
Alias: data.Alias,
PublishedAt: data.PublishedAt,
AuthorID: user.ID,
},
Hashtags: data.Hashtags,
Categories: data.Categories,
Content: data.Content,
}
postType := c.Params("postType")
alias := c.Params("postId")
var err error
var res models.Feed
switch postType {
case "moments":
err = database.C.Model(&models.Moment{}).Where("alias = ?", alias).Select("id").First(&res).Error
case "articles":
err = database.C.Model(&models.Article{}).Where("alias = ?", alias).Select("id").First(&res).Error
default:
return fiber.NewError(fiber.StatusBadRequest, "comment must belongs to a resource")
}
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("belongs to resource was not found: %v", err))
} else {
switch postType {
case "moments":
item.MomentID = &res.ID
case "articles":
item.ArticleID = &res.ID
}
}
var relatedCount int64
if data.ReplyTo > 0 {
if err := database.C.Where("id = ?", data.ReplyTo).
Model(&models.Comment{}).Count(&relatedCount).Error; err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
} else if relatedCount <= 0 {
return fiber.NewError(fiber.StatusNotFound, "related post was not found")
} else {
item.ReplyID = &data.ReplyTo
}
}
if item, err := services.NewPost(item); err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
} else {
return c.JSON(item)
}
}
func editComment(c *fiber.Ctx) error {
user := c.Locals("principal").(models.Account)
id, _ := c.ParamsInt("commentId", 0)
var data struct {
Alias string `json:"alias" form:"alias" validate:"required"`
Content string `json:"content" form:"content" validate:"required"`
PublishedAt *time.Time `json:"published_at" form:"published_at"`
Hashtags []models.Tag `json:"hashtags" form:"hashtags"`
Categories []models.Category `json:"categories" form:"categories"`
}
if err := BindAndValidate(c, &data); err != nil {
return err
}
var item *models.Comment
if err := database.C.Where(models.Comment{
PostBase: models.PostBase{
BaseModel: models.BaseModel{ID: uint(id)},
AuthorID: user.ID,
},
}).First(&item).Error; err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
}
item.Alias = data.Alias
item.Content = data.Content
item.PublishedAt = data.PublishedAt
item.Hashtags = data.Hashtags
item.Categories = data.Categories
if item, err := services.EditPost(item); err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
} else {
return c.JSON(item)
}
}
func deleteComment(c *fiber.Ctx) error {
user := c.Locals("principal").(models.Account)
id, _ := c.ParamsInt("commentId", 0)
var item *models.Comment
if err := database.C.Where(models.Comment{
PostBase: models.PostBase{
BaseModel: models.BaseModel{ID: uint(id)},
AuthorID: user.ID,
},
}).First(&item).Error; err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
}
if err := services.DeletePost(item); err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
return c.SendStatus(fiber.StatusOK)
}

View File

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

191
pkg/server/feed_api.go Normal file
View File

@ -0,0 +1,191 @@
package server
import (
"fmt"
"code.smartsheep.studio/hydrogen/interactive/pkg/database"
"code.smartsheep.studio/hydrogen/interactive/pkg/models"
"github.com/gofiber/fiber/v2"
"github.com/samber/lo"
"github.com/spf13/viper"
)
const (
queryArticle = "id, created_at, updated_at, alias, title, NULL as content, description, realm_id, author_id, 'article' as model_type"
queryMoment = "id, created_at, updated_at, alias, NULL as title, content, NULL as description, realm_id, author_id, 'moment' as model_type"
)
func listFeed(c *fiber.Ctx) error {
take := c.QueryInt("take", 0)
offset := c.QueryInt("offset", 0)
realmId := c.QueryInt("realmId", 0)
if take > 20 {
take = 20
}
var whereCondition string
if realmId > 0 {
whereCondition += fmt.Sprintf("feed.realm_id = %d", realmId)
} else {
whereCondition += "feed.realm_id IS NULL"
}
var author models.Account
if len(c.Query("authorId")) > 0 {
if err := database.C.Where(&models.Account{Name: c.Query("authorId")}).First(&author).Error; err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
} else {
whereCondition += fmt.Sprintf("AND feed.author_id = %d", author.ID)
}
}
var result []*models.Feed
userTable := viper.GetString("database.prefix") + "accounts"
commentTable := viper.GetString("database.prefix") + "comments"
reactionTable := viper.GetString("database.prefix") + "reactions"
database.C.Raw(
fmt.Sprintf(`SELECT feed.*, author.*,
COALESCE(comment_count, 0) AS comment_count,
COALESCE(reaction_count, 0) AS reaction_count
FROM (? UNION ALL ?) AS feed
INNER JOIN %s AS author ON author_id = author.id
LEFT JOIN (SELECT article_id, moment_id, COUNT(*) AS comment_count
FROM %s
GROUP BY article_id, moment_id) AS comments
ON (feed.model_type = 'article' AND feed.id = comments.article_id) OR
(feed.model_type = 'moment' AND feed.id = comments.moment_id)
LEFT JOIN (SELECT article_id, moment_id, COUNT(*) AS reaction_count
FROM %s
GROUP BY article_id, moment_id) AS reactions
ON (feed.model_type = 'article' AND feed.id = reactions.article_id) OR
(feed.model_type = 'moment' AND feed.id = reactions.moment_id)
WHERE %s ORDER BY feed.created_at desc LIMIT ? OFFSET ?`,
userTable,
commentTable,
reactionTable,
whereCondition,
),
database.C.Select(queryArticle).Model(&models.Article{}),
database.C.Select(queryMoment).Model(&models.Moment{}),
take,
offset,
).Scan(&result)
if !c.QueryBool("noReact", false) {
var reactions []struct {
PostID uint
Symbol string
Count int64
}
revertReaction := func(dataset string) error {
itemMap := lo.SliceToMap(lo.FilterMap(result, func(item *models.Feed, index int) (*models.Feed, bool) {
return item, item.ModelType == dataset
}), func(item *models.Feed) (uint, *models.Feed) {
return item.ID, item
})
idx := lo.Map(lo.Filter(result, func(item *models.Feed, index int) bool {
return item.ModelType == dataset
}), func(item *models.Feed, index int) uint {
return item.ID
})
if err := database.C.Model(&models.Reaction{}).
Select(dataset+"_id as post_id, symbol, COUNT(id) as count").
Where(dataset+"_id IN (?)", idx).
Group("post_id, symbol").
Scan(&reactions).Error; err != nil {
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
list := map[uint]map[string]int64{}
for _, info := range reactions {
if _, ok := list[info.PostID]; !ok {
list[info.PostID] = make(map[string]int64)
}
list[info.PostID][info.Symbol] = info.Count
}
for k, v := range list {
if post, ok := itemMap[k]; ok {
post.ReactionList = v
}
}
return nil
}
if err := revertReaction("article"); err != nil {
return err
}
if err := revertReaction("moment"); err != nil {
return err
}
}
if !c.QueryBool("noAttachment", false) {
revertAttachment := func(dataset string) error {
var attachments []struct {
models.Attachment
PostID uint `json:"post_id"`
}
itemMap := lo.SliceToMap(lo.FilterMap(result, func(item *models.Feed, index int) (*models.Feed, bool) {
return item, item.ModelType == dataset
}), func(item *models.Feed) (uint, *models.Feed) {
return item.ID, item
})
idx := lo.Map(lo.Filter(result, func(item *models.Feed, index int) bool {
return item.ModelType == dataset
}), func(item *models.Feed, index int) uint {
return item.ID
})
if err := database.C.
Model(&models.Attachment{}).
Select(dataset+"_id as post_id, *").
Where(dataset+"_id IN (?)", idx).
Scan(&attachments).Error; err != nil {
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
list := map[uint][]models.Attachment{}
for _, info := range attachments {
list[info.PostID] = append(list[info.PostID], info.Attachment)
}
for k, v := range list {
if post, ok := itemMap[k]; ok {
post.Attachments = v
}
}
return nil
}
if err := revertAttachment("article"); err != nil {
return err
}
if err := revertAttachment("moment"); err != nil {
return err
}
}
var count int64
database.C.Raw(`SELECT COUNT(*) FROM (? UNION ALL ?) as feed`,
database.C.Select(queryArticle).Model(&models.Article{}),
database.C.Select(queryMoment).Model(&models.Moment{}),
).Scan(&count)
return c.JSON(fiber.Map{
"count": count,
"data": result,
})
}

146
pkg/server/moments_api.go Normal file
View File

@ -0,0 +1,146 @@
package server
import (
"strings"
"time"
"code.smartsheep.studio/hydrogen/interactive/pkg/database"
"code.smartsheep.studio/hydrogen/interactive/pkg/models"
"code.smartsheep.studio/hydrogen/interactive/pkg/services"
"github.com/gofiber/fiber/v2"
"github.com/google/uuid"
)
func contextMoment() *services.PostTypeContext {
return &services.PostTypeContext{
Tx: database.C,
TableName: "moments",
ColumnName: "moment",
CanReply: false,
CanRepost: true,
}
}
func createMoment(c *fiber.Ctx) error {
user := c.Locals("principal").(models.Account)
var data struct {
Alias string `json:"alias" form:"alias"`
Content string `json:"content" form:"content" validate:"required,max=1024"`
Hashtags []models.Tag `json:"hashtags" form:"hashtags"`
Categories []models.Category `json:"categories" form:"categories"`
Attachments []models.Attachment `json:"attachments" form:"attachments"`
PublishedAt *time.Time `json:"published_at" form:"published_at"`
RealmID *uint `json:"realm_id" form:"realm_id"`
RepostTo uint `json:"repost_to" form:"repost_to"`
}
if err := BindAndValidate(c, &data); err != nil {
return err
} else if len(data.Alias) == 0 {
data.Alias = strings.ReplaceAll(uuid.NewString(), "-", "")
}
item := &models.Moment{
PostBase: models.PostBase{
Alias: data.Alias,
PublishedAt: data.PublishedAt,
AuthorID: user.ID,
},
Hashtags: data.Hashtags,
Categories: data.Categories,
Attachments: data.Attachments,
Content: data.Content,
RealmID: data.RealmID,
}
var relatedCount int64
if data.RepostTo > 0 {
if err := database.C.Where("id = ?", data.RepostTo).
Model(&models.Moment{}).Count(&relatedCount).Error; err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
} else if relatedCount <= 0 {
return fiber.NewError(fiber.StatusNotFound, "related post was not found")
} else {
item.RepostID = &data.RepostTo
}
}
var realm *models.Realm
if data.RealmID != nil {
if err := database.C.Where(&models.Realm{
BaseModel: models.BaseModel{ID: *data.RealmID},
}).First(&realm).Error; err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
}
item, err := services.NewPost(item)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
return c.JSON(item)
}
func editMoment(c *fiber.Ctx) error {
user := c.Locals("principal").(models.Account)
id, _ := c.ParamsInt("momentId", 0)
var data struct {
Alias string `json:"alias" form:"alias" validate:"required"`
Content string `json:"content" form:"content" validate:"required,max=1024"`
PublishedAt *time.Time `json:"published_at" form:"published_at"`
Hashtags []models.Tag `json:"hashtags" form:"hashtags"`
Categories []models.Category `json:"categories" form:"categories"`
Attachments []models.Attachment `json:"attachments" form:"attachments"`
}
if err := BindAndValidate(c, &data); err != nil {
return err
}
var item *models.Moment
if err := database.C.Where(models.Comment{
PostBase: models.PostBase{
BaseModel: models.BaseModel{ID: uint(id)},
AuthorID: user.ID,
},
}).First(&item).Error; err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
}
item.Alias = data.Alias
item.Content = data.Content
item.PublishedAt = data.PublishedAt
item.Hashtags = data.Hashtags
item.Categories = data.Categories
item.Attachments = data.Attachments
if item, err := services.EditPost(item); err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
} else {
return c.JSON(item)
}
}
func deleteMoment(c *fiber.Ctx) error {
user := c.Locals("principal").(models.Account)
id, _ := c.ParamsInt("momentId", 0)
var item *models.Moment
if err := database.C.Where(models.Comment{
PostBase: models.PostBase{
BaseModel: models.BaseModel{ID: uint(id)},
AuthorID: user.ID,
},
}).First(&item).Error; err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
}
if err := services.DeletePost(item); err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
return c.SendStatus(fiber.StatusOK)
}

View File

@ -1,271 +1,151 @@
package server
import (
"strings"
"fmt"
"time"
"code.smartsheep.studio/hydrogen/interactive/pkg/database"
"code.smartsheep.studio/hydrogen/interactive/pkg/models"
"code.smartsheep.studio/hydrogen/interactive/pkg/services"
"github.com/gofiber/fiber/v2"
"github.com/google/uuid"
"github.com/samber/lo"
)
var postContextKey = "ptx"
func useDynamicContext(c *fiber.Ctx) error {
postType := c.Params("postType")
switch postType {
case "articles":
c.Locals(postContextKey, contextArticle())
case "moments":
c.Locals(postContextKey, contextMoment())
case "comments":
c.Locals(postContextKey, contextComment())
default:
return fiber.NewError(fiber.StatusBadRequest, "invalid dataset")
}
return c.Next()
}
func getPost(c *fiber.Ctx) error {
id := c.Params("postId")
take := c.QueryInt("take", 0)
offset := c.QueryInt("offset", 0)
alias := c.Params("postId")
tx := database.C.Where(&models.Post{
Alias: id,
}).Where("published_at <= ? OR published_at IS NULL", time.Now())
mx := c.Locals(postContextKey).(*services.PostTypeContext).
FilterPublishedAt(time.Now())
post, err := services.GetPost(tx)
item, err := mx.GetViaAlias(alias)
if err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
}
tx = database.C.
Where(&models.Post{ReplyID: &post.ID}).
Where("published_at <= ? OR published_at IS NULL", time.Now()).
Order("created_at desc")
var count int64
if err := tx.
Model(&models.Post{}).
Count(&count).Error; err != nil {
item.ReactionList, err = mx.CountReactions(item.ID)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
posts, err := services.ListPost(tx, take, offset)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
return c.JSON(fiber.Map{
"data": post,
"count": count,
"related": posts,
})
return c.JSON(item)
}
func listPost(c *fiber.Ctx) error {
take := c.QueryInt("take", 0)
offset := c.QueryInt("offset", 0)
realmId := c.QueryInt("realmId", 0)
tx := database.C.
Where("published_at <= ? OR published_at IS NULL", time.Now()).
Order("created_at desc")
if realmId > 0 {
tx = tx.Where(&models.Post{RealmID: lo.ToPtr(uint(realmId))})
} else {
tx = tx.Where("realm_id IS NULL")
}
mx := c.Locals(postContextKey).(*services.PostTypeContext).
FilterPublishedAt(time.Now()).
FilterRealm(uint(realmId)).
SortCreatedAt("desc")
var author models.Account
if len(c.Query("authorId")) > 0 {
if err := database.C.Where(&models.Account{Name: c.Query("authorId")}).First(&author).Error; err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
}
tx = tx.Where(&models.Post{AuthorID: author.ID})
mx = mx.FilterAuthor(author.ID)
}
if len(c.Query("category")) > 0 {
tx = services.FilterPostWithCategory(tx, c.Query("category"))
mx = mx.FilterWithCategory(c.Query("category"))
}
if len(c.Query("tag")) > 0 {
tx = services.FilterPostWithTag(tx, c.Query("tag"))
mx = mx.FilterWithTag(c.Query("tag"))
}
if !c.QueryBool("reply", true) {
tx = tx.Where("reply_id IS NULL")
mx = mx.FilterReply(true)
}
var count int64
if err := tx.
Model(&models.Post{}).
Count(&count).Error; err != nil {
count, err := mx.Count()
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
posts, err := services.ListPost(tx, take, offset)
items, err := mx.List(take, offset)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
return c.JSON(fiber.Map{
"count": count,
"data": posts,
"data": items,
})
}
func createPost(c *fiber.Ctx) error {
user := c.Locals("principal").(models.Account)
var data struct {
Alias string `json:"alias"`
Title string `json:"title"`
Content string `json:"content" validate:"required"`
Tags []models.Tag `json:"tags"`
Categories []models.Category `json:"categories"`
Attachments []models.Attachment `json:"attachments"`
PublishedAt *time.Time `json:"published_at"`
RealmID *uint `json:"realm_id"`
RepostTo uint `json:"repost_to"`
ReplyTo uint `json:"reply_to"`
}
if err := BindAndValidate(c, &data); err != nil {
return err
} else if len(data.Alias) == 0 {
data.Alias = strings.ReplaceAll(uuid.NewString(), "-", "")
}
var repostTo *uint = nil
var replyTo *uint = nil
var relatedCount int64
if data.RepostTo > 0 {
if err := database.C.Where(&models.Post{
BaseModel: models.BaseModel{ID: data.RepostTo},
}).Model(&models.Post{}).Count(&relatedCount).Error; err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
} else if relatedCount <= 0 {
return fiber.NewError(fiber.StatusNotFound, "related post was not found")
} else {
repostTo = &data.RepostTo
}
} else if data.ReplyTo > 0 {
if err := database.C.Where(&models.Post{
BaseModel: models.BaseModel{ID: data.ReplyTo},
}).Model(&models.Post{}).Count(&relatedCount).Error; err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
} else if relatedCount <= 0 {
return fiber.NewError(fiber.StatusNotFound, "related post was not found")
} else {
replyTo = &data.ReplyTo
}
}
var realm *models.Realm
if data.RealmID != nil {
if err := database.C.Where(&models.Realm{
BaseModel: models.BaseModel{ID: *data.RealmID},
}).First(&realm).Error; err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
}
post, err := services.NewPost(
user,
realm,
data.Alias,
data.Title,
data.Content,
data.Attachments,
data.Categories,
data.Tags,
data.PublishedAt,
replyTo,
repostTo,
)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
return c.JSON(post)
}
func editPost(c *fiber.Ctx) error {
user := c.Locals("principal").(models.Account)
id, _ := c.ParamsInt("postId", 0)
var data struct {
Alias string `json:"alias" validate:"required"`
Title string `json:"title"`
Content string `json:"content" validate:"required"`
PublishedAt *time.Time `json:"published_at"`
Tags []models.Tag `json:"tags"`
Categories []models.Category `json:"categories"`
Attachments []models.Attachment `json:"attachments"`
}
if err := BindAndValidate(c, &data); err != nil {
return err
}
var post models.Post
if err := database.C.Where(&models.Post{
BaseModel: models.BaseModel{ID: uint(id)},
AuthorID: user.ID,
}).First(&post).Error; err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
}
post, err := services.EditPost(
post,
data.Alias,
data.Title,
data.Content,
data.PublishedAt,
data.Categories,
data.Tags,
data.Attachments,
)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
return c.JSON(post)
}
func reactPost(c *fiber.Ctx) error {
user := c.Locals("principal").(models.Account)
id, _ := c.ParamsInt("postId", 0)
var post models.Post
if err := database.C.Where(&models.Post{
BaseModel: models.BaseModel{ID: uint(id)},
}).First(&post).Error; err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
var data struct {
Symbol string `json:"symbol" form:"symbol" validate:"required"`
Attitude models.ReactionAttitude `json:"attitude" form:"attitude" validate:"required"`
}
switch strings.ToLower(c.Params("reactType")) {
case "like":
if positive, err := services.LikePost(user, post); err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
} else {
return c.SendStatus(lo.Ternary(positive, fiber.StatusCreated, fiber.StatusNoContent))
}
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))
}
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, "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())
return fiber.NewError(fiber.StatusBadRequest, "comment must belongs to a resource")
}
if err := services.DeletePost(post); err != nil {
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
}
}
if positive, reaction, err := mx.React(reaction); err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
} else {
return c.Status(lo.Ternary(positive, fiber.StatusCreated, fiber.StatusNoContent)).JSON(reaction)
}
return c.SendStatus(fiber.StatusOK)
}

View File

@ -5,7 +5,7 @@ import (
"strings"
"time"
"code.smartsheep.studio/hydrogen/interactive/pkg/view"
"code.smartsheep.studio/hydrogen/interactive/pkg/views"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/cache"
"github.com/gofiber/fiber/v2/middleware/cors"
@ -69,21 +69,42 @@ func NewServer() {
}), openAttachment)
api.Post("/attachments", authMiddleware, uploadAttachment)
api.Get("/posts", listPost)
api.Get("/posts/:postId", getPost)
api.Post("/posts", authMiddleware, createPost)
api.Post("/posts/:postId/react/:reactType", authMiddleware, reactPost)
api.Put("/posts/:postId", authMiddleware, editPost)
api.Delete("/posts/:postId", authMiddleware, deletePost)
api.Get("/feed", listFeed)
api.Get("/categories", listCategroies)
posts := api.Group("/p/:postType").Use(useDynamicContext).Name("Dataset Universal API")
{
posts.Get("/", listPost)
posts.Get("/:postId", getPost)
posts.Post("/:postId/react", authMiddleware, reactPost)
posts.Get("/:postId/comments", listComment)
posts.Post("/:postId/comments", authMiddleware, createComment)
}
moments := api.Group("/p/moments").Name("Moments API")
{
moments.Post("/", authMiddleware, createMoment)
moments.Put("/:momentId", authMiddleware, editMoment)
moments.Delete("/:momentId", authMiddleware, deleteMoment)
}
articles := api.Group("/p/articles").Name("Articles API")
{
articles.Post("/", authMiddleware, createArticle)
articles.Put("/:articleId", authMiddleware, editArticle)
articles.Delete("/:articleId", authMiddleware, deleteArticle)
}
comments := api.Group("/p/comments").Name("Comments API")
{
comments.Put("/:commentId", authMiddleware, editComment)
comments.Delete("/:commentId", authMiddleware, deleteComment)
}
api.Get("/categories", listCategories)
api.Post("/categories", authMiddleware, newCategory)
api.Put("/categories/:categoryId", authMiddleware, editCategory)
api.Delete("/categories/:categoryId", authMiddleware, deleteCategory)
api.Get("/creators/posts", authMiddleware, listOwnPost)
api.Get("/creators/posts/:postId", authMiddleware, getOwnPost)
api.Get("/realms", listRealm)
api.Get("/realms/me", authMiddleware, listOwnedRealm)
api.Get("/realms/me/available", authMiddleware, listAvailableRealm)
@ -99,7 +120,7 @@ func NewServer() {
Expiration: 24 * time.Hour,
CacheControl: true,
}), filesystem.New(filesystem.Config{
Root: http.FS(view.FS),
Root: http.FS(views.FS),
PathPrefix: "dist",
Index: "index.html",
NotFoundFile: "dist/index.html",

View File

@ -1,11 +1,13 @@
package services
import (
"mime/multipart"
"net/http"
"strings"
"code.smartsheep.studio/hydrogen/interactive/pkg/database"
"code.smartsheep.studio/hydrogen/interactive/pkg/models"
"github.com/google/uuid"
"mime/multipart"
"net/http"
)
func NewAttachment(user models.Account, header *multipart.FileHeader) (models.Attachment, error) {
@ -14,7 +16,7 @@ func NewAttachment(user models.Account, header *multipart.FileHeader) (models.At
Filesize: header.Size,
Filename: header.Filename,
Mimetype: "unknown/unknown",
PostID: nil,
Type: models.AttachmentOthers,
AuthorID: user.ID,
}
@ -33,6 +35,17 @@ func NewAttachment(user models.Account, header *multipart.FileHeader) (models.At
}
attachment.Mimetype = http.DetectContentType(fileHeader)
switch strings.Split(attachment.Mimetype, "/")[0] {
case "image":
attachment.Type = models.AttachmentPhoto
case "video":
attachment.Type = models.AttachmentVideo
case "audio":
attachment.Type = models.AttachmentAudio
default:
attachment.Type = models.AttachmentOthers
}
// Save into database
err = database.C.Save(&attachment).Error

View File

@ -7,12 +7,16 @@ import (
"code.smartsheep.studio/hydrogen/interactive/pkg/models"
"context"
"errors"
"fmt"
"gorm.io/gorm"
"time"
)
func LinkAccount(userinfo *proto.Userinfo) (models.Account, error) {
var account models.Account
if userinfo == nil {
return account, fmt.Errorf("remote userinfo was not found")
}
if err := database.C.Where(&models.Account{
ExternalID: uint(userinfo.Id),
}).First(&account).Error; err != nil {

82
pkg/services/comments.go Normal file
View File

@ -0,0 +1,82 @@
package services
import (
"fmt"
"time"
"code.smartsheep.studio/hydrogen/interactive/pkg/database"
"code.smartsheep.studio/hydrogen/interactive/pkg/models"
"github.com/samber/lo"
"github.com/spf13/viper"
)
func (v *PostTypeContext) ListComment(id uint, take int, offset int, noReact ...bool) ([]*models.Feed, error) {
if take > 20 {
take = 20
}
var items []*models.Feed
table := viper.GetString("database.prefix") + "comments"
userTable := viper.GetString("database.prefix") + "accounts"
if err := v.Tx.
Table(table).
Select("*, ? as model_type", "comment").
Where(v.ColumnName+"_id = ?", id).
Joins(fmt.Sprintf("INNER JOIN %s as author ON author_id = author.id", userTable)).
Limit(take).Offset(offset).Find(&items).Error; err != nil {
return items, err
}
idx := lo.Map(items, func(item *models.Feed, index int) uint {
return item.ID
})
if len(noReact) <= 0 || !noReact[0] {
var reactions []struct {
PostID uint
Symbol string
Count int64
}
if err := database.C.Model(&models.Reaction{}).
Select("comment_id as post_id, symbol, COUNT(id) as count").
Where("comment_id IN (?)", idx).
Group("post_id, symbol").
Scan(&reactions).Error; err != nil {
return items, err
}
itemMap := lo.SliceToMap(items, func(item *models.Feed) (uint, *models.Feed) {
return item.ID, item
})
list := map[uint]map[string]int64{}
for _, info := range reactions {
if _, ok := list[info.PostID]; !ok {
list[info.PostID] = make(map[string]int64)
}
list[info.PostID][info.Symbol] = info.Count
}
for k, v := range list {
if post, ok := itemMap[k]; ok {
post.ReactionList = v
}
}
}
return items, nil
}
func (v *PostTypeContext) CountComment(id uint) (int64, error) {
var count int64
if err := database.C.
Model(&models.Comment{}).
Where(v.ColumnName+"_id = ?", id).
Where("published_at <= ?", time.Now()).
Count(&count).Error; err != nil {
return count, err
}
return count, nil
}

1
pkg/services/moments.go Normal file
View File

@ -0,0 +1 @@
package services

View File

@ -1,350 +1,364 @@
package services
import (
"code.smartsheep.studio/hydrogen/identity/pkg/grpc/proto"
"errors"
"fmt"
"time"
"github.com/rs/zerolog/log"
"code.smartsheep.studio/hydrogen/identity/pkg/grpc/proto"
"code.smartsheep.studio/hydrogen/interactive/pkg/database"
"code.smartsheep.studio/hydrogen/interactive/pkg/models"
"github.com/rs/zerolog/log"
"github.com/samber/lo"
"github.com/spf13/viper"
"gorm.io/gorm"
)
func PreloadRelatedPost(tx *gorm.DB) *gorm.DB {
return tx.
Preload("Author").
Preload("Attachments").
Preload("Categories").
Preload("Tags").
Preload("RepostTo").
Preload("ReplyTo").
Preload("RepostTo.Author").
Preload("ReplyTo.Author").
Preload("RepostTo.Attachments").
Preload("ReplyTo.Attachments").
Preload("RepostTo.Categories").
Preload("ReplyTo.Categories").
Preload("RepostTo.Tags").
Preload("ReplyTo.Tags")
type PostTypeContext struct {
Tx *gorm.DB
TableName string
ColumnName string
CanReply bool
CanRepost bool
}
func FilterPostWithCategory(tx *gorm.DB, alias string) *gorm.DB {
prefix := viper.GetString("database.prefix")
return tx.Joins(fmt.Sprintf("JOIN %spost_categories ON %sposts.id = %spost_categories.post_id", prefix, prefix, prefix)).
Joins(fmt.Sprintf("JOIN %scategories ON %scategories.id = %spost_categories.category_id", prefix, prefix, prefix)).
Where(fmt.Sprintf("%scategories.alias = ?", prefix), alias)
func (v *PostTypeContext) FilterWithCategory(alias string) *PostTypeContext {
name := v.ColumnName
v.Tx.Joins(fmt.Sprintf("JOIN %s_categories ON %s.id = %s_categories.%s_id", name, v.TableName, name, name)).
Joins(fmt.Sprintf("JOIN %s_categories ON %s_categories.id = %s_categories.category_id", name, name, name)).
Where(name+"_categories.alias = ?", alias)
return v
}
func FilterPostWithTag(tx *gorm.DB, alias string) *gorm.DB {
prefix := viper.GetString("database.prefix")
return tx.Joins(fmt.Sprintf("JOIN %spost_tags ON %sposts.id = %spost_tags.post_id", prefix, prefix, prefix)).
Joins(fmt.Sprintf("JOIN %stags ON %stags.id = %spost_tags.tag_id", prefix, prefix, prefix)).
Where(fmt.Sprintf("%stags.alias = ?", prefix), alias)
func (v *PostTypeContext) FilterWithTag(alias string) *PostTypeContext {
name := v.ColumnName
v.Tx.Joins(fmt.Sprintf("JOIN %s_tags ON %s.id = %s_tags.%s_id", name, v.TableName, name, name)).
Joins(fmt.Sprintf("JOIN %s_tags ON %s_tags.id = %s_tags.category_id", name, name, name)).
Where(name+"_tags.alias = ?", alias)
return v
}
func GetPost(tx *gorm.DB) (*models.Post, error) {
var post *models.Post
if err := PreloadRelatedPost(tx).First(&post).Error; err != nil {
return post, err
func (v *PostTypeContext) FilterPublishedAt(date time.Time) *PostTypeContext {
v.Tx.Where("published_at <= ? AND published_at IS NULL", date)
return v
}
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
}
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"`
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
}
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
return item, nil
}
func ListPost(tx *gorm.DB, take int, offset int) ([]*models.Post, error) {
func (v *PostTypeContext) Get(id uint, noComments ...bool) (models.Feed, error) {
var item models.Feed
table := viper.GetString("database.prefix") + v.TableName
userTable := viper.GetString("database.prefix") + "accounts"
if err := v.Tx.
Table(table).
Select("*, ? as model_type", v.ColumnName).
Joins(fmt.Sprintf("INNER JOIN %s AS author ON author_id = author.id", userTable)).
Where("id = ?", id).First(&item).Error; err != nil {
return item, err
}
var attachments []models.Attachment
if err := database.C.
Model(&models.Attachment{}).
Where(v.ColumnName+"_id = ?", id).
Scan(&attachments).Error; err != nil {
return item, err
} else {
item.Attachments = attachments
}
return item, nil
}
func (v *PostTypeContext) Count() (int64, error) {
var count int64
table := viper.GetString("database.prefix") + v.TableName
if err := v.Tx.Table(table).Count(&count).Error; err != nil {
return count, err
}
return count, nil
}
func (v *PostTypeContext) CountReactions(id uint) (map[string]int64, error) {
var reactions []struct {
Symbol string
Count int64
}
if err := database.C.Model(&models.Reaction{}).
Select("symbol, COUNT(id) as count").
Where(v.ColumnName+"_id = ?", id).
Group("symbol").
Scan(&reactions).Error; err != nil {
return map[string]int64{}, err
}
return lo.SliceToMap(reactions, func(item struct {
Symbol string
Count int64
},
) (string, int64) {
return item.Symbol, item.Count
}), nil
}
func (v *PostTypeContext) List(take int, offset int, noReact ...bool) ([]*models.Feed, error) {
if take > 20 {
take = 20
}
var posts []*models.Post
if err := PreloadRelatedPost(tx).
Limit(take).
Offset(offset).
Find(&posts).Error; err != nil {
return posts, err
var items []*models.Feed
table := viper.GetString("database.prefix") + v.TableName
if err := v.Tx.
Table(table).
Select("*, ? as model_type", v.ColumnName).
Limit(take).Offset(offset).Find(&items).Error; err != nil {
return items, err
}
postIds := lo.Map(posts, func(item *models.Post, _ int) uint {
idx := lo.Map(items, func(item *models.Feed, index int) uint {
return item.ID
})
var reactInfo []struct {
PostID uint `json:"post_id"`
LikeCount int64 `json:"like_count"`
DislikeCount int64 `json:"dislike_count"`
ReplyCount int64 `json:"reply_count"`
RepostCount int64 `json:"repost_count"`
}
if len(noReact) <= 0 || !noReact[0] {
var reactions []struct {
PostID uint
Symbol string
Count int64
}
prefix := viper.GetString("database.prefix")
database.C.Raw(fmt.Sprintf(`
SELECT t.id as post_id,
COALESCE(l.like_count, 0) AS like_count,
COALESCE(d.dislike_count, 0) AS dislike_count,
COALESCE(r.reply_count, 0) AS reply_count,
COALESCE(rp.repost_count, 0) AS repost_count
FROM %sposts t
LEFT JOIN (SELECT post_id, COUNT(*) AS like_count
FROM %spost_likes
GROUP BY post_id) l ON t.id = l.post_id
LEFT JOIN (SELECT post_id, COUNT(*) AS dislike_count
FROM %spost_dislikes
GROUP BY post_id) d ON t.id = d.post_id
LEFT JOIN (SELECT reply_id, COUNT(*) AS reply_count
FROM %sposts
WHERE reply_id IS NOT NULL
GROUP BY reply_id) r ON t.id = r.reply_id
LEFT JOIN (SELECT repost_id, COUNT(*) AS repost_count
FROM %sposts
WHERE repost_id IS NOT NULL
GROUP BY repost_id) rp ON t.id = rp.repost_id
WHERE t.id IN ?`, prefix, prefix, prefix, prefix, prefix), postIds).Scan(&reactInfo)
if err := database.C.Model(&models.Reaction{}).
Select(v.ColumnName+"_id as post_id, symbol, COUNT(id) as count").
Where(v.ColumnName+"_id IN (?)", idx).
Group("post_id, symbol").
Scan(&reactions).Error; err != nil {
return items, err
}
postMap := lo.SliceToMap(posts, func(item *models.Post) (uint, *models.Post) {
return item.ID, item
})
itemMap := lo.SliceToMap(items, func(item *models.Feed) (uint, *models.Feed) {
return item.ID, item
})
for _, info := range reactInfo {
if post, ok := postMap[info.PostID]; ok {
post.LikeCount = info.LikeCount
post.DislikeCount = info.DislikeCount
post.ReplyCount = info.ReplyCount
post.RepostCount = info.RepostCount
list := map[uint]map[string]int64{}
for _, info := range reactions {
if _, ok := list[info.PostID]; !ok {
list[info.PostID] = make(map[string]int64)
}
list[info.PostID][info.Symbol] = info.Count
}
for k, v := range list {
if post, ok := itemMap[k]; ok {
post.ReactionList = v
}
}
}
return posts, nil
{
var attachments []struct {
models.Attachment
PostID uint `json:"post_id"`
}
itemMap := lo.SliceToMap(items, func(item *models.Feed) (uint, *models.Feed) {
return item.ID, item
})
idx := lo.Map(items, func(item *models.Feed, index int) uint {
return item.ID
})
if err := database.C.
Model(&models.Attachment{}).
Select(v.ColumnName+"_id as post_id, *").
Where(v.ColumnName+"_id IN (?)", idx).
Scan(&attachments).Error; err != nil {
return items, err
}
list := map[uint][]models.Attachment{}
for _, info := range attachments {
list[info.PostID] = append(list[info.PostID], info.Attachment)
}
for k, v := range list {
if post, ok := itemMap[k]; ok {
post.Attachments = v
}
}
}
return items, nil
}
func 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) {
func MapCategoriesAndTags[T models.PostInterface](item T) (T, error) {
var err error
var post models.Post
categories := item.GetCategories()
for idx, category := range categories {
categories[idx], err = GetCategory(category.Alias)
if err != nil {
return post, err
return item, err
}
}
item.SetCategories(categories)
tags := item.GetHashtags()
for idx, tag := range tags {
tags[idx], err = GetTagOrCreate(tag.Alias, tag.Name)
if err != nil {
return post, err
return item, err
}
}
item.SetHashtags(tags)
return item, nil
}
var realmId *uint
if realm != nil {
if !realm.IsPublic {
func NewPost[T models.PostInterface](item T) (T, error) {
item, err := MapCategoriesAndTags(item)
if err != nil {
return item, err
}
if item.GetRealm() != nil {
if !item.GetRealm().IsPublic {
var member models.RealmMember
if err := database.C.Where(&models.RealmMember{
RealmID: realm.ID,
AccountID: user.ID,
RealmID: item.GetRealm().ID,
AccountID: item.GetAuthor().ID,
}).First(&member).Error; err != nil {
return post, fmt.Errorf("you aren't a part of that realm")
return item, fmt.Errorf("you aren't a part of that realm")
}
}
realmId = &realm.ID
}
if publishedAt == nil {
publishedAt = lo.ToPtr(time.Now())
if err := database.C.Save(&item).Error; err != nil {
return item, err
}
post = models.Post{
Alias: alias,
Title: title,
Content: content,
Attachments: attachments,
Tags: tags,
Categories: categories,
AuthorID: user.ID,
RealmID: realmId,
PublishedAt: *publishedAt,
RepostID: repostTo,
ReplyID: replyTo,
if 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...")
}
}
}
}()
}
if err := database.C.Save(&post).Error; err != nil {
return post, err
}
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 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)
for _, account := range accounts {
postUrl := fmt.Sprintf("https://%s/posts/%d", viper.GetString("domain"), item.GetID())
err := NotifyAccount(
op.Author,
fmt.Sprintf("%s replied you", user.Name),
fmt.Sprintf("%s replied your post. Check it out!", user.Name),
account,
fmt.Sprintf("%s just posted a post", item.GetAuthor().Name),
"Account you followed post a brand new post. Check it out!",
&proto.NotifyLink{Label: "Related post", Url: postUrl},
)
if err != nil {
log.Error().Err(err).Msg("An error occurred when notifying user...")
}
}
}
}()
}
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
return item, 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
}
func EditPost[T models.PostInterface](item T) (T, error) {
item, err := MapCategoriesAndTags(item)
if err != nil {
return item, err
}
if publishedAt == nil {
publishedAt = lo.ToPtr(time.Now())
}
err = database.C.Save(&item).Error
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
return item, 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
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
}
like = models.PostLike{
AccountID: user.ID,
PostID: post.ID,
}
return true, database.C.Save(&like).Error
} else {
return false, database.C.Delete(&like).Error
return false, reaction, database.C.Delete(&reaction).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
View File

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

View File

@ -1,28 +0,0 @@
## Usage
```bash
$ npm install # or pnpm install or yarn install
```
### Learn more on the [Solid Website](https://solidjs.com) and come chat with us on our [Discord](https://discord.com/invite/solidjs)
## Available Scripts
In the project directory, you can run:
### `npm run dev`
Runs the app in the development mode.<br>
Open [http://localhost:5173](http://localhost:5173) to view it in the browser.
### `npm run build`
Builds the app for production to the `dist` folder.<br>
It correctly bundles Solid in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.<br>
Your app is ready to be deployed!
## Deployment
Learn more about deploying your application with the [documentations](https://vitejs.dev/guide/static-deploy.html)

View File

@ -1,27 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Embedded Interactive</title>
<style>
body, html {
padding: 0;
margin: 0;
}
iframe {
width: 100vw;
height: 100vh;
display: block;
border: 0;
}
</style>
</head>
<body>
<iframe src="http://localhost:8445/realms/1?noTitle=1"></iframe>
</body>
</html>

View File

@ -1,13 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Goatplaza</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>

View File

@ -1,36 +0,0 @@
{
"name": "@hydrogen/interactive-web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@fortawesome/fontawesome-free": "^6.5.1",
"@solidjs/router": "^0.10.10",
"artplayer": "^5.1.1",
"cherry-markdown": "^0.8.38",
"dompurify": "^3.0.8",
"flv.js": "^1.6.2",
"hls.js": "^1.5.3",
"marked": "^12.0.0",
"medium-zoom": "^1.1.0",
"solid-js": "^1.8.7",
"universal-cookie": "^7.0.2"
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.10",
"@types/dompurify": "^3.0.5",
"autoprefixer": "^10.4.17",
"daisyui": "^4.6.1",
"postcss": "^8.4.33",
"solid-devtools": "^0.29.3",
"tailwindcss": "^3.4.1",
"typescript": "^5.2.2",
"vite": "^5.0.8",
"vite-plugin-solid": "^2.8.0"
}
}

View File

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

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 12 KiB

View File

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

View File

@ -1,184 +0,0 @@
:root {
--bs-body-font-family: "IBM Plex Sans", "Noto Serif SC", sans-serif !important;
}
html,
body {
font-family: var(--bs-body-font-family);
}
/* ibm-plex-sans-100 - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'IBM Plex Sans';
font-style: normal;
font-weight: 100;
src: url('./ibm-plex-sans-v19-latin-100.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* ibm-plex-sans-100italic - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'IBM Plex Sans';
font-style: italic;
font-weight: 100;
src: url('./ibm-plex-sans-v19-latin-100italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* ibm-plex-sans-200 - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'IBM Plex Sans';
font-style: normal;
font-weight: 200;
src: url('./ibm-plex-sans-v19-latin-200.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* ibm-plex-sans-200italic - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'IBM Plex Sans';
font-style: italic;
font-weight: 200;
src: url('./ibm-plex-sans-v19-latin-200italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* ibm-plex-sans-300 - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'IBM Plex Sans';
font-style: normal;
font-weight: 300;
src: url('./ibm-plex-sans-v19-latin-300.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* ibm-plex-sans-300italic - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'IBM Plex Sans';
font-style: italic;
font-weight: 300;
src: url('./ibm-plex-sans-v19-latin-300italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* ibm-plex-sans-regular - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'IBM Plex Sans';
font-style: normal;
font-weight: 400;
src: url('./ibm-plex-sans-v19-latin-regular.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* ibm-plex-sans-italic - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'IBM Plex Sans';
font-style: italic;
font-weight: 400;
src: url('./ibm-plex-sans-v19-latin-italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* ibm-plex-sans-500 - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'IBM Plex Sans';
font-style: normal;
font-weight: 500;
src: url('./ibm-plex-sans-v19-latin-500.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* ibm-plex-sans-500italic - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'IBM Plex Sans';
font-style: italic;
font-weight: 500;
src: url('./ibm-plex-sans-v19-latin-500italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* ibm-plex-sans-600 - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'IBM Plex Sans';
font-style: normal;
font-weight: 600;
src: url('./ibm-plex-sans-v19-latin-600.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* ibm-plex-sans-600italic - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'IBM Plex Sans';
font-style: italic;
font-weight: 600;
src: url('./ibm-plex-sans-v19-latin-600italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* ibm-plex-sans-700 - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'IBM Plex Sans';
font-style: normal;
font-weight: 700;
src: url('./ibm-plex-sans-v19-latin-700.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* ibm-plex-sans-700italic - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'IBM Plex Sans';
font-style: italic;
font-weight: 700;
src: url('./ibm-plex-sans-v19-latin-700italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* noto-serif-sc-200 - chinese-simplified */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: "Noto Serif SC";
font-style: normal;
font-weight: 200;
src: url("./noto-serif-sc-v22-chinese-simplified-200.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* noto-serif-sc-300 - chinese-simplified */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: "Noto Serif SC";
font-style: normal;
font-weight: 300;
src: url("./noto-serif-sc-v22-chinese-simplified-300.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* noto-serif-sc-regular - chinese-simplified */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: "Noto Serif SC";
font-style: normal;
font-weight: 400;
src: url("./noto-serif-sc-v22-chinese-simplified-regular.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* noto-serif-sc-500 - chinese-simplified */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: "Noto Serif SC";
font-style: normal;
font-weight: 500;
src: url("./noto-serif-sc-v22-chinese-simplified-500.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* noto-serif-sc-600 - chinese-simplified */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: "Noto Serif SC";
font-style: normal;
font-weight: 600;
src: url("./noto-serif-sc-v22-chinese-simplified-600.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* noto-serif-sc-700 - chinese-simplified */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: "Noto Serif SC";
font-style: normal;
font-weight: 700;
src: url("./noto-serif-sc-v22-chinese-simplified-700.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* noto-serif-sc-900 - chinese-simplified */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: "Noto Serif SC";
font-style: normal;
font-weight: 900;
src: url("./noto-serif-sc-v22-chinese-simplified-900.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}

View File

@ -1,22 +0,0 @@
import { Show } from "solid-js";
export default function Avatar(props: { user: any }) {
return (
<Show
when={props.user?.avatar}
fallback={
<div class="avatar placeholder">
<div class="w-12 h-12 bg-neutral text-neutral-content">
<span class="text-xl uppercase">{props.user?.name?.substring(0, 1)}</span>
</div>
</div>
}
>
<div class="avatar">
<div class="w-12">
<img alt="avatar" src={props.user?.avatar} />
</div>
</div>
</Show>
);
}

View File

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

View File

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

View File

@ -1,98 +0,0 @@
import { createSignal, Show } from "solid-js";
import styles from "./NameCard.module.css";
import { getAtk } from "../stores/userinfo.tsx";
import { request } from "../scripts/request.ts";
export default function NameCard(props: { accountId: string, onError: (messasge: string | null) => void }) {
const [info, setInfo] = createSignal<any>(null);
const [isFollowing, setIsFollowing] = createSignal(false);
const [_, setLoading] = createSignal(true);
const [submitting, setSubmitting] = createSignal(false);
async function readInfo() {
setLoading(true);
const res = await request(`/api/users/${props.accountId}`);
if (res.status !== 200) {
props.onError(await res.text());
} else {
setInfo(await res.json());
props.onError(null);
}
setLoading(false);
}
async function readIsFollowing() {
setLoading(true);
const res = await request(`/api/users/${props.accountId}/follow`, {
method: "GET",
headers: { Authorization: `Bearer ${getAtk()}` }
});
if (res.status === 200) {
const data = await res.json();
setIsFollowing(data["is_followed"]);
}
setLoading(false);
}
async function follow() {
setSubmitting(true);
const res = await request(`/api/users/${props.accountId}/follow`, {
method: "POST",
headers: { "Authorization": `Bearer ${getAtk()}` }
});
if (res.status !== 201 && res.status !== 204) {
props.onError(await res.text());
} else {
await readIsFollowing();
props.onError(null);
}
setSubmitting(false);
}
readInfo();
readIsFollowing();
return (
<div class="relative">
<figure id="banner">
<img class="object-cover w-full h-36" src="https://images.unsplash.com/photo-1464822759023-fed622ff2c3b"
alt="banner" />
</figure>
<div id="avatar" class="avatar absolute border-4 border-base-200 left-[20px] top-[4.5rem]">
<div class="w-24">
<img src={info()?.avatar} alt="avatar" />
</div>
</div>
<div id="actions" class="flex justify-end">
<div>
<Show when={isFollowing()} fallback={
<button type="button" class="btn btn-primary" disabled={submitting()} onClick={() => follow()}>
<i class="fa-solid fa-plus"></i>
Follow
</button>
}>
<button type="button" class="btn btn-accent" disabled={submitting()} onClick={() => follow()}>
<i class="fa-solid fa-check"></i>
Followed
</button>
</Show>
</div>
</div>
<div id="description" class="px-6 pb-7">
<h2 class="text-2xl font-bold">{info()?.name}</h2>
<p class="text-md">{info()?.description}</p>
<div class={`mt-2 ${styles.description}`}>
<p class="text-xs">
<i class="fa-solid fa-calendar-days me-2"></i>
Joined at {new Date(info()?.created_at).toLocaleString()}
</p>
</div>
</div>
</div>
);
}

View File

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

View File

@ -1,158 +0,0 @@
import { createEffect, createMemo, createSignal, Match, Switch } from "solid-js";
import mediumZoom from "medium-zoom";
import styles from "./PostAttachments.module.css";
import Artplayer from "artplayer";
import HlsJs from "hls.js";
import FlvJs from "flv.js";
function Video({ url, ...rest }: any) {
let container: any;
function playM3u8(video: HTMLVideoElement, url: string, art: Artplayer) {
if (HlsJs.isSupported()) {
if (art.hls) art.hls.destroy();
const hls = new HlsJs();
hls.loadSource(url);
hls.attachMedia(video);
art.hls = hls;
art.on("destroy", () => hls.destroy());
} else if (video.canPlayType("application/vnd.apple.mpegurl")) {
video.src = url;
} else {
art.notice.show = "Unsupported playback format: m3u8";
}
}
function playFlv(video: HTMLVideoElement, url: string, art: Artplayer) {
if (FlvJs.isSupported()) {
if (art.flv) art.flv.destroy();
const flv = FlvJs.createPlayer({ type: "flv", url });
flv.attachMediaElement(video);
flv.load();
art.flv = flv;
art.on("destroy", () => flv.destroy());
} else {
art.notice.show = "Unsupported playback format: flv";
}
}
createEffect(() => {
new Artplayer({
container: container as HTMLDivElement,
url: url,
setting: true,
flip: true,
loop: true,
playbackRate: true,
aspectRatio: true,
subtitleOffset: true,
fullscreen: true,
fullscreenWeb: true,
theme: "#49509e",
customType: {
m3u8: playM3u8,
flv: playFlv
}
});
});
return (
<div ref={container} {...rest}></div>
);
}
function Audio({ url, caption, ...rest }: any) {
return (
<figure {...rest}>
<figcaption>{caption}</figcaption>
<audio controls src={url} />
</figure>
);
}
export default function PostAttachments(props: { attachments: any[] }) {
if (props.attachments.length <= 0) return null;
const [focus, setFocus] = createSignal(0);
const item = createMemo(() => props.attachments[focus()]);
function getRenderType(item: any): string {
return item.mimetype.split("/")[0];
}
function getUrl(item: any): string {
return item.external_url ? item.external_url : `/api/attachments/o/${item.file_id}`;
}
createEffect(() => {
mediumZoom(document.querySelectorAll(".attachment-image img"), {
background: "var(--fallback-b1,oklch(var(--b1)/1))"
});
}, [focus()]);
return (
<>
<p class="text-xs mt-3 mb-2">
<i class="fa-solid fa-paperclip me-2"></i>
Attached {props.attachments.length} file{props.attachments.length > 1 ? "s" : null}
</p>
<div class="border border-base-200">
<Switch fallback={
<div class="py-16 flex justify-center items-center">
<div class="text-center">
<i class="fa-solid fa-circle-question text-3xl"></i>
<p class="mt-3">{item().filename}</p>
<div class="flex gap-2 w-full">
<p class="text-sm">{item().filesize <= 0 ? "Unknown" : item().filesize} Bytes</p>
<p class="text-sm">{item().mimetype}</p>
</div>
<div class="mt-5">
<a class="link" href={getUrl(item())} target="_blank">Open in browser</a>
</div>
</div>
</div>
}>
<Match when={getRenderType(item()) === "image"}>
<figure class="attachment-image">
<img class="object-cover" src={getUrl(item())} alt={item().filename} />
</figure>
</Match>
<Match when={getRenderType(item()) === "audio"}>
<Audio class="p-5 flex flex-col items-center justify-center gap-2 w-full" url={getUrl(item())}
caption={item().filename} />
</Match>
<Match when={getRenderType(item()) === "video"}>
<Video class="h-[360px] w-full" url={getUrl(item())} caption={item().filename} />
</Match>
</Switch>
<div id="attachments-control" class="flex justify-between border-t border-base-200">
<div class="flex">
<button class={`w-12 h-12 btn btn-ghost ${styles.attachmentsControl}`}
disabled={focus() - 1 < 0}
onClick={() => setFocus(focus() - 1)}>
<i class="fa-solid fa-caret-left"></i>
</button>
<button class={`w-12 h-12 btn btn-ghost ${styles.attachmentsControl}`}
disabled={focus() + 1 >= props.attachments.length}
onClick={() => setFocus(focus() + 1)}>
<i class="fa-solid fa-caret-right"></i>
</button>
</div>
<div>
<div class="h-12 px-5 py-3.5 text-sm">
File {focus() + 1}
</div>
</div>
</div>
</div>
</>
);
}

View File

@ -1,399 +0,0 @@
import { closeModel, openModel } from "../../scripts/modals.ts";
import { createSignal, For, Match, Show, Switch } from "solid-js";
import { getAtk, useUserinfo } from "../../stores/userinfo.tsx";
import { request } from "../../scripts/request.ts";
import styles from "./PostPublish.module.css";
export default function PostEditActions(props: {
editing?: any;
onInputAlias: (value: string) => void;
onInputPublish: (value: string) => void;
onInputAttachments: (value: any[]) => void;
onInputCategories: (categories: any[]) => void;
onInputTags: (tags: any[]) => void;
onError: (message: string | null) => void;
}) {
const userinfo = useUserinfo();
const [uploading, setUploading] = createSignal(false);
const [attachments, setAttachments] = createSignal<any[]>(props.editing?.attachments ?? []);
const [categories, setCategories] = createSignal<{ alias: string; name: string }[]>(props.editing?.categories ?? []);
const [tags, setTags] = createSignal<{ alias: string; name: string }[]>(props.editing?.tags ?? []);
const [availableCategories, setAvailableCategories] = createSignal<any[]>([]);
const [attachmentMode, setAttachmentMode] = createSignal(0);
async function readCategories() {
const res = await request("/api/categories");
if (res.status === 200) {
setAvailableCategories(await res.json());
}
}
readCategories();
async function uploadAttachment(evt: SubmitEvent) {
evt.preventDefault();
const form = evt.target as HTMLFormElement;
const data = new FormData(form);
if (!data.get("attachment")) return;
setUploading(true);
const res = await request("/api/attachments", {
method: "POST",
headers: { Authorization: `Bearer ${getAtk()}` },
body: data,
});
if (res.status !== 200) {
props.onError(await res.text());
} else {
const data = await res.json();
setAttachments(attachments().concat([data.info]));
props.onInputAttachments(attachments());
props.onError(null);
form.reset();
}
setUploading(false);
}
function addAttachment(evt: SubmitEvent) {
evt.preventDefault();
const form = evt.target as HTMLFormElement;
const data = Object.fromEntries(new FormData(form));
setAttachments(
attachments().concat([
{
...data,
author_id: userinfo?.profiles?.id,
},
]),
);
props.onInputAttachments(attachments());
form.reset();
}
function removeAttachment(idx: number) {
const data = attachments().slice();
data.splice(idx, 1);
setAttachments(data);
props.onInputAttachments(attachments());
}
function addCategory(evt: SubmitEvent) {
evt.preventDefault();
const form = evt.target as HTMLFormElement;
const data = Object.fromEntries(new FormData(form));
if (!data.category) return;
const item = availableCategories().find((item) => item.alias === data.category);
setCategories(categories().concat([item]));
props.onInputCategories(categories());
form.reset();
}
function removeCategory(idx: number) {
const data = categories().slice();
data.splice(idx, 1);
setCategories(data);
props.onInputCategories(categories());
}
function addTag(evt: SubmitEvent) {
evt.preventDefault();
const form = evt.target as HTMLFormElement;
const data = Object.fromEntries(new FormData(evt.target as HTMLFormElement));
if (!data.alias) data.alias = crypto.randomUUID().replace(/-/g, "");
if (!data.name) return;
setTags(tags().concat([data as any]));
props.onInputTags(tags());
form.reset();
}
function removeTag(idx: number) {
const data = tags().slice();
data.splice(idx, 1);
setTags(data);
props.onInputTags(tags());
}
return (
<>
<div class="flex pl-[20px]">
<button type="button" class="btn btn-ghost w-12" onClick={() => openModel("#alias")}>
<i class="fa-solid fa-link"></i>
</button>
<button type="button" class="btn btn-ghost w-12" onClick={() => openModel("#attachments")}>
<i class="fa-solid fa-paperclip"></i>
</button>
<button type="button" class="btn btn-ghost w-12" onClick={() => openModel("#planning-publish")}>
<i class="fa-solid fa-calendar-day"></i>
</button>
<button type="button" class="btn btn-ghost w-12" onClick={() => openModel("#categories-and-tags")}>
<i class="fa-solid fa-tag"></i>
</button>
</div>
<dialog id="alias" class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg mx-1">Permalink</h3>
<label class="form-control w-full mt-3">
<div class="label">
<span class="label-text">Alias</span>
</div>
<input
name="alias"
type="text"
placeholder="Type here"
class="input input-bordered w-full"
value={props.editing?.alias ?? ""}
onInput={(evt) => props.onInputAlias(evt.target.value)}
/>
<div class="label">
<span class="label-text-alt">Leave blank to generate a random string.</span>
</div>
</label>
<div class="modal-action">
<button type="button" class="btn" onClick={() => closeModel("#alias")}>
Close
</button>
</div>
</div>
</dialog>
<dialog id="planning-publish" class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg mx-1">Planning Publish</h3>
<label class="form-control w-full mt-3">
<div class="label">
<span class="label-text">Published At</span>
</div>
<input
name="published_at"
type="datetime-local"
placeholder="Pick a date"
class="input input-bordered w-full"
value={props.editing?.published_at ?? ""}
onInput={(evt) => props.onInputAlias(evt.target.value)}
/>
<div class="label">
<span class="label-text-alt">
Before this time, your post will not be visible for everyone. You can modify this plan on Creator Hub.
</span>
</div>
</label>
<div class="modal-action">
<button type="button" class="btn" onClick={() => closeModel("#planning-publish")}>
Close
</button>
</div>
</div>
</dialog>
<dialog id="attachments" class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg mx-1">Attachments</h3>
<div role="tablist" class="tabs tabs-boxed mt-3">
<input
type="radio"
name="attachment"
role="tab"
class="tab"
aria-label="File picker"
checked={attachmentMode() === 0}
onClick={() => setAttachmentMode(0)}
/>
<input
type="radio"
name="attachment"
role="tab"
class="tab"
aria-label="External link"
checked={attachmentMode() === 1}
onClick={() => setAttachmentMode(1)}
/>
</div>
<Switch>
<Match when={attachmentMode() === 0}>
<form class="w-full mt-2" onSubmit={uploadAttachment}>
<label class="form-control">
<div class="label">
<span class="label-text">Pick a file</span>
</div>
<div class="join">
<input
required
type="file"
name="attachment"
class="join-item file-input file-input-bordered w-full"
/>
<button type="submit" class="join-item btn btn-primary" disabled={uploading()}>
<i class="fa-solid fa-upload"></i>
</button>
</div>
<div class="label">
<span class="label-text-alt">Click upload to add this file into list</span>
</div>
</label>
</form>
</Match>
<Match when={attachmentMode() === 1}>
<form class="w-full mt-2" onSubmit={addAttachment}>
<label class="form-control">
<div class="label">
<span class="label-text">Attach an external file</span>
</div>
<div class="join">
<input
required
type="text"
name="mimetype"
class="join-item input input-bordered w-full"
placeholder="Mimetype"
/>
<input
required
type="text"
name="filename"
class="join-item input input-bordered w-full"
placeholder="Name"
/>
</div>
<div class="join">
<input
required
type="text"
name="external_url"
class="join-item input input-bordered w-full"
placeholder="External URL"
/>
<button type="submit" class="join-item btn btn-primary">
<i class="fa-solid fa-plus"></i>
</button>
</div>
<div class="label">
<span class="label-text-alt">Click add button to add it into list</span>
</div>
</label>
</form>
</Match>
</Switch>
<Show when={attachments().length > 0}>
<h3 class="font-bold mt-3 mx-1">Attachment list</h3>
<ol class="mt-2 mx-1 text-sm">
<For each={attachments()}>
{(item, idx) => (
<li>
<i class="fa-regular fa-file me-2"></i>
{item.filename}
<button class="ml-2" onClick={() => removeAttachment(idx())}>
<i class="fa-solid fa-delete-left"></i>
</button>
</li>
)}
</For>
</ol>
</Show>
<div class="modal-action">
<button type="button" class="btn" onClick={() => closeModel("#attachments")}>
Close
</button>
</div>
</div>
</dialog>
<dialog id="categories-and-tags" class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg mx-1">Categories & Tags</h3>
<form class="w-full mt-3" onSubmit={addCategory}>
<label class="form-control">
<div class="label">
<span class="label-text">Add a category</span>
</div>
<div class="join">
<select name="category" class="join-item select select-bordered w-full">
<For each={availableCategories()}>{(item) => <option value={item.alias}>{item.name}</option>}</For>
</select>
<button type="submit" class="join-item btn btn-primary">
<i class="fa-solid fa-plus"></i>
</button>
</div>
</label>
</form>
<Show when={categories().length > 0}>
<h3 class="font-bold mt-3 mx-1">Category list</h3>
<ol class="mt-2 mx-1 text-sm">
<For each={categories()}>
{(item, idx) => (
<li>
<i class="fa-solid fa-layer-group me-2"></i>
{item.name} <span class={styles.description}>#{item.alias}</span>
<button class="ml-2" onClick={() => removeCategory(idx())}>
<i class="fa-solid fa-delete-left"></i>
</button>
</li>
)}
</For>
</ol>
</Show>
<form class="w-full mt-3" onSubmit={addTag}>
<label class="form-control">
<div class="label">
<span class="label-text">Add a tag</span>
</div>
<div class="join">
<input type="text" name="alias" placeholder="Alias" class="join-item input input-bordered w-full" />
<input type="text" name="name" placeholder="Name" class="join-item input input-bordered w-full" />
<button type="submit" class="join-item btn btn-primary">
<i class="fa-solid fa-plus"></i>
</button>
</div>
<div class="label">
<span class="label-text-alt">
Alias is the url key of this tag. Lowercase only, required length 4-24. Leave blank for auto generate.
</span>
</div>
</label>
</form>
<Show when={tags().length > 0}>
<h3 class="font-bold mt-3 mx-1">Category list</h3>
<ol class="mt-2 mx-1 text-sm">
<For each={tags()}>
{(item, idx) => (
<li>
<i class="fa-solid fa-tag me-2"></i>
{item.name} <span class={styles.description}>#{item.alias}</span>
<button class="ml-2" onClick={() => removeTag(idx())}>
<i class="fa-solid fa-delete-left"></i>
</button>
</li>
)}
</For>
</ol>
</Show>
<div class="modal-action">
<button type="button" class="btn" onClick={() => closeModel("#categories-and-tags")}>
Close
</button>
</div>
</div>
</dialog>
</>
);
}

View File

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

View File

@ -1,215 +0,0 @@
import { createSignal, For, Show } from "solid-js";
import { getAtk, useUserinfo } from "../../stores/userinfo.tsx";
import { request } from "../../scripts/request.ts";
import PostAttachments from "./PostAttachments.tsx";
import * as marked from "marked";
import DOMPurify from "dompurify";
import Avatar from "../Avatar.tsx";
export default function PostItem(props: {
post: any;
noClick?: boolean;
noAuthor?: boolean;
noControl?: boolean;
noRelated?: boolean;
noContent?: boolean;
onRepost?: (post: any) => void;
onReply?: (post: any) => void;
onEdit?: (post: any) => void;
onDelete?: (post: any) => void;
onSearch?: (filter: any) => void;
onError: (message: string | null) => void;
onReact: () => void;
}) {
const [reacting, setReacting] = createSignal(false);
const userinfo = useUserinfo();
async function reactPost(item: any, type: string) {
setReacting(true);
const res = await request(`/api/posts/${item.id}/react/${type}`, {
method: "POST",
headers: { Authorization: `Bearer ${getAtk()}` }
});
if (res.status !== 201 && res.status !== 204) {
props.onError(await res.text());
} else {
props.onReact();
props.onError(null);
}
setReacting(false);
}
const content = <article class="prose" innerHTML={DOMPurify.sanitize(marked.parse(props.post.content) as string)} />;
return (
<div class="post-item">
<Show when={!props.noAuthor}>
<a href={`/accounts/${props.post.author.name}`}>
<div class="flex bg-base-200">
<div class="pl-[20px]">
<Avatar user={props.post.author} />
</div>
<div class="flex items-center px-5">
<div>
<h3 class="font-bold text-sm">{props.post.author.nick}</h3>
<p class="text-xs">{props.post.author.description}</p>
</div>
</div>
</div>
</a>
</Show>
<Show when={!props.noContent}>
<div class="px-7 py-5">
<h2 class="card-title">{props.post.title}</h2>
<Show when={!props.noClick} fallback={content}>
<a href={`/posts/${props.post.alias}`}>{content}</a>
</Show>
<div class="mt-2 flex gap-2">
<For each={props.post.categories}>
{(item) => (
<a href={`/search?category=${item.alias}`} class="badge badge-primary">
<i class="fa-solid fa-layer-group me-1.5"></i>
{item.name}
</a>
)}
</For>
<For each={props.post.tags}>
{(item) => (
<a href={`/search?tag=${item.alias}`} class="badge badge-accent">
<i class="fa-solid fa-tag me-1.5"></i>
{item.name}
</a>
)}
</For>
</div>
<Show when={props.post.attachments?.length > 0}>
<div>
<PostAttachments attachments={props.post.attachments ?? []} />
</div>
</Show>
<Show when={!props.noRelated && props.post.repost_to}>
<p class="text-xs mt-3 mb-2">
<i class="fa-solid fa-retweet me-2"></i>
Reposted a post
</p>
<div class="border border-base-200 mb-5">
<PostItem noControl post={props.post.repost_to} onError={props.onError} onReact={props.onReact} />
</div>
</Show>
<Show when={!props.noRelated && props.post.reply_to}>
<p class="text-xs mt-3 mb-2">
<i class="fa-solid fa-reply me-2"></i>
Replied a post
</p>
<div class="border border-base-200 mb-5">
<PostItem noControl post={props.post.reply_to} onError={props.onError} onReact={props.onReact} />
</div>
</Show>
</div>
</Show>
<Show when={!props.noControl}>
<div class="relative">
<Show when={!userinfo?.isLoggedIn}>
<div
class="px-7 py-2.5 h-12 w-full opacity-0 transition-opacity hover:opacity-100 bg-base-100 border-t border-base-200 z-[1] absolute top-0 left-0">
<b>Login!</b> To access entire platform.
</div>
</Show>
<div class="grid grid-cols-3 border-y border-base-200">
<div class="max-md:col-span-2 md:col-span-1 grid grid-cols-2">
<div class="tooltip" data-tip="Daisuki">
<button
type="button"
class="btn btn-ghost btn-block"
disabled={reacting()}
onClick={() => reactPost(props.post, "like")}
>
<i class="fa-solid fa-thumbs-up"></i>
<code class="font-mono">{props.post.like_count}</code>
</button>
</div>
<div class="tooltip" data-tip="Daikirai">
<button
type="button"
class="btn btn-ghost btn-block"
disabled={reacting()}
onClick={() => reactPost(props.post, "dislike")}
>
<i class="fa-solid fa-thumbs-down"></i>
<code class="font-mono">{props.post.dislike_count}</code>
</button>
</div>
</div>
<div class="max-md:col-span-1 md:col-span-2 flex justify-end">
<section class="max-md:hidden">
<div class="tooltip" data-tip="Reply">
<button
type="button"
class="indicator btn btn-ghost btn-block"
onClick={() => props.onReply && props.onReply(props.post)}
>
<span class="indicator-item badge badge-sm badge-primary">{props.post.reply_count}</span>
<i class="fa-solid fa-reply"></i>
</button>
</div>
<div class="tooltip" data-tip="Repost">
<button
type="button"
class="indicator btn btn-ghost btn-block"
onClick={() => props.onRepost && props.onRepost(props.post)}
>
<span class="indicator-item badge badge-sm badge-secondary">{props.post.repost_count}</span>
<i class="fa-solid fa-retweet"></i>
</button>
</div>
</section>
<div class="dropdown dropdown-end">
<div tabIndex="0" role="button" class="btn btn-ghost w-12">
<i class="fa-solid fa-ellipsis-vertical"></i>
</div>
<ul tabIndex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52">
<li class="md:hidden">
<a class="flex justify-between" onClick={() => props.onReply && props.onReply(props.post)}>
<span>Reply</span>
<span class="badge badge-primary">{props.post.reply_count}</span>
</a>
</li>
<li class="md:hidden">
<a class="flex justify-between" onClick={() => props.onRepost && props.onRepost(props.post)}>
<span>Repost</span>
<span class="badge badge-secondary">{props.post.repost_count}</span>
</a>
</li>
<Show when={userinfo?.profiles?.id === props.post.author_id}>
<li>
<a onClick={() => props.onDelete && props.onDelete(props.post)}>Delete</a>
</li>
</Show>
<Show when={userinfo?.profiles?.id === props.post.author_id}>
<li>
<a onClick={() => props.onEdit && props.onEdit(props.post)}>Edit</a>
</li>
</Show>
<li>
<a>Report</a>
</li>
</ul>
</div>
</div>
</div>
</div>
</Show>
</div>
);
}

View File

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

View File

@ -1,96 +0,0 @@
import { createMemo, createSignal, For, Show } from "solid-js";
import styles from "./PostList.module.css";
import PostItem from "./PostItem.tsx";
import LoadingAnimation from "../LoadingAnimation.tsx";
import { getAtk } from "../../stores/userinfo.tsx";
import { request } from "../../scripts/request.ts";
export default function PostList(props: {
noRelated?: boolean,
info: { data: any[], count: number } | null,
onRepost?: (post: any) => void,
onReply?: (post: any) => void,
onEdit?: (post: any) => void,
onUpdate: (pn: number, filter?: any) => Promise<void>,
onError: (message: string | null) => void
}) {
const [loading, setLoading] = createSignal(true);
const posts = createMemo(() => props.info?.data);
const postCount = createMemo<number>(() => props.info?.count ?? 0);
const [page, setPage] = createSignal(1);
const pageCount = createMemo(() => Math.ceil(postCount() / 10));
async function readPosts(filter?: any) {
setLoading(true);
await props.onUpdate(page(), filter);
setLoading(false);
}
readPosts();
async function deletePost(item: any) {
if (!confirm(`Are you sure to delete post#${item.id}?`)) return;
setLoading(true);
const res = await request(`/api/posts/${item.id}`, {
method: "DELETE",
headers: { "Authorization": `Bearer ${getAtk()}` }
});
if (res.status !== 200) {
props.onError(await res.text());
} else {
await readPosts();
props.onError(null);
}
setLoading(false);
}
function changePage(pn: number) {
setPage(pn);
readPosts().then(() => {
setTimeout(() => window.scrollTo({ top: 0, behavior: "smooth" }), 16);
});
}
return (
<div id="post-list">
<div id="posts">
<For each={posts()}>
{item =>
<PostItem
post={item}
noRelated={props.noRelated}
onRepost={props.onRepost}
onReply={props.onReply}
onEdit={props.onEdit}
onDelete={deletePost}
onReact={() => readPosts()}
onError={props.onError}
/>
}
</For>
<div class="flex justify-center">
<div class="join">
<button class={`join-item btn btn-ghost ${styles.paginationControl}`} disabled={page() <= 1}
onClick={() => changePage(page() - 1)}>
<i class="fa-solid fa-caret-left"></i>
</button>
<button class="join-item btn btn-ghost">Page {page()}</button>
<button class={`join-item btn btn-ghost ${styles.paginationControl}`} disabled={page() >= pageCount()}
onClick={() => changePage(page() + 1)}>
<i class="fa-solid fa-caret-right"></i>
</button>
</div>
</div>
<Show when={loading()}>
<LoadingAnimation />
</Show>
</div>
</div>
);
}

View File

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

View File

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

View File

@ -1,39 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html, body {
padding: 0;
margin: 0;
}
.medium-zoom-image--opened {
z-index: 15;
}
.medium-zoom-overlay {
z-index: 10;
}
.scrollbar-hidden {
scrollbar-width: none;
}
.scrollbar-hidden::-webkit-scrollbar {
display: none;
width: 0;
}
.cherry, .cherry-toolbar, .cherry-editor, .cherry-previewer, .cherry-drag {
box-shadow: none !important;
}
.cherry-drag {
width: 2px !important;
}
@media (prefers-color-scheme: dark) {
.cherry-drag {
background: oklch(var(--b2)) !important;
}
}

View File

@ -1,79 +0,0 @@
import "solid-devtools";
/* @refresh reload */
import { render } from "solid-js/web";
import "./index.css";
import "./assets/fonts/fonts.css";
import { lazy } from "solid-js";
import { Route, Router } from "@solidjs/router";
import "@fortawesome/fontawesome-free/css/all.css";
import RootLayout from "./layouts/RootLayout.tsx";
import FeedView from "./pages/view.tsx";
import Global from "./pages/global.tsx";
import PostReference from "./pages/post.tsx";
import CreatorView from "./pages/creators/view.tsx";
import { UserinfoProvider } from "./stores/userinfo.tsx";
import { WellKnownProvider } from "./stores/wellKnown.tsx";
const root = document.getElementById("root");
const router = (basename?: string) => (
<WellKnownProvider>
<UserinfoProvider>
<Router root={RootLayout} base={basename}>
<Route path="/" component={FeedView}>
<Route path="/" component={Global} />
<Route path="/posts/:postId" component={PostReference} />
<Route path="/search" component={lazy(() => import("./pages/search.tsx"))} />
<Route path="/realms" component={lazy(() => import("./pages/realms"))} />
<Route path="/realms/:realmId" component={lazy(() => import("./pages/realms/realm.tsx"))} />
<Route path="/accounts/:accountId" component={lazy(() => import("./pages/account.tsx"))} />
</Route>
<Route path="/creators" component={CreatorView}>
<Route path="/" component={lazy(() => import("./pages/creators"))} />
<Route path="/publish" component={lazy(() => import("./pages/creators/publish.tsx"))} />
<Route path="/edit/:postId" component={lazy(() => import("./pages/creators/edit.tsx"))} />
</Route>
</Router>
</UserinfoProvider>
</WellKnownProvider>
);
declare const __GARFISH_EXPORTS__: {
provider: Object;
registerProvider?: (provider: any) => void;
};
declare global {
interface Window {
__GARFISH__: boolean;
__LAUNCHPAD_TARGET__?: string;
}
}
export const provider = () => ({
render: ({ dom, basename }: { dom: any, basename: string }) => {
render(
() => router(basename),
dom.querySelector("#root")
);
},
destroy: () => {
}
});
if (!window.__GARFISH__) {
console.log("Running directly!")
render(router, root!);
} else if (typeof __GARFISH_EXPORTS__ !== "undefined") {
console.log("Running in launchpad container!")
console.log("Launchpad target:", window.__LAUNCHPAD_TARGET__)
if (__GARFISH_EXPORTS__.registerProvider) {
__GARFISH_EXPORTS__.registerProvider(provider);
} else {
__GARFISH_EXPORTS__.provider = provider;
}
}

View File

@ -1,63 +0,0 @@
import Navigator from "./shared/Navigator.tsx";
import { readProfiles, useUserinfo } from "../stores/userinfo.tsx";
import { createEffect, createMemo, createSignal, Show } from "solid-js";
import { readWellKnown } from "../stores/wellKnown.tsx";
import { BeforeLeaveEventArgs, useLocation, useNavigate, useSearchParams } from "@solidjs/router";
export default function RootLayout(props: any) {
const [ready, setReady] = createSignal(false);
Promise.all([readWellKnown(), readProfiles()]).then(() => setReady(true));
const navigate = useNavigate();
const userinfo = useUserinfo();
const [searchParams] = useSearchParams();
const location = useLocation();
createEffect(() => {
if (ready()) {
keepGate(location.pathname + location.search, searchParams["embedded"] != null);
}
}, [ready, userinfo]);
function keepGate(path: string, embedded: boolean, e?: BeforeLeaveEventArgs) {
const blacklist = ["/creators"];
if (!userinfo?.isLoggedIn && blacklist.includes(path)) {
if (!e?.defaultPrevented) e?.preventDefault();
if (embedded) {
navigate(`/auth?redirect_uri=${path}&embedded=${location.query["embedded"]}`);
} else {
navigate(`/auth?redirect_uri=${path}`);
}
}
}
const mainContentStyles = createMemo(() => {
if (!searchParams["embedded"]) {
return "h-[calc(100vh-64px)] max-md:mb-[64px] md:mt-[64px]";
} else {
return "h-[100vh]";
}
});
return (
<Show
when={ready()}
fallback={
<div class="h-screen w-screen flex justify-center items-center">
<div>
<span class="loading loading-lg loading-infinity"></span>
</div>
</div>
}
>
<Show when={!searchParams["embedded"]}>
<Navigator />
</Show>
<main class={`${mainContentStyles()} scrollbar-hidden`}>{props.children}</main>
</Show>
);
}

View File

@ -1,80 +0,0 @@
import { createMemo, For, Match, Switch } from "solid-js";
import { clearUserinfo, useUserinfo } from "../../stores/userinfo.tsx";
import { useNavigate } from "@solidjs/router";
import { useWellKnown } from "../../stores/wellKnown.tsx";
interface MenuItem {
icon: string;
label: string;
href?: string;
}
export default function Navigator() {
const nav: MenuItem[] = [
{ icon: "fa-solid fa-pen-nib", label: "Creators", href: "/creators" },
{ icon: "fa-solid fa-newspaper", label: "Feed", href: "/" },
{ icon: "fa-solid fa-people-group", label: "Realms", href: "/realms" },
];
const wellKnown = useWellKnown();
const userinfo = useUserinfo();
const navigate = useNavigate();
const endpoint = createMemo(() => wellKnown?.components?.identity)
function logout() {
clearUserinfo();
navigate("/");
}
return (
<>
<div class="max-md:hidden navbar bg-base-100 shadow-md px-5 z-10 h-[64px] fixed top-0">
<div class="navbar-start">
<a href="/" class="btn btn-ghost text-xl">
{wellKnown?.name ?? "Interactive"}
</a>
</div>
<div class="navbar-center hidden md:flex">
<ul class="menu menu-horizontal px-1">
<For each={nav}>
{(item) => (
<li class="tooltip tooltip-bottom" data-tip={item.label}>
<a href={item.href}>
<i class={item.icon}></i>
</a>
</li>
)}
</For>
</ul>
</div>
<div class="navbar-end pe-5">
<Switch>
<Match when={userinfo?.isLoggedIn}>
<button type="button" class="btn btn-sm btn-ghost" onClick={() => logout()}>
Logout
</button>
</Match>
<Match when={!userinfo?.isLoggedIn}>
<a href={`${endpoint()}/auth/login?redirect_uri=${window.location}`} class="btn btn-sm btn-primary">
Login
</a>
</Match>
</Switch>
</div>
</div>
<div class="md:hidden btm-nav fixed bottom-0 bg-base-100 border-t border-base-200 z-10 h-[64px]">
<For each={nav}>
{(item) => (
<a href={item.href}>
<div class="tooltip" data-tip={item.label}>
<i class={item.icon}></i>
</div>
</a>
)}
</For>
</div>
</>
);
}

View File

@ -1,110 +0,0 @@
import { createEffect, createSignal, Show } from "solid-js";
import { useParams } from "@solidjs/router";
import { useSearchParams } from "@solidjs/router";
import { createStore } from "solid-js/store";
import { closeModel, openModel } from "../scripts/modals.ts";
import { request } from "../scripts/request.ts";
import PostList from "../components/posts/PostList.tsx";
import NameCard from "../components/NameCard.tsx";
import PostPublish from "../components/posts/PostPublish.tsx";
export default function AccountPage() {
const [error, setError] = createSignal<string | null>(null);
const [page, setPage] = createSignal(0);
const [info, setInfo] = createSignal<any>(null);
const [searchParams, setSearchParams] = useSearchParams();
const params = useParams();
createEffect(() => {
setPage(parseInt(searchParams["page"] ?? "1"));
}, [searchParams]);
async function readPosts(pn?: number) {
if (pn) setSearchParams({ page: pn });
const res = await request(
"/api/posts?" +
new URLSearchParams({
take: searchParams["take"] ? searchParams["take"] : (10).toString(),
offset: searchParams["offset"] ? searchParams["offset"] : ((page() - 1) * 10).toString(),
authorId: params["accountId"],
}),
);
if (res.status !== 200) {
setError(await res.text());
} else {
setError(null);
setInfo(await res.json());
}
}
function setMeta(data: any, field: string, open = true) {
const meta: { [id: string]: any } = {
reposting: null,
replying: null,
editing: null,
};
meta[field] = data;
setPublishMeta(meta);
if (open) openModel("#post-publish");
else closeModel("#post-publish");
}
const [publishMeta, setPublishMeta] = createStore<any>({
replying: null,
reposting: null,
editing: null,
});
return (
<>
<div id="alerts">
<Show when={error()}>
<div role="alert" class="alert alert-error">
<svg
xmlns="http://www.w3.org/2000/svg"
class="stroke-current shrink-0 h-6 w-6"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span class="capitalize">{error()}</span>
</div>
</Show>
</div>
<NameCard accountId={params["accountId"]} onError={setError} />
<dialog id="post-publish" class="modal">
<div class="modal-box p-0 w-[540px]">
<PostPublish
reposting={publishMeta.reposting}
replying={publishMeta.replying}
editing={publishMeta.editing}
onReset={() => setMeta(null, "none", false)}
onError={setError}
onPost={() => readPosts()}
/>
</div>
</dialog>
<PostList
info={info()}
onUpdate={readPosts}
onError={setError}
onRepost={(item) => setMeta(item, "reposting")}
onReply={(item) => setMeta(item, "replying")}
onEdit={(item) => setMeta(item, "editing")}
/>
</>
);
}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,28 +0,0 @@
import { createMemo } from "solid-js";
import { useSearchParams } from "@solidjs/router";
import styles from "./view.module.css";
export default function CreatorView(props: any) {
const [searchParams] = useSearchParams();
const scrollContentStyles = createMemo(() => {
if (!searchParams["embedded"]) {
return "max-md:mb-[64px]";
} else {
return "h-[100vh]";
}
});
return (
<div class={`${styles.wrapper} container mx-auto`}>
<div id="nav" class="card shadow-xl h-fit">
<h2 class="text-xl font-bold mt-1 py-5 px-7">Creator Hub</h2>
</div>
<div id="content" class={`${scrollContentStyles()} card shadow-xl`}>
{props.children}
</div>
</div>
);
}

View File

@ -1,99 +0,0 @@
import { createEffect, createSignal, Show } from "solid-js";
import { createStore } from "solid-js/store";
import { useSearchParams } from "@solidjs/router";
import { request } from "../scripts/request.ts";
import PostList from "../components/posts/PostList.tsx";
import PostPublish from "../components/posts/PostPublish.tsx";
export default function DashboardPage() {
const [error, setError] = createSignal<string | null>(null);
const [page, setPage] = createSignal(0);
const [info, setInfo] = createSignal<any>(null);
const [searchParams, setSearchParams] = useSearchParams();
createEffect(() => {
setPage(parseInt(searchParams["page"] ?? "1"));
}, [searchParams]);
async function readPosts(pn?: number) {
if (pn) setSearchParams({ page: pn });
const res = await request(
"/api/posts?" +
new URLSearchParams({
take: searchParams["take"] ? searchParams["take"] : (10).toString(),
offset: searchParams["offset"] ? searchParams["offset"] : ((page() - 1) * 10).toString(),
reply: false.toString(),
}),
);
if (res.status !== 200) {
setError(await res.text());
} else {
setError(null);
setInfo(await res.json());
}
}
function setMeta(data: any, field: string, scroll = true) {
const meta: { [id: string]: any } = {
reposting: null,
replying: null,
editing: null,
};
meta[field] = data;
setPublishMeta(meta);
if (scroll) window.scroll({ top: 0, behavior: "smooth" });
}
const [publishMeta, setPublishMeta] = createStore<any>({
replying: null,
reposting: null,
editing: null,
});
return (
<>
<div id="alerts">
<Show when={error()}>
<div role="alert" class="alert alert-error">
<svg
xmlns="http://www.w3.org/2000/svg"
class="stroke-current shrink-0 h-6 w-6"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span class="capitalize">{error()}</span>
</div>
</Show>
</div>
<PostPublish
replying={publishMeta.replying}
reposting={publishMeta.reposting}
editing={publishMeta.editing}
onReset={() => setMeta(null, "none", false)}
onPost={() => readPosts()}
onError={setError}
/>
<PostList
info={info()}
onUpdate={readPosts}
onError={setError}
onRepost={(item) => setMeta(item, "reposting")}
onReply={(item) => setMeta(item, "replying")}
onEdit={(item) => setMeta(item, "editing")}
/>
</>
);
}

View File

@ -1,161 +0,0 @@
import { createSignal, Show } from "solid-js";
import { useNavigate, useParams, useSearchParams } from "@solidjs/router";
import { createStore } from "solid-js/store";
import { closeModel, openModel } from "../scripts/modals.ts";
import { getAtk } from "../stores/userinfo.tsx";
import { request } from "../scripts/request.ts";
import PostPublish from "../components/posts/PostPublish.tsx";
import PostList from "../components/posts/PostList.tsx";
import PostItem from "../components/posts/PostItem.tsx";
export default function PostPage() {
const [error, setError] = createSignal<string | null>(null);
const [page, setPage] = createSignal(0);
const [related, setRelated] = createSignal<any>(null);
const [info, setInfo] = createSignal<any>(null);
const params = useParams();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
async function readPost(pn?: number) {
if (pn) setPage(pn);
const res = await request(`/api/posts/${params["postId"]}?` + new URLSearchParams({
take: (10).toString(),
offset: ((page() - 1) * 10).toString()
}));
if (res.status !== 200) {
setError(await res.text());
} else {
setError(null);
const data = await res.json();
setInfo(data["data"]);
setRelated({
count: data["count"],
data: data["related"]
});
}
}
readPost();
async function deletePost(item: any) {
if (!confirm(`Are you sure to delete post#${item.id}?`)) return;
const res = await request(`/api/posts/${item.id}`, {
method: "DELETE",
headers: { "Authorization": `Bearer ${getAtk()}` }
});
if (res.status !== 200) {
setError(await res.text());
} else {
back();
setError(null);
}
}
function setMeta(data: any, field: string, open = true) {
const meta: { [id: string]: any } = {
reposting: null,
replying: null,
editing: null
};
meta[field] = data;
setPublishMeta(meta);
if (open) openModel("#post-publish");
else closeModel("#post-publish");
}
const [publishMeta, setPublishMeta] = createStore<any>({
replying: null,
reposting: null,
editing: null
});
function back() {
if (window.history.length > 0) {
window.history.back();
} else {
navigate("/");
}
}
return (
<>
<div id="alerts">
<Show when={error()}>
<div role="alert" class="alert alert-error">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="capitalize">{error()}</span>
</div>
</Show>
</div>
<div class="flex pt-1">
<Show when={searchParams["embedded"]} fallback={
<button class="btn btn-ghost ml-[20px] w-12 h-12" onClick={() => back()}>
<i class="fa-solid fa-angle-left"></i>
</button>
}>
<div class="w-12 h-12 ml-[20px] flex justify-center items-center">
<i class="fa-solid fa-comments mb-1"></i>
</div>
</Show>
<div class="px-5 flex items-center">
<p>{searchParams["title"] ?? "Post details"}</p>
</div>
</div>
<dialog id="post-publish" class="modal">
<div class="modal-box p-0 w-[540px]">
<PostPublish
reposting={publishMeta.reposting}
replying={publishMeta.replying}
editing={publishMeta.editing}
onReset={() => setMeta(null, "none", false)}
onError={setError}
onPost={() => readPost()}
/>
</div>
</dialog>
<Show when={info()} fallback={
<div class="w-full border-b border-base-200 pt-5 pb-7 text-center">
<p class="loading loading-lg loading-infinity"></p>
<p>Creating fake news...</p>
</div>
}>
<PostItem
noClick
post={info()}
onError={setError}
onReact={readPost}
onDelete={deletePost}
noAuthor={searchParams["noAuthor"] != null}
noContent={searchParams["noContent"] != null}
noControl={searchParams["noControl"] != null}
onRepost={(item) => setMeta(item, "reposting")}
onReply={(item) => setMeta(item, "replying")}
onEdit={(item) => setMeta(item, "editing")}
/>
<PostList
noRelated
info={related()}
onUpdate={readPost}
onError={setError}
onRepost={(item) => setMeta(item, "reposting")}
onReply={(item) => setMeta(item, "replying")}
onEdit={(item) => setMeta(item, "editing")}
/>
</Show>
</>
);
}

View File

@ -1,115 +0,0 @@
import { createSignal, For, Show } from "solid-js";
import { closeModel, openModel } from "../../scripts/modals.ts";
import { getAtk } from "../../stores/userinfo.tsx";
import { request } from "../../scripts/request.ts";
export default function RealmDirectoryPage() {
const [error, setError] = createSignal<string | null>(null);
const [submitting, setSubmitting] = createSignal(false);
const [realms, setRealms] = createSignal<any>(null);
async function readRealms() {
const res = await request(`/api/realms`);
if (res.status !== 200) {
setError(await res.text());
} else {
setRealms(await res.json());
}
}
readRealms();
async function createRealm(evt: SubmitEvent) {
evt.preventDefault();
const form = evt.target as HTMLFormElement;
const data = Object.fromEntries(new FormData(form));
setSubmitting(true);
const res = await request("/api/realms", {
method: "POST",
headers: { "Authorization": `Bearer ${getAtk()}`, "Content-Type": "application/json" },
body: JSON.stringify({
name: data.name,
description: data.description,
is_public: data.is_public != null
})
});
if (res.status !== 200) {
setError(await res.text());
} else {
await readRealms();
closeModel("#create-realm");
form.reset();
}
setSubmitting(false);
}
return (
<>
<div id="alerts">
<Show when={error()}>
<div role="alert" class="alert alert-error">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="capitalize">{error()}</span>
</div>
</Show>
</div>
<div class="mt-1 px-7 flex items-center justify-between">
<h3 class="py-3 font-bold">Realms directory</h3>
<button type="button" class="btn btn-primary" onClick={() => openModel("#create-realm")}>
<i class="fa-solid fa-plus"></i>
</button>
</div>
<For each={realms()}>
{item => <div class="px-7 pt-7 pb-5 border-t border-base-200">
<h2 class="text-xl font-bold">{item.name}</h2>
<p>{item.description}</p>
<div class="mt-2">
<a href={`/realms/${item.id}`} class="link">Jump in</a>
</div>
</div>}
</For>
<dialog id="create-realm" class="modal">
<div class="modal-box">
<h2 class="card-title px-1">Create a realm</h2>
<form class="mt-2" onSubmit={createRealm}>
<label class="form-control w-full">
<div class="label">
<span class="label-text">Realm name</span>
</div>
<input name="name" type="text" placeholder="Type here" class="input input-bordered w-full" />
</label>
<label class="form-control w-full">
<div class="label">
<span class="label-text">Realm description</span>
</div>
<textarea name="description" placeholder="Type here" class="textarea textarea-bordered w-full" />
</label>
<div class="form-control mt-2">
<label class="label cursor-pointer">
<span class="label-text">Make it public</span>
<input type="checkbox" name="is_public" class="checkbox checkbox-primary" />
</label>
</div>
<button type="submit" class="btn btn-primary mt-2" disabled={submitting()}>
<Show when={submitting()} fallback={"Submit"}>
<span class="loading"></span>
</Show>
</button>
</form>
</div>
</dialog>
</>
);
}

View File

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

View File

@ -1,291 +0,0 @@
import { createSignal, Show } from "solid-js";
import { createStore } from "solid-js/store";
import { useNavigate, useParams } from "@solidjs/router";
import { request } from "../../scripts/request.ts";
import PostList from "../../components/posts/PostList.tsx";
import PostPublish from "../../components/posts/PostPublish.tsx";
import styles from "./realm.module.css";
import { getAtk, useUserinfo } from "../../stores/userinfo.tsx";
import { closeModel, openModel } from "../../scripts/modals.ts";
export default function RealmPage() {
const userinfo = useUserinfo();
const [error, setError] = createSignal<string | null>(null);
const [submitting, setSubmitting] = createSignal(false);
const [realm, setRealm] = createSignal<any>(null);
const [page, setPage] = createSignal(0);
const [info, setInfo] = createSignal<any>(null);
const params = useParams();
const navigate = useNavigate();
async function readRealm() {
const res = await request(`/api/realms/${params["realmId"]}`);
if (res.status !== 200) {
setError(await res.text());
} else {
setRealm(await res.json());
}
}
readRealm();
async function readPosts(pn?: number) {
if (pn) setPage(pn);
const res = await request(`/api/posts?` + new URLSearchParams({
take: (10).toString(),
offset: ((page() - 1) * 10).toString(),
realmId: params["realmId"]
}));
if (res.status !== 200) {
setError(await res.text());
} else {
setError(null);
setInfo(await res.json());
}
}
async function editRealm(evt: SubmitEvent) {
evt.preventDefault();
const form = evt.target as HTMLFormElement;
const data = Object.fromEntries(new FormData(form));
setSubmitting(true);
const res = await request(`/api/realms/${params["realmId"]}`, {
method: "PUT",
headers: { "Authorization": `Bearer ${getAtk()}`, "Content-Type": "application/json" },
body: JSON.stringify({
name: data.name,
description: data.description,
is_public: data.is_public != null
})
});
if (res.status !== 200) {
setError(await res.text());
} else {
await readRealm();
closeModel("#edit-realm");
form.reset();
}
setSubmitting(false);
}
async function inviteMember(evt: SubmitEvent) {
evt.preventDefault();
const form = evt.target as HTMLFormElement;
const data = Object.fromEntries(new FormData(form));
setSubmitting(true);
const res = await request(`/api/realms/${params["realmId"]}/invite`, {
method: "POST",
headers: { "Authorization": `Bearer ${getAtk()}`, "Content-Type": "application/json" },
body: JSON.stringify(data)
});
if (res.status !== 200) {
setError(await res.text());
} else {
await readRealm();
closeModel("#invite-member");
form.reset();
}
setSubmitting(false);
}
async function kickMember(evt: SubmitEvent) {
evt.preventDefault();
const form = evt.target as HTMLFormElement;
const data = Object.fromEntries(new FormData(form));
setSubmitting(true);
const res = await request(`/api/realms/${params["realmId"]}/kick`, {
method: "POST",
headers: { "Authorization": `Bearer ${getAtk()}`, "Content-Type": "application/json" },
body: JSON.stringify(data)
});
if (res.status !== 200) {
setError(await res.text());
} else {
await readRealm();
closeModel("#kick-member");
form.reset();
}
setSubmitting(false);
}
async function breakRealm() {
if (!confirm("Are you sure about that? All posts in this realm will disappear forever.")) return;
const res = await request(`/api/realms/${params["realmId"]}`, {
method: "DELETE",
headers: { "Authorization": `Bearer ${getAtk()}` }
});
if (res.status !== 200) {
setError(await res.text());
} else {
navigate("/realms");
}
}
function setMeta(data: any, field: string, scroll = true) {
const meta: { [id: string]: any } = {
reposting: null,
replying: null,
editing: null
};
meta[field] = data;
setPublishMeta(meta);
if (scroll) window.scroll({ top: 0, behavior: "smooth" });
}
const [publishMeta, setPublishMeta] = createStore<any>({
replying: null,
reposting: null,
editing: null
});
return (
<>
<div id="alerts">
<Show when={error()}>
<div role="alert" class="alert alert-error">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="capitalize">{error()}</span>
</div>
</Show>
</div>
<div class="px-7 pt-7 pb-5">
<h2 class="text-2xl font-bold">{realm()?.name}</h2>
<p>{realm()?.description}</p>
<div class={`${styles.description} text-sm mt-3`}>
<p>Realm #{realm()?.id}</p>
<Show when={realm()?.account_id === userinfo?.profiles?.id}>
<div class="flex gap-2">
<button class="link" onClick={() => openModel("#edit-realm")}>Edit</button>
<button class="link" onClick={() => openModel("#invite-member")}>Invite</button>
<button class="link" onClick={() => openModel("#kick-member")}>Kick</button>
<button class="link" onClick={() => breakRealm()}>Break-up</button>
</div>
</Show>
</div>
</div>
<PostPublish
realmId={parseInt(params["realmId"])}
replying={publishMeta.replying}
reposting={publishMeta.reposting}
editing={publishMeta.editing}
onReset={() => setMeta(null, "none", false)}
onPost={() => readPosts()}
onError={setError}
/>
<PostList
info={info()}
onUpdate={readPosts}
onError={setError}
onRepost={(item) => setMeta(item, "reposting")}
onReply={(item) => setMeta(item, "replying")}
onEdit={(item) => setMeta(item, "editing")}
/>
<dialog id="edit-realm" class="modal">
<div class="modal-box">
<h2 class="card-title px-1">Edit your realm</h2>
<form class="mt-2" onSubmit={editRealm}>
<label class="form-control w-full">
<div class="label">
<span class="label-text">Realm name</span>
</div>
<input value={realm()?.name} name="name" type="text" placeholder="Type here"
class="input input-bordered w-full" />
</label>
<label class="form-control w-full">
<div class="label">
<span class="label-text">Realm description</span>
</div>
<textarea value={realm()?.description} name="description" placeholder="Type here"
class="textarea textarea-bordered w-full" />
</label>
<div class="form-control mt-2">
<label class="label cursor-pointer">
<span class="label-text">Make it public</span>
<input checked={realm()?.is_public} type="checkbox" name="is_public"
class="checkbox checkbox-primary" />
</label>
</div>
<button type="submit" class="btn btn-primary mt-2" disabled={submitting()}>
<Show when={submitting()} fallback={"Submit"}>
<span class="loading"></span>
</Show>
</button>
</form>
</div>
</dialog>
<dialog id="invite-member" class="modal">
<div class="modal-box">
<h2 class="card-title px-1">Invite someone as a member</h2>
<form class="mt-2" onSubmit={inviteMember}>
<label class="form-control w-full">
<div class="label">
<span class="label-text">Username</span>
</div>
<input name="account_name" type="text" placeholder="Type here" class="input input-bordered w-full" />
<div class="label">
<span class="label-text-alt">
Invite someone via their username so that they can publish content in non-public realm.
</span>
</div>
</label>
<button type="submit" class="btn btn-primary mt-2" disabled={submitting()}>
<Show when={submitting()} fallback={"Submit"}>
<span class="loading"></span>
</Show>
</button>
</form>
</div>
</dialog>
<dialog id="kick-member" class="modal">
<div class="modal-box">
<h2 class="card-title px-1">Kick someone out of your realm</h2>
<form class="mt-2" onSubmit={kickMember}>
<label class="form-control w-full">
<div class="label">
<span class="label-text">Username</span>
</div>
<input name="account_name" type="text" placeholder="Type here" class="input input-bordered w-full" />
<div class="label">
<span class="label-text-alt">
Remove someone out of your realm.
</span>
</div>
</label>
<button type="submit" class="btn btn-primary mt-2" disabled={submitting()}>
<Show when={submitting()} fallback={"Submit"}>
<span class="loading"></span>
</Show>
</button>
</form>
</div>
</dialog>
</>
);
}

View File

@ -1,124 +0,0 @@
import { useNavigate, useSearchParams } from "@solidjs/router";
import { createSignal, Show } from "solid-js";
import { createStore } from "solid-js/store";
import { closeModel, openModel } from "../scripts/modals.ts";
import { request } from "../scripts/request.ts";
import PostPublish from "../components/posts/PostPublish.tsx";
import PostList from "../components/posts/PostList.tsx";
export default function SearchPage() {
const [searchParams] = useSearchParams();
const [error, setError] = createSignal<string | null>(null);
const [page, setPage] = createSignal(0);
const [info, setInfo] = createSignal<any>(null);
const navigate = useNavigate();
async function readPosts(pn?: number) {
if (pn) setPage(pn);
const res = await request("/api/posts?" + new URLSearchParams({
take: (10).toString(),
offset: ((page() - 1) * 10).toString(),
...searchParams
}));
if (res.status !== 200) {
setError(await res.text());
} else {
setError(null);
setInfo(await res.json());
}
}
function setMeta(data: any, field: string, open = true) {
const meta: { [id: string]: any } = {
reposting: null,
replying: null,
editing: null
};
meta[field] = data;
setPublishMeta(meta);
if (open) openModel("#post-publish");
else closeModel("#post-publish");
}
const [publishMeta, setPublishMeta] = createStore<any>({
replying: null,
reposting: null,
editing: null
});
function getDescribe() {
let builder = [];
if (searchParams["category"]) {
builder.push("category is #" + searchParams["category"]);
} else if (searchParams["tag"]) {
builder.push("tag is #" + searchParams["tag"]);
}
return builder.join(" and ");
}
function back() {
if (window.history.length > 0) {
window.history.back();
} else {
navigate("/");
}
}
return (
<>
<div class="flex pt-1">
<button class="btn btn-ghost ml-[20px] w-12 h-12" onClick={() => back()}>
<i class="fa-solid fa-angle-left"></i>
</button>
<div class="px-5 flex items-center">
<p>Search</p>
</div>
</div>
<div id="alerts">
<Show when={error()}>
<div role="alert" class="alert alert-error">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="capitalize">{error()}</span>
</div>
</Show>
</div>
<div role="alert" class="alert alert-info px-[20px]">
<i class="fa-solid fa-magnifying-glass pl-[13px]"></i>
<span>You will only see posts with <b>{getDescribe()}</b></span>
</div>
<dialog id="post-publish" class="modal">
<div class="modal-box p-0 w-[540px]">
<PostPublish
reposting={publishMeta.reposting}
replying={publishMeta.replying}
editing={publishMeta.editing}
onReset={() => setMeta(null, "none", false)}
onError={setError}
onPost={() => readPosts()}
/>
</div>
</dialog>
<PostList
info={info()}
onUpdate={readPosts}
onError={setError}
onRepost={(item) => setMeta(item, "reposting")}
onReply={(item) => setMeta(item, "replying")}
onEdit={(item) => setMeta(item, "editing")}
/>
</>
);
}

View File

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

View File

@ -1,28 +0,0 @@
import { createMemo } from "solid-js";
import { useSearchParams } from "@solidjs/router";
import styles from "./view.module.css";
export default function FeedView(props: any) {
const [searchParams] = useSearchParams();
const scrollContentStyles = createMemo(() => {
if (!searchParams["embedded"]) {
return "max-md:mb-[64px]";
} else {
return "h-[100vh]";
}
});
return (
<div class={`${styles.wrapper} container mx-auto`}>
<div id="trending" class="card shadow-xl h-fit"></div>
<div id="content" class={`${scrollContentStyles()} card shadow-xl`}>
{props.children}
</div>
<div id="well-known" class="card shadow-xl h-fit"></div>
</div>
);
}

View File

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

View File

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

View File

@ -1,73 +0,0 @@
import Cookie from "universal-cookie";
import { createContext, useContext } from "solid-js";
import { createStore } from "solid-js/store";
import { request } from "../scripts/request.ts";
export interface Userinfo {
isLoggedIn: boolean,
displayName: string,
profiles: any,
}
const UserinfoContext = createContext<Userinfo>();
const defaultUserinfo: Userinfo = {
isLoggedIn: false,
displayName: "Citizen",
profiles: null
};
const [userinfo, setUserinfo] = createStore<Userinfo>(structuredClone(defaultUserinfo));
export function getAtk(): string {
return new Cookie().get("identity_auth_key");
}
function checkLoggedIn(): boolean {
return new Cookie().get("identity_auth_key");
}
export async function readProfiles() {
if (!checkLoggedIn()) return;
const res = await request("/api/users/me", {
headers: { "Authorization": `Bearer ${getAtk()}` }
});
if (res.status !== 200) {
clearUserinfo();
window.location.reload();
}
const data = await res.json();
setUserinfo({
isLoggedIn: true,
displayName: data["name"],
profiles: data
});
}
export function clearUserinfo() {
const cookies = document.cookie.split(";");
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i];
const eqPos = cookie.indexOf("=");
const name = eqPos > -1 ? cookie.substring(0, eqPos) : cookie;
document.cookie = name + "=;expires=Thu, 01 Jan 1970 00:00:00 GMT";
}
setUserinfo(defaultUserinfo);
}
export function UserinfoProvider(props: any) {
return (
<UserinfoContext.Provider value={userinfo}>
{props.children}
</UserinfoContext.Provider>
);
}
export function useUserinfo() {
return useContext(UserinfoContext);
}

View File

@ -1,24 +0,0 @@
import { createContext, useContext } from "solid-js";
import { createStore } from "solid-js/store";
import { request } from "../scripts/request.ts";
const WellKnownContext = createContext<any>();
const [wellKnown, setWellKnown] = createStore<any>(null);
export async function readWellKnown() {
const res = await request("/.well-known")
setWellKnown(await res.json())
}
export function WellKnownProvider(props: any) {
return (
<WellKnownContext.Provider value={wellKnown}>
{props.children}
</WellKnownContext.Provider>
);
}
export function useWellKnown() {
return useContext(WellKnownContext);
}

View File

@ -1,44 +0,0 @@
/** @type {import("tailwindcss").Config} */
export default {
content: [
"./src/**/*.{js,jsx,ts,tsx}"
],
daisyui: {
themes: [
{
light: {
...require("daisyui/src/theming/themes")["light"],
primary: "#4750a3",
secondary: "#93c5fd",
accent: "#0f766e",
info: "#67e8f9",
success: "#15803d",
warning: "#f97316",
error: "#dc2626",
"--rounded-box": "0",
"--rounded-btn": "0",
"--rounded-badge": "0",
"--tab-radius": "0"
}
},
{
dark: {
...require("daisyui/src/theming/themes")["dark"],
primary: "#4750a3",
secondary: "#93c5fd",
accent: "#0f766e",
info: "#67e8f9",
success: "#15803d",
warning: "#f97316",
error: "#dc2626",
"--rounded-box": "0",
"--rounded-btn": "0",
"--rounded-badge": "0",
"--tab-radius": "0"
}
}
]
},
plugins: [require("daisyui"), require("@tailwindcss/typography")]
};

View File

@ -1,26 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "ES2015", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"jsxImportSource": "solid-js",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@ -1,10 +0,0 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

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