1
0
mirror of https://github.com/thomiceli/opengist.git synced 2025-02-06 10:24:05 +00:00

Search gists on user profile with title, visibility, language & topics (#422)

This commit is contained in:
Thomas Miceli 2025-02-02 18:14:03 +01:00 committed by GitHub
parent 76fc129c09
commit 7aa8f84eff
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 429 additions and 54 deletions

1
go.mod
View File

@ -11,6 +11,7 @@ require (
github.com/go-playground/validator/v10 v10.23.0
github.com/go-webauthn/webauthn v0.11.2
github.com/google/uuid v1.6.0
github.com/gorilla/schema v1.4.1
github.com/gorilla/securecookie v1.1.2
github.com/gorilla/sessions v1.4.0
github.com/labstack/echo/v4 v4.12.0

2
go.sum
View File

@ -126,6 +126,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E=
github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM=
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=

View File

@ -23,6 +23,7 @@ const (
SyncGistPreviews
ResetHooks
IndexGists
SyncGistLanguages
)
var (
@ -73,6 +74,8 @@ func Run(actionType int) {
functionToRun = resetHooks
case IndexGists:
functionToRun = indexGists
case SyncGistLanguages:
functionToRun = syncGistLanguages
default:
log.Error().Msg("Unknown action type")
}
@ -166,3 +169,17 @@ func indexGists() {
}
}
}
func syncGistLanguages() {
log.Info().Msg("Syncing all Gist languages...")
gists, err := db.GetAllGistsRows()
if err != nil {
log.Error().Err(err).Msg("Cannot get gists")
return
}
for _, gist := range gists {
log.Info().Msgf("Syncing languages for gist %d", gist.ID)
gist.UpdateLanguages()
}
}

View File

@ -144,7 +144,7 @@ func Setup(dbUri string) error {
return err
}
if err = db.AutoMigrate(&User{}, &Gist{}, &SSHKey{}, &AdminSetting{}, &Invitation{}, &WebAuthnCredential{}, &TOTP{}, &GistTopic{}); err != nil {
if err = db.AutoMigrate(&User{}, &Gist{}, &SSHKey{}, &AdminSetting{}, &Invitation{}, &WebAuthnCredential{}, &TOTP{}, &GistTopic{}, &GistLanguage{}); err != nil {
return err
}
@ -258,5 +258,5 @@ func DeprecationDBFilename() {
}
func TruncateDatabase() error {
return db.Migrator().DropTable("likes", &User{}, "gists", &SSHKey{}, &AdminSetting{}, &Invitation{}, &WebAuthnCredential{}, &TOTP{}, &GistTopic{})
return db.Migrator().DropTable("likes", &User{}, "gists", &SSHKey{}, &AdminSetting{}, &Invitation{}, &WebAuthnCredential{}, &TOTP{}, &GistTopic{}, &GistLanguage{})
}

View File

@ -6,6 +6,7 @@ import (
"fmt"
"os/exec"
"path/filepath"
"slices"
"strings"
"time"
@ -50,16 +51,16 @@ func (v Visibility) Next() Visibility {
}
}
func ParseVisibility[T string | int](v T) (Visibility, error) {
func ParseVisibility[T string | int](v T) Visibility {
switch s := fmt.Sprint(v); s {
case "0", "public":
return PublicVisibility, nil
return PublicVisibility
case "1", "unlisted":
return UnlistedVisibility, nil
return UnlistedVisibility
case "2", "private":
return PrivateVisibility, nil
return PrivateVisibility
default:
return -1, fmt.Errorf("unknown visibility %q", s)
return PublicVisibility
}
}
@ -84,7 +85,8 @@ type Gist struct {
Forked *Gist `gorm:"foreignKey:ForkedID;constraint:OnUpdate:CASCADE,OnDelete:SET NULL"`
ForkedID uint
Topics []GistTopic `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
Topics []GistTopic `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
Languages []GistLanguage `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
}
type Like struct {
@ -166,25 +168,59 @@ func GetAllGistsFromSearch(currentUserId uint, query string, offset int, sort st
}
func gistsFromUserStatement(fromUserId uint, currentUserId uint) *gorm.DB {
return db.
Where("((gists.private = 0) or (gists.private > 0 and gists.user_id = ?))", currentUserId).
Where("users.id = ?", fromUserId).
Joins("join users on gists.user_id = users.id")
}
func gistsFromUserStatementWithPreloads(fromUserId uint, currentUserId uint) *gorm.DB {
return db.Preload("User").Preload("Forked.User").Preload("Topics").
Where("((gists.private = 0) or (gists.private > 0 and gists.user_id = ?))", currentUserId).
Where("users.id = ?", fromUserId).
Joins("join users on gists.user_id = users.id")
}
func GetAllGistsFromUser(fromUserId uint, currentUserId uint, offset int, sort string, order string) ([]*Gist, error) {
func GetAllGistsFromUser(fromUserId uint, currentUserId uint, title string, language string, visibility string, topics []string, offset int, sort string, order string) ([]*Gist, int64, error) {
var gists []*Gist
err := gistsFromUserStatement(fromUserId, currentUserId).Limit(11).
var count int64
baseQuery := gistsFromUserStatementWithPreloads(fromUserId, currentUserId).Model(&Gist{})
if title != "" {
baseQuery = baseQuery.Where("gists.title like ?", "%"+title+"%")
}
if language != "" {
baseQuery = baseQuery.Joins("join gist_languages on gists.id = gist_languages.gist_id").
Where("gist_languages.language = ?", language)
}
if visibility != "" {
baseQuery = baseQuery.Where("gists.private = ?", ParseVisibility(visibility))
}
if len(topics) > 0 {
baseQuery = baseQuery.Joins("join gist_topics on gists.id = gist_topics.gist_id").
Where("gist_topics.topic in ?", topics)
}
err := baseQuery.Count(&count).Error
if err != nil {
return nil, 0, err
}
err = baseQuery.Limit(11).
Offset(offset * 10).
Order("gists." + sort + "_at " + order).
Find(&gists).Error
return gists, err
return gists, count, err
}
func CountAllGistsFromUser(fromUserId uint, currentUserId uint) (int64, error) {
var count int64
err := gistsFromUserStatement(fromUserId, currentUserId).Model(&Gist{}).Count(&count).Error
err := gistsFromUserStatementWithPreloads(fromUserId, currentUserId).Model(&Gist{}).Count(&count).Error
return count, err
}
@ -258,7 +294,18 @@ func GetAllGistsByIds(ids []uint) ([]*Gist, error) {
Where("id in ?", ids).
Find(&gists).Error
return gists, err
// keep order
ordered := make([]*Gist, 0, len(ids))
for _, wantedId := range ids {
for _, gist := range gists {
if gist.ID == wantedId {
ordered = append(ordered, gist)
break
}
}
}
return ordered, err
}
func (gist *Gist) Create() error {
@ -593,6 +640,47 @@ func DeserialiseInitRepository(user string) (*Gist, error) {
return &gist, nil
}
func (gist *Gist) UpdateLanguages() {
languages, err := gist.GetLanguagesFromFiles()
if err != nil {
log.Error().Err(err).Msgf("Cannot get languages for gist %d", gist.ID)
return
}
slices.Sort(languages)
languages = slices.Compact(languages)
tx := db.Begin()
if tx.Error != nil {
log.Error().Err(tx.Error).Msgf("Cannot start transaction for gist %d", gist.ID)
return
}
if err := tx.Where("gist_id = ?", gist.ID).Delete(&GistLanguage{}).Error; err != nil {
tx.Rollback()
log.Error().Err(err).Msgf("Cannot delete languages for gist %d", gist.ID)
return
}
for _, language := range languages {
gistLanguage := &GistLanguage{
GistID: gist.ID,
Language: language,
}
if err := tx.Create(gistLanguage).Error; err != nil {
tx.Rollback()
log.Error().Err(err).Msgf("Cannot create gist language %s for gist %d", language, gist.ID)
return
}
}
if err := tx.Commit().Error; err != nil {
tx.Rollback()
log.Error().Err(err).Msgf("Cannot commit transaction for gist %d", gist.ID)
return
}
}
func (gist *Gist) ToDTO() (*GistDTO, error) {
files, err := gist.Files("HEAD", false)
if err != nil {
@ -684,6 +772,9 @@ func (gist *Gist) ToIndexedGist() (*index.Gist, error) {
wholeContent := ""
for _, file := range files {
wholeContent += file.Content
if !strings.HasSuffix(wholeContent, "\n") {
wholeContent += "\n"
}
exts = append(exts, filepath.Ext(file.Filename))
}

View File

@ -0,0 +1,27 @@
package db
type GistLanguage struct {
GistID uint `gorm:"primaryKey"`
Language string `gorm:"primaryKey;size:100"`
}
func GetGistLanguagesForUser(fromUserId, currentUserId uint) ([]struct {
Language string
Count int64
}, error) {
var results []struct {
Language string
Count int64
}
err := gistsFromUserStatement(fromUserId, currentUserId).Model(&GistLanguage{}).
Select("language, count(*) as count").
Joins("JOIN gists ON gists.id = gist_languages.gist_id").
Where("gists.user_id = ?", fromUserId).
Group("language").
Order("count DESC").
Limit(15). // Added limit of 15
Find(&results).Error
return results, err
}

View File

@ -46,7 +46,7 @@ func PostReceive(in io.Reader, out, er io.Writer) error {
}
if slices.Contains([]string{"public", "unlisted", "private"}, opts["visibility"]) {
gist.Private, _ = db.ParseVisibility(opts["visibility"])
gist.Private = db.ParseVisibility(opts["visibility"])
outputSb.WriteString(fmt.Sprintf("Gist visibility set to %s\n\n", opts["visibility"]))
}

View File

@ -87,7 +87,15 @@ gist.search.help.filename: gists having files with given name
gist.search.help.extension: gists having files with given extension
gist.search.help.language: gists having files with given language
gist.search.help.topic: gists with given topic
gist.search.placeholder.title: Title
gist.search.placeholder.visibility: Visibility
gist.search.placeholder.public: Public
gist.search.placeholder.unlisted: Unlisted
gist.search.placeholder.private: Private
gist.search.placeholder.language: Language
gist.search.placeholder.all: All
gist.search.placeholder.topics: Topics
gist.search.placeholder.search: Search
gist.forks: Forks
gist.forks.view: View fork
@ -234,6 +242,7 @@ admin.actions.git-gc: Garbage collect all git repositories
admin.actions.sync-previews: Synchronize all gists previews
admin.actions.reset-hooks: Reset Git server hooks for all repositories
admin.actions.index-gists: Index all gists
admin.actions.sync-gist-languages: Synchronize all gists languages
admin.id: ID
admin.user: User
admin.delete: Delete
@ -279,6 +288,7 @@ flash.admin.git-gc: Garbage collecting repositories...
flash.admin.sync-previews: Syncing Gist previews...
flash.admin.reset-hooks: Resetting Git server hooks for all repositories...
flash.admin.index-gists: Indexing all gists...
flash.admin.sync-gist-languages: Syncing Gist languages...
flash.auth.username-exists: Username already exists
flash.auth.invalid-credentials: Invalid credentials

View File

@ -177,7 +177,7 @@ func SearchGists(queryStr string, queryMetadata SearchGistMetadata, gistsIds []u
perPage := 10
offset := (page - 1) * perPage
s := bleve.NewSearchRequestOptions(indexerQuery, perPage, offset, false)
s := bleve.NewSearchRequestOptions(indexerQuery, perPage+1, offset, false)
s.AddFacet("languageFacet", languageFacet)
s.Fields = []string{"GistID"}
s.IncludeLocations = false

View File

@ -40,3 +40,9 @@ func AdminIndexGists(ctx *context.Context) error {
go actions.Run(actions.IndexGists)
return ctx.RedirectTo("/admin-panel")
}
func AdminSyncGistLanguages(ctx *context.Context) error {
ctx.AddFlash(ctx.Tr("flash.admin.sync-gist-languages"), "success")
go actions.Run(actions.SyncGistLanguages)
return ctx.RedirectTo("/admin-panel")
}

View File

@ -48,6 +48,7 @@ func AdminIndex(ctx *context.Context) error {
ctx.SetData("syncGistPreviews", actions.IsRunning(actions.SyncGistPreviews))
ctx.SetData("resetHooks", actions.IsRunning(actions.ResetHooks))
ctx.SetData("indexGists", actions.IsRunning(actions.IndexGists))
ctx.SetData("syncGistLanguages", actions.IsRunning(actions.SyncGistLanguages))
return ctx.Html("admin_index.html")
}
@ -64,7 +65,7 @@ func AdminUsers(ctx *context.Context) error {
return ctx.ErrorRes(500, "Cannot get users", err)
}
if err = handlers.Paginate(ctx, data, pageInt, 10, "data", "admin-panel/users", 1); err != nil {
if err = handlers.Paginate(ctx, data, pageInt, 10, "data", "admin-panel/users", 1, nil); err != nil {
return ctx.ErrorRes(404, ctx.Tr("error.page-not-found"), nil)
}
@ -82,7 +83,7 @@ func AdminGists(ctx *context.Context) error {
return ctx.ErrorRes(500, "Cannot get gists", err)
}
if err = handlers.Paginate(ctx, data, pageInt, 10, "data", "admin-panel/gists", 1); err != nil {
if err = handlers.Paginate(ctx, data, pageInt, 10, "data", "admin-panel/gists", 1, nil); err != nil {
return ctx.ErrorRes(404, ctx.Tr("error.page-not-found"), nil)
}

View File

@ -9,7 +9,8 @@ import (
"github.com/thomiceli/opengist/internal/web/context"
"github.com/thomiceli/opengist/internal/web/handlers"
"gorm.io/gorm"
"html/template"
"slices"
"strings"
)
func AllGists(ctx *context.Context) error {
@ -35,6 +36,11 @@ func AllGists(ctx *context.Context) error {
orderText = ctx.TrH("gist.list.order-by-asc")
}
pagination := &handlers.PaginationParams{
Sort: sort,
Order: order,
}
ctx.SetData("sort", sortText)
ctx.SetData("order", orderText)
@ -51,7 +57,7 @@ func AllGists(ctx *context.Context) error {
if mode == "search" {
ctx.SetData("htmlTitle", ctx.TrH("gist.list.search-results"))
ctx.SetData("searchQuery", ctx.QueryParam("q"))
ctx.SetData("searchQueryUrl", template.URL("&q="+ctx.QueryParam("q")))
pagination.Query = ctx.QueryParam("q")
urlPage = "search"
gists, err = db.GetAllGistsFromSearch(currentUserId, ctx.QueryParam("q"), pageInt-1, sort, order, "")
} else if mode == "topics" {
@ -66,6 +72,7 @@ func AllGists(ctx *context.Context) error {
}
} else {
var fromUser *db.User
var count int64
fromUser, err = db.GetUserByUsername(fromUserStr)
if err != nil {
@ -104,10 +111,39 @@ func AllGists(ctx *context.Context) error {
gists, err = db.GetAllGistsForkedByUser(fromUser.ID, currentUserId, pageInt-1, sort, order)
} else if mode == "fromUser" {
urlPage = fromUserStr
if languages, err := db.GetGistLanguagesForUser(fromUser.ID, currentUserId); err != nil {
return ctx.ErrorRes(500, "Error fetching languages", err)
} else {
ctx.SetData("languages", languages)
}
title := ctx.QueryParam("title")
language := ctx.QueryParam("language")
visibility := ctx.QueryParam("visibility")
topicsStr := ctx.QueryParam("topics")
topics := strings.Fields(topicsStr)
if len(topics) > 10 {
topics = topics[:10]
}
slices.Sort(topics)
topics = slices.Compact(topics)
pagination.Title = title
pagination.Language = language
pagination.Visibility = visibility
pagination.Topics = topicsStr
ctx.SetData("title", title)
ctx.SetData("language", language)
ctx.SetData("visibility", visibility)
ctx.SetData("topics", topicsStr)
ctx.SetData("htmlTitle", ctx.TrH("gist.list.all-from", fromUserStr))
gists, err = db.GetAllGistsFromUser(fromUser.ID, currentUserId, pageInt-1, sort, order)
gists, count, err = db.GetAllGistsFromUser(fromUser.ID, currentUserId, title, language, visibility, topics, pageInt-1, sort, order)
ctx.SetData("countFromUser", count)
}
}
if err != nil {
return ctx.ErrorRes(500, "Error fetching gists", err)
}
renderedGists := make([]*render.RenderedGist, 0, len(gists))
for _, gist := range gists {
@ -118,21 +154,20 @@ func AllGists(ctx *context.Context) error {
renderedGists = append(renderedGists, &rendered)
}
if err != nil {
return ctx.ErrorRes(500, "Error fetching gists", err)
}
if err = handlers.Paginate(ctx, renderedGists, pageInt, 10, "gists", fromUserStr, 2, "&sort="+sort+"&order="+order); err != nil {
if err = handlers.Paginate(ctx, renderedGists, pageInt, 10, "gists", urlPage, 2, pagination); err != nil {
return ctx.ErrorRes(404, ctx.Tr("error.page-not-found"), nil)
}
ctx.SetData("urlPage", urlPage)
return ctx.Html("all.html")
}
func Search(ctx *context.Context) error {
var err error
pagination := &handlers.PaginationParams{
Query: ctx.QueryParam("q"),
}
content, meta := handlers.ParseSearchQueryStr(ctx.QueryParam("q"))
pageInt := handlers.GetPage(ctx)
@ -176,19 +211,12 @@ func Search(ctx *context.Context) error {
renderedGists = append(renderedGists, &rendered)
}
if pageInt > 1 && len(renderedGists) != 0 {
ctx.SetData("prevPage", pageInt-1)
if err = handlers.Paginate(ctx, renderedGists, pageInt, 10, "gists", "search", 2, pagination); err != nil {
return ctx.ErrorRes(404, ctx.Tr("error.page-not-found"), nil)
}
if 10*pageInt < int(nbHits) {
ctx.SetData("nextPage", pageInt+1)
}
ctx.SetData("prevLabel", ctx.TrH("pagination.previous"))
ctx.SetData("nextLabel", ctx.TrH("pagination.next"))
ctx.SetData("urlPage", "search")
ctx.SetData("urlParams", template.URL("&q="+ctx.QueryParam("q")))
ctx.SetData("htmlTitle", ctx.TrH("gist.list.search-results"))
ctx.SetData("nbHits", nbHits)
ctx.SetData("gists", renderedGists)
ctx.SetData("langs", langs)
ctx.SetData("searchQuery", ctx.QueryParam("q"))
return ctx.Html("search.html")

View File

@ -137,6 +137,7 @@ func ProcessCreate(ctx *context.Context) error {
}
gist.AddInIndex()
gist.UpdateLanguages()
return ctx.RedirectTo("/" + user.Username + "/" + gist.Identifier())
}

View File

@ -77,7 +77,7 @@ func Forks(ctx *context.Context) error {
return ctx.ErrorRes(500, "Error getting users who liked this gist", err)
}
if err = handlers.Paginate(ctx, forks, pageInt, 30, "forks", gist.User.Username+"/"+gist.Identifier()+"/forks", 2); err != nil {
if err = handlers.Paginate(ctx, forks, pageInt, 30, "forks", gist.User.Username+"/"+gist.Identifier()+"/forks", 2, nil); err != nil {
return ctx.ErrorRes(404, ctx.Tr("error.page-not-found"), nil)
}

View File

@ -42,7 +42,7 @@ func Likes(ctx *context.Context) error {
return ctx.ErrorRes(500, "Error getting users who liked this gist", err)
}
if err = handlers.Paginate(ctx, likers, pageInt, 30, "likers", gist.User.Username+"/"+gist.Identifier()+"/likes", 1); err != nil {
if err = handlers.Paginate(ctx, likers, pageInt, 30, "likers", gist.User.Username+"/"+gist.Identifier()+"/likes", 1, nil); err != nil {
return ctx.ErrorRes(404, ctx.Tr("error.page-not-found"), nil)
}

View File

@ -19,7 +19,7 @@ func Revisions(ctx *context.Context) error {
return ctx.ErrorRes(500, "Error fetching commits log", err)
}
if err := handlers.Paginate(ctx, commits, pageInt, 10, "commits", userName+"/"+gistName+"/revisions", 2); err != nil {
if err := handlers.Paginate(ctx, commits, pageInt, 10, "commits", userName+"/"+gistName+"/revisions", 2, nil); err != nil {
return ctx.ErrorRes(404, ctx.Tr("error.page-not-found"), nil)
}

View File

@ -2,7 +2,9 @@ package handlers
import (
"errors"
"github.com/gorilla/schema"
"html/template"
"net/url"
"path/filepath"
"strconv"
"strings"
@ -24,7 +26,68 @@ func GetPage(ctx *context.Context) int {
return pageInt
}
func Paginate[T any](ctx *context.Context, data []*T, pageInt int, perPage int, templateDataName string, urlPage string, labels int, urlParams ...string) error {
type PaginationParams struct {
Page int `schema:"page,omitempty"`
Sort string `schema:"sort,omitempty"`
Order string `schema:"order,omitempty"`
Title string `schema:"title,omitempty"`
Visibility string `schema:"visibility,omitempty"`
Language string `schema:"language,omitempty"`
Topics string `schema:"topics,omitempty"`
Query string `schema:"q,omitempty"`
HasPrevious bool `schema:"-"` // Exclude from URL parameters
HasNext bool `schema:"-"`
}
var encoder = schema.NewEncoder()
func (p PaginationParams) String() string {
values := url.Values{}
err := encoder.Encode(p, values)
if err != nil {
return ""
}
if len(values) == 0 {
return ""
}
return "?" + values.Encode()
}
func (p PaginationParams) NextURL() template.URL {
p.Page++
return template.URL(p.String())
}
func (p PaginationParams) PreviousURL() template.URL {
p.Page--
return template.URL(p.String())
}
func (p PaginationParams) WithParams(pairs ...string) template.URL {
values := url.Values{}
_ = encoder.Encode(p, values)
// reset page
values.Del("page")
for i := 0; i < len(pairs); i += 2 {
values.Set(pairs[i], pairs[i+1])
}
return template.URL("?" + values.Encode())
}
func Paginate[T any](ctx *context.Context, data []*T, pageInt int, perPage int, templateDataName string, urlPage string, labels int, params *PaginationParams) error {
var paginationParams PaginationParams
if params == nil {
paginationParams = PaginationParams{}
} else {
paginationParams = *params
}
paginationParams.Page = pageInt
lenData := len(data)
if lenData == 0 && pageInt != 1 {
return errors.New("page not found")
@ -34,15 +97,13 @@ func Paginate[T any](ctx *context.Context, data []*T, pageInt int, perPage int,
if lenData > 1 {
data = data[:lenData-1]
}
ctx.SetData("nextPage", pageInt+1)
paginationParams.HasNext = true
}
if pageInt > 1 {
ctx.SetData("prevPage", pageInt-1)
paginationParams.HasPrevious = true
}
if len(urlParams) > 0 {
ctx.SetData("urlParams", template.URL(urlParams[0]))
}
ctx.SetData("pagination", paginationParams)
switch labels {
case 1:

View File

@ -82,6 +82,7 @@ func (s *Server) registerRoutes() {
sB.POST("/sync-previews", admin.AdminSyncGistPreviews)
sB.POST("/reset-hooks", admin.AdminResetHooks)
sB.POST("/index-gists", admin.AdminIndexGists)
sB.POST("/sync-languages", admin.AdminSyncGistLanguages)
sB.GET("/configuration", admin.AdminConfig)
sB.PUT("/set-config", admin.AdminSetConfig)
}

View File

@ -161,6 +161,40 @@ document.addEventListener('DOMContentLoaded', () => {
};
}
const searchUserGistsVisibility = document.getElementById('search-user-gists-visibility');
if (searchUserGistsVisibility) {
let dropdown = document.getElementById('search-user-gists-visibility-dropdown');
searchUserGistsVisibility.onclick = () => {
dropdown!.classList.toggle('hidden');
};
let buttons = dropdown.querySelectorAll('button');
buttons.forEach((button) => {
button.onclick = () => {
let value = document.getElementById('visibility-value') as HTMLInputElement;
value.textContent = button.dataset.visibilityStr;
dropdown!.classList.add('hidden');
dropdown.querySelector('input')!.value = button.dataset.visibility || '';
};
});
}
const searchUserGistsLanguage = document.getElementById('search-user-gists-language');
if (searchUserGistsLanguage) {
let dropdown = document.getElementById('search-user-gists-language-dropdown');
searchUserGistsLanguage.onclick = () => {
dropdown!.classList.toggle('hidden');
};
let buttons = dropdown.querySelectorAll('button');
buttons.forEach((button) => {
button.onclick = () => {
let value = document.getElementById('language-value') as HTMLInputElement;
value.textContent = button.dataset.languageStr;
dropdown!.classList.add('hidden');
dropdown.querySelector('input')!.value = button.dataset.language || '';
};
});
}
document.getElementById('language-btn')!.onclick = () => {
document.getElementById('language-list')!.classList.toggle('hidden');
};

View File

@ -92,6 +92,12 @@
{{ .locale.Tr "admin.actions.index-gists" }}
</button>
</form>
<form action="{{ $.c.ExternalUrl }}/admin-panel/sync-languages" method="POST">
{{ .csrfHtml }}
<button type="submit" {{ if .syncGistLanguages }}disabled="disabled"{{ end }} class="whitespace-nowrap text-slate-700 dark:text-slate-300{{ if .syncGistLanguages }} text-slate-500 cursor-not-allowed {{ end }}rounded border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 px-2.5 py-2 text-xs font-medium text-gray-700 dark:text-white shadow-sm hover:bg-gray-100 dark:hover:bg-gray-700 hover:border-gray-500 hover:text-slate-700 dark:hover:text-slate-300 focus:outline-none focus:ring-1 focus:border-primary-500 focus:ring-primary-500 leading-3">
{{ .locale.Tr "admin.actions.sync-gist-languages" }}
</button>
</form>
</div>
</div>
</div>

View File

@ -43,22 +43,22 @@
</div>
<div id="sort-gists-dropdown" class="hidden absolute right-0 z-10 mt-2 w-max origin-top-right divide-y divide-gray-200 dark:divide-gray-700 rounded-md rounded border border-gray-200 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 shadow-lg ring-1 ring-white dark:ring-black ring-opacity-5 focus:outline-none" role="menu" aria-orientation="vertical" aria-labelledby="menu-button" tabindex="-1">
<div class="" role="none">
<a href="{{ $.c.ExternalUrl }}/{{ .urlPage }}?sort=created&order=desc{{.searchQueryUrl}}" class="text-slate-700 dark:text-slate-300 group flex items-center px-3 py-2 text-xs hover:bg-gray-200 dark:hover:bg-gray-700 hover:text-black dark:hover:text-white hover:text-white hover:bg-primary-500 hover:rounded-t-md" role="menuitem">
<a href="{{ $.c.ExternalUrl }}/{{ .urlPage }}{{ .pagination.WithParams "sort" "created" "order" "desc" }}" class="text-slate-700 dark:text-slate-300 group flex items-center px-3 py-2 text-xs hover:bg-gray-200 dark:hover:bg-gray-700 hover:text-black dark:hover:text-white hover:text-white hover:bg-primary-500 hover:rounded-t-md" role="menuitem">
{{ .locale.Tr "gist.list.order-by-desc" }} {{ .locale.Tr "gist.list.sort-by-created" }}
</a>
</div>
<div class="" role="none">
<a href="{{ $.c.ExternalUrl }}/{{ .urlPage }}?sort=created&order=asc{{.searchQueryUrl}}" class="text-slate-700 dark:text-slate-300 group flex items-center px-3 py-2 text-xs hover:bg-gray-200 dark:hover:bg-gray-700 hover:text-black dark:hover:text-white hover:text-white hover:bg-primary-500" role="menuitem">
<a href="{{ $.c.ExternalUrl }}/{{ .urlPage }}{{ .pagination.WithParams "sort" "created" "order" "asc" }}" class="text-slate-700 dark:text-slate-300 group flex items-center px-3 py-2 text-xs hover:bg-gray-200 dark:hover:bg-gray-700 hover:text-black dark:hover:text-white hover:text-white hover:bg-primary-500" role="menuitem">
{{ .locale.Tr "gist.list.order-by-asc" }} {{ .locale.Tr "gist.list.sort-by-created" }}
</a>
</div>
<div class="" role="none">
<a href="{{ $.c.ExternalUrl }}/{{ .urlPage }}?sort=updated&order=desc{{.searchQueryUrl}}" class="text-slate-700 dark:text-slate-300 group flex items-center px-3 py-2 text-xs hover:bg-gray-200 dark:hover:bg-gray-700 hover:text-black dark:hover:text-white hover:text-white hover:bg-primary-500" role="menuitem">
<a href="{{ $.c.ExternalUrl }}/{{ .urlPage }}{{ .pagination.WithParams "sort" "updated" "order" "desc" }}" class="text-slate-700 dark:text-slate-300 group flex items-center px-3 py-2 text-xs hover:bg-gray-200 dark:hover:bg-gray-700 hover:text-black dark:hover:text-white hover:text-white hover:bg-primary-500" role="menuitem">
{{ .locale.Tr "gist.list.order-by-desc" }} {{ .locale.Tr "gist.list.sort-by-updated" }}
</a>
</div>
<div class="" role="none">
<a href="{{ $.c.ExternalUrl }}/{{ .urlPage }}?sort=updated&order=asc{{.searchQueryUrl}}" class="text-slate-700 dark:text-slate-300 group flex items-center px-3 py-2 text-xs hover:bg-gray-200 dark:hover:bg-gray-700 hover:text-black dark:hover:text-white hover:text-white hover:bg-primary-500 hover:rounded-b-md" role="menuitem">
<a href="{{ $.c.ExternalUrl }}/{{ .urlPage }}{{ .pagination.WithParams "sort" "updated" "order" "asc" }}" class="text-slate-700 dark:text-slate-300 group flex items-center px-3 py-2 text-xs hover:bg-gray-200 dark:hover:bg-gray-700 hover:text-black dark:hover:text-white hover:text-white hover:bg-primary-500 hover:rounded-b-md" role="menuitem">
{{ .locale.Tr "gist.list.order-by-asc" }} {{ .locale.Tr "gist.list.sort-by-updated" }}
</a>
</div>
@ -115,6 +115,95 @@
{{ end }}
</header>
<main>
{{if eq .mode "fromUser"}}
<form action="{{ $.c.ExternalUrl }}/{{ .fromUser.Username }}">
<div class="grid grid-cols-12 gap-x-1 pb-4">
<div class="col-span-3">
<input type="text" name="title" value="{{ .title }}" placeholder="{{ .locale.Tr "gist.search.placeholder.title"}}" class="bg-white dark:bg-gray-900 shadow-sm focus:ring-primary-500 focus:border-primary-500 block w-full sm:text-xs border-gray-200 dark:border-gray-700 rounded-md py-1.5" />
</div>
<div class="col-span-2">
<div class="">
<div class="relative text-left">
<div>
<button type="button" class="w-full flex text-slate-700 dark:text-slate-300 rounded border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 px-2.5 py-2 text-xs font-medium text-gray-700 dark:text-white shadow-sm hover:bg-gray-100 dark:hover:bg-gray-700 hover:border-gray-500 hover:text-slate-700 dark:hover:text-slate-300 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 leading-3" id="search-user-gists-visibility">
<span class="text-gray-700 dark:text-gray-300">{{ .locale.Tr "gist.search.placeholder.visibility" }} :
<span id="visibility-value" class="text-slate-700 dark:text-slate-300">
{{ if eq .visibility "public" }}{{ .locale.Tr "gist.search.placeholder.public" }}
{{ else if eq .visibility "unlisted" }}{{ .locale.Tr "gist.search.placeholder.unlisted" }}
{{ else if eq .visibility "private" }}{{ .locale.Tr "gist.search.placeholder.private" }}
{{ else }}{{ .locale.Tr "gist.search.placeholder.all" }}
{{ end }}
</span>
</span>
<svg class="-mr-1 ml-2 h-3 w-3" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z" clip-rule="evenodd" />
</svg>
</button>
</div>
<div id="search-user-gists-visibility-dropdown" class="hidden absolute left-0 z-10 mt-2 w-max origin-top-right divide-y divide-gray-200 dark:divide-gray-700 rounded-md rounded border border-gray-200 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 shadow-lg ring-1 ring-white dark:ring-black ring-opacity-5 focus:outline-none" role="menu" aria-orientation="vertical" aria-labelledby="menu-button" tabindex="-1">
<div class="" role="none">
<button type="button" data-visibility="" data-visibility-str="{{ .locale.Tr "gist.search.placeholder.all" }}" class="text-slate-700 dark:text-slate-300 w-full flex px-3 py-2 text-xs hover:bg-gray-200 dark:hover:bg-gray-700 hover:text-black dark:hover:text-white hover:text-white hover:bg-primary-500 hover:rounded-t-md" role="menuitem">
{{ .locale.Tr "gist.search.placeholder.all" }}
</button>
</div>
<div class="" role="none">
<button type="button" data-visibility="public" data-visibility-str="{{ .locale.Tr "gist.search.placeholder.public" }}" class="text-slate-700 dark:text-slate-300 w-full flex px-3 py-2 text-xs hover:bg-gray-200 dark:hover:bg-gray-700 hover:text-black dark:hover:text-white hover:text-white hover:bg-primary-500" role="menuitem">
{{ .locale.Tr "gist.search.placeholder.public" }}
</button>
</div>
<div class="" role="none">
<button type="button" data-visibility="unlisted" data-visibility-str="{{ .locale.Tr "gist.search.placeholder.unlisted" }}" class="text-slate-700 dark:text-slate-300 w-full flex px-3 py-2 text-xs hover:bg-gray-200 dark:hover:bg-gray-700 hover:text-black dark:hover:text-white hover:text-white hover:bg-primary-500" role="menuitem">
{{ .locale.Tr "gist.search.placeholder.unlisted" }}
</button>
</div>
<div class="" role="none">
<button type="button" data-visibility="private" data-visibility-str="{{ .locale.Tr "gist.search.placeholder.private" }}" class="text-slate-700 dark:text-slate-300 w-full flex px-3 py-2 text-xs hover:bg-gray-200 dark:hover:bg-gray-700 hover:text-black dark:hover:text-white hover:text-white hover:bg-primary-500 hover:rounded-b-md" role="menuitem">
{{ .locale.Tr "gist.search.placeholder.private" }}
</button>
</div>
<input type="hidden" name="visibility" value="{{ .visibility }}" />
</div>
</div>
</div>
</div>
<div class="col-span-3">
<div class="align-middle items-center">
<div class="relative text-left">
<div>
<button type="button" class="w-full flex text-slate-700 dark:text-slate-300 rounded border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 px-2.5 py-2 text-xs font-medium text-gray-700 dark:text-white shadow-sm hover:bg-gray-100 dark:hover:bg-gray-700 hover:border-gray-500 hover:text-slate-700 dark:hover:text-slate-300 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 leading-3" id="search-user-gists-language">
<span class="text-gray-700 dark:text-gray-300">{{ .locale.Tr "gist.search.placeholder.language" }} :
<span id="language-value" class="text-slate-700 dark:text-slate-300">
{{ if eq .language "" }}{{ .locale.Tr "gist.search.placeholder.all" }}
{{ else }}{{ .language }}
{{ end }}</span></span>
<svg class="-mr-1 ml-2 h-3 w-3" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z" clip-rule="evenodd" />
</svg>
</button>
</div>
<div id="search-user-gists-language-dropdown" class="hidden absolute left-0 z-10 mt-2 w-max origin-top-right divide-y divide-gray-200 dark:divide-gray-700 rounded-md rounded border border-gray-200 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 shadow-lg ring-1 ring-white dark:ring-black ring-opacity-5 focus:outline-none" role="menu" aria-orientation="vertical" aria-labelledby="menu-button" tabindex="-1">
<button type="button" data-language="" data-language-str="{{ .locale.Tr "gist.search.placeholder.all" }}" class="text-slate-700 dark:text-slate-300 w-full flex px-3 py-2 text-xs hover:bg-gray-200 dark:hover:bg-gray-700 hover:text-black dark:hover:text-white hover:text-white hover:bg-primary-500 first:hover:rounded-t-md last:hover:rounded-b-md" role="menuitem">
{{ .locale.Tr "gist.search.placeholder.all" }}
</button>
{{ range .languages }}
<button type="button" data-language="{{ .Language }}" data-language-str="{{ .Language }}" class="text-slate-700 dark:text-slate-300 w-full flex px-3 py-2 text-xs hover:bg-gray-200 dark:hover:bg-gray-700 hover:text-black dark:hover:text-white hover:text-white hover:bg-primary-500 first:hover:rounded-t-md last:hover:rounded-b-md" role="menuitem">
{{ .Language }} ({{ .Count }})
</button>
{{ end }}
<input type="hidden" name="language" value="{{ .language }}" />
</div>
</div>
</div>
</div>
<div class="col-span-2">
<input type="text" name="topics" value="{{ .topics }}" placeholder="{{ .locale.Tr "gist.search.placeholder.topics"}}" class="bg-white dark:bg-gray-900 shadow-sm focus:ring-primary-500 focus:border-primary-500 block w-full sm:text-xs border-gray-200 dark:border-gray-700 rounded-md py-1.5" />
</div>
<div class="col-span-2">
<button type="submit" class="w-full px-4 py-1.5 border border-transparent border-gray-200 dark:border-gray-700 text-xs font-medium rounded-md shadow-sm text-white dark:text-white bg-primary-500 hover:bg-primary-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500">{{ .locale.Tr "gist.search.placeholder.search" }}</button>
</div>
</div>
</form>
{{ end }}
<div>
{{ if ne (len .gists) 0 }}
{{ range $gist := .gists }}

View File

@ -1,7 +1,7 @@
{{ define "_pagination" }}
<div class="flex justify-center space-x-2">
{{ if .prevPage }}
<a href="{{ $.c.ExternalUrl }}/{{ .urlPage }}?page={{ .prevPage }}{{ .urlParams }}" class="relative inline-flex items-center space-x-2 rounded-md border border-white dark:border-gray-900 bg-white dark:bg-gray-900 px-2 py-1.5 font-medium text-slate-700 dark:text-slate-300 hover:border-gray-200 dark:hover:border-gray-400 hover:text-slate-700 dark:hover:text-slate-300 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 text-sm leading-4">
{{ if .pagination.HasPrevious }}
<a href="{{ $.c.ExternalUrl }}/{{ .urlPage }}{{ .pagination.PreviousURL }}" class="relative inline-flex items-center space-x-2 rounded-md border border-white dark:border-gray-900 bg-white dark:bg-gray-900 px-2 py-1.5 font-medium text-slate-700 dark:text-slate-300 hover:border-gray-200 dark:hover:border-gray-400 hover:text-slate-700 dark:hover:text-slate-300 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 text-sm leading-4">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="mr-1 w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
@ -14,8 +14,8 @@
</svg>
{{ .prevLabel }}</span>
{{ end }}
{{ if .nextPage }}
<a href="{{ $.c.ExternalUrl }}/{{ .urlPage }}?page={{ .nextPage }}{{ .urlParams }}" class="relative inline-flex items-center space-x-2 rounded-md border border-white dark:border-gray-900 bg-white dark:bg-gray-900 px-2 py-1.5 font-medium text-slate-700 dark:text-slate-300 hover:border-gray-200 dark:hover:border-gray-400 hover:text-slate-700 dark:hover:text-slate-300 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 text-sm leading-4">{{ .nextLabel }}
{{ if .pagination.HasNext }}
<a href="{{ $.c.ExternalUrl }}/{{ .urlPage }}{{ .pagination.NextURL }}" class="relative inline-flex items-center space-x-2 rounded-md border border-white dark:border-gray-900 bg-white dark:bg-gray-900 px-2 py-1.5 font-medium text-slate-700 dark:text-slate-300 hover:border-gray-200 dark:hover:border-gray-400 hover:text-slate-700 dark:hover:text-slate-300 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 text-sm leading-4">{{ .nextLabel }}
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="ml-1 w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>