mirror of
https://github.com/thomiceli/opengist.git
synced 2026-08-07 07:14:49 +00:00
UI rework (#757)
This commit is contained in:
@@ -59,6 +59,15 @@ func IsRunning(actionType int) bool {
|
||||
return actionType >= 0 && actionType < numActions && running[actionType].Load()
|
||||
}
|
||||
|
||||
func IsPeriodic(actionType int) bool {
|
||||
a, ok := registry[actionType]
|
||||
return ok && a.spec != ""
|
||||
}
|
||||
|
||||
func Spec(actionType int) string {
|
||||
return registry[actionType].spec
|
||||
}
|
||||
|
||||
func RunOnce(actionType int) {
|
||||
a, ok := registry[actionType]
|
||||
if !ok {
|
||||
@@ -89,6 +98,7 @@ func RunOnce(actionType int) {
|
||||
}()
|
||||
|
||||
log.Info().Msgf("Starting running action %d", actionType)
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
a.run()
|
||||
log.Info().Msgf("Finished running action %d", actionType)
|
||||
}
|
||||
|
||||
@@ -129,10 +129,10 @@ func TestCmdAdminCreateUser_MissingPassword(t *testing.T) {
|
||||
|
||||
func TestCmdAdminCreateUser_InvalidUsername(t *testing.T) {
|
||||
// Reserved names must be rejected with the same rules as the web form.
|
||||
err := runCreateUser(t, "--username", "login", "--password", "pw")
|
||||
err := runCreateUser(t, "--username", "oauth", "--password", "pw")
|
||||
require.Error(t, err)
|
||||
|
||||
_, fetchErr := db.GetUserByUsername("login")
|
||||
_, fetchErr := db.GetUserByUsername("oauth")
|
||||
require.Error(t, fetchErr, "reserved user must not be persisted")
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,11 @@ import (
|
||||
// keyed by Action (the action's identifier); LockedUntil holds the Unix
|
||||
// timestamp the current lease expires at (0 = free).
|
||||
type ActionLock struct {
|
||||
// autoIncrement:false is required: Action holds the action's own identifier,
|
||||
// and the first action (SyncReposFromFS) is 0. Without this, GORM treats the
|
||||
// int primary key as auto-increment and, on Create with the zero value, lets
|
||||
// the DB assign an id instead — so the action=0 row never exists and its lock
|
||||
// can never be acquired.
|
||||
Action int `gorm:"primaryKey;autoIncrement:false"`
|
||||
LockedUntil int64
|
||||
}
|
||||
|
||||
@@ -167,6 +167,94 @@ func GetAllGistsForCurrentUser(currentUserId uint, since *time.Time, offset int,
|
||||
return gists, err
|
||||
}
|
||||
|
||||
// GetAllGistsForCurrentUserFiltered is the filterable variant of
|
||||
// GetAllGistsForCurrentUser used by the explore "All gists" page. It applies the
|
||||
// same visibility rule (public gists plus currentUserId's own) and optionally
|
||||
// narrows by title, language, visibility and topics, returning the total match
|
||||
// count alongside the page of results.
|
||||
func GetAllGistsForCurrentUserFiltered(currentUserId uint, title string, language string, visibility string, topics []string, offset int, sort string, order string) ([]*Gist, int64, error) {
|
||||
var gists []*Gist
|
||||
var count int64
|
||||
|
||||
baseQuery := db.Preload("User").Preload("Forked.User").Preload("Topics").
|
||||
Where("gists.private = 0 or gists.user_id = ?", 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, count, err
|
||||
}
|
||||
|
||||
// GetAllGistsByTopicFiltered returns gists carrying `topic` that are visible to
|
||||
// currentUserId, optionally narrowed further by the faceted filter params
|
||||
// (title, language, visibility and extra topics). The path topic is always
|
||||
// required; any extra topics from the filter are ANDed on top so the result
|
||||
// stays scoped to the topic page.
|
||||
func GetAllGistsByTopicFiltered(currentUserId uint, topic string, title string, language string, visibility string, topics []string, offset int, sort string, order string) ([]*Gist, int64, error) {
|
||||
var gists []*Gist
|
||||
var count int64
|
||||
|
||||
baseQuery := db.Preload("User").Preload("Forked.User").Preload("Topics").
|
||||
Where("((gists.private = 0) or (gists.private > 0 and gists.user_id = ?))", currentUserId).
|
||||
Where("exists (select 1 from gist_topics where gist_topics.gist_id = gists.id and gist_topics.topic = ?)", topic).
|
||||
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))
|
||||
}
|
||||
|
||||
for _, t := range topics {
|
||||
baseQuery = baseQuery.Where("exists (select 1 from gist_topics where gist_topics.gist_id = gists.id and gist_topics.topic = ?)", t)
|
||||
}
|
||||
|
||||
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, count, err
|
||||
}
|
||||
|
||||
// GetAllGistsFromUserVisibleTo returns gists owned by fromUserId, filtered
|
||||
// to what currentUserId is allowed to see (public always; private/unlisted
|
||||
// only when currentUserId == fromUserId). Same pagination/since shape as
|
||||
@@ -344,6 +432,26 @@ func CountAllGistsLikedByUser(fromUserId uint, currentUserId uint) (int64, error
|
||||
return count, err
|
||||
}
|
||||
|
||||
func likedAllStatement(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("exists (select 1 from likes where likes.gist_id = gists.id)")
|
||||
}
|
||||
|
||||
func GetAllGistsLiked(currentUserId uint, since *time.Time, offset int, sort string, order string, limit int, perPage int) ([]*Gist, error) {
|
||||
var gists []*Gist
|
||||
query := likedAllStatement(currentUserId)
|
||||
if since != nil {
|
||||
query = query.Where("gists.updated_at >= ?", since.Unix())
|
||||
}
|
||||
err := query.
|
||||
Limit(limit).
|
||||
Offset(offset * perPage).
|
||||
Order("gists." + sort + "_at " + order).
|
||||
Find(&gists).Error
|
||||
return gists, err
|
||||
}
|
||||
|
||||
func forkedStatement(fromUserId uint, currentUserId uint) *gorm.DB {
|
||||
return db.Preload("User").Preload("Forked.User").Preload("Topics").
|
||||
Where("gists.forked_id is not null and ((gists.private = 0) or (gists.private > 0 and gists.user_id = ?))", currentUserId).
|
||||
@@ -375,6 +483,29 @@ func CountAllGistsForkedByUser(fromUserId uint, currentUserId uint) (int64, erro
|
||||
return count, err
|
||||
}
|
||||
|
||||
// forkedAllStatement scopes the explore "recently forked" feed to the source
|
||||
// gists that have been forked from - i.e. gists that at least one other gist
|
||||
// points to via forked_id - rather than the fork copies themselves.
|
||||
func forkedAllStatement(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("exists (select 1 from gists as forks where forks.forked_id = gists.id)")
|
||||
}
|
||||
|
||||
func GetAllGistsForked(currentUserId uint, since *time.Time, offset int, sort string, order string, limit int, perPage int) ([]*Gist, error) {
|
||||
var gists []*Gist
|
||||
query := forkedAllStatement(currentUserId)
|
||||
if since != nil {
|
||||
query = query.Where("gists.updated_at >= ?", since.Unix())
|
||||
}
|
||||
err := query.
|
||||
Limit(limit).
|
||||
Offset(offset * perPage).
|
||||
Order("gists." + sort + "_at " + order).
|
||||
Find(&gists).Error
|
||||
return gists, err
|
||||
}
|
||||
|
||||
// applySince narrows a gist query to rows updated at or after `since` when it
|
||||
// is non-nil, matching the filter the API list queries apply. Kept separate so
|
||||
// the count helpers stay in sync with their Find counterparts.
|
||||
@@ -1005,6 +1136,29 @@ type VisibilityDTO struct {
|
||||
Private Visibility `validate:"number,min=0,max=2" form:"private"`
|
||||
}
|
||||
|
||||
// GistMetadataDTO carries the editable gist metadata (title, URL path,
|
||||
// description, topics) without the files, so the settings page can update them
|
||||
// independently of the gist content.
|
||||
type GistMetadataDTO struct {
|
||||
Title string `validate:"max=250" form:"title"`
|
||||
Description string `validate:"max=1000" form:"description"`
|
||||
URL string `validate:"max=32,alphanumdashorempty" form:"url"`
|
||||
Topics string `validate:"gisttopics" form:"topics"`
|
||||
}
|
||||
|
||||
func (dto *GistMetadataDTO) ToExistingGist(gist *Gist) *Gist {
|
||||
gist.Title = dto.Title
|
||||
gist.Description = dto.Description
|
||||
gist.URL = dto.URL
|
||||
topics := strings.Fields(dto.Topics)
|
||||
gistTopics := make([]GistTopic, 0, len(topics))
|
||||
for _, topic := range topics {
|
||||
gistTopics = append(gistTopics, GistTopic{Topic: topic})
|
||||
}
|
||||
gist.Topics = gistTopics
|
||||
return gist
|
||||
}
|
||||
|
||||
type FileDTO struct {
|
||||
Filename string `validate:"excludes=\x2f,excludes=\x5c,max=255"`
|
||||
Content string
|
||||
|
||||
@@ -27,3 +27,53 @@ func GetGistLanguagesForUser(fromUserId, currentUserId uint) ([]struct {
|
||||
|
||||
return results, err
|
||||
}
|
||||
|
||||
// GetGistLanguages returns the most common languages across every gist visible
|
||||
// to currentUserId (public gists plus their own). Used to populate the language
|
||||
// facet on the explore "All gists" filter.
|
||||
func GetGistLanguages(currentUserId uint) ([]struct {
|
||||
Language string
|
||||
Count int64
|
||||
}, error) {
|
||||
var results []struct {
|
||||
Language string
|
||||
Count int64
|
||||
}
|
||||
|
||||
err := db.Model(&GistLanguage{}).
|
||||
Select("language, count(*) as count").
|
||||
Joins("JOIN gists ON gists.id = gist_languages.gist_id").
|
||||
Where("((gists.private = 0) or (gists.private > 0 and gists.user_id = ?))", currentUserId).
|
||||
Group("language").
|
||||
Order("count DESC").
|
||||
Limit(15).
|
||||
Find(&results).Error
|
||||
|
||||
return results, err
|
||||
}
|
||||
|
||||
// GetGistLanguagesByTopic returns the most common languages across the gists
|
||||
// carrying `topic` that are visible to currentUserId. Used to populate the
|
||||
// language facet on the /-/topics/{topic} filter so it only lists languages
|
||||
// present among that topic's gists.
|
||||
func GetGistLanguagesByTopic(currentUserId uint, topic string) ([]struct {
|
||||
Language string
|
||||
Count int64
|
||||
}, error) {
|
||||
var results []struct {
|
||||
Language string
|
||||
Count int64
|
||||
}
|
||||
|
||||
err := db.Model(&GistLanguage{}).
|
||||
Select("language, count(*) as count").
|
||||
Joins("JOIN gists ON gists.id = gist_languages.gist_id").
|
||||
Where("((gists.private = 0) or (gists.private > 0 and gists.user_id = ?))", currentUserId).
|
||||
Where("exists (select 1 from gist_topics where gist_topics.gist_id = gists.id and gist_topics.topic = ?)", topic).
|
||||
Group("language").
|
||||
Order("count DESC").
|
||||
Limit(15).
|
||||
Find(&results).Error
|
||||
|
||||
return results, err
|
||||
}
|
||||
|
||||
@@ -4,3 +4,28 @@ type GistTopic struct {
|
||||
GistID uint `gorm:"primaryKey"`
|
||||
Topic string `gorm:"primaryKey;size:50"`
|
||||
}
|
||||
|
||||
// TopicCount is a topic together with the number of gists tagged with it.
|
||||
type TopicCount struct {
|
||||
Topic string
|
||||
Count int64
|
||||
}
|
||||
|
||||
// GetTopicsWithCount returns topics in use, together with how many gists carry
|
||||
// each, ordered from most to least used and paginated (limit is typically
|
||||
// perPage+1 so callers can detect a following page). Only topics on gists
|
||||
// visible to currentUserId are counted (public gists, plus the user's own
|
||||
// private ones).
|
||||
func GetTopicsWithCount(currentUserId uint, offset int, limit int, perPage int) ([]*TopicCount, error) {
|
||||
var topics []*TopicCount
|
||||
err := db.Model(&GistTopic{}).
|
||||
Select("gist_topics.topic as topic, count(*) as count").
|
||||
Joins("join gists on gists.id = gist_topics.gist_id").
|
||||
Where("gists.private = 0 or gists.user_id = ?", currentUserId).
|
||||
Group("gist_topics.topic").
|
||||
Order("count desc, gist_topics.topic asc").
|
||||
Limit(limit).
|
||||
Offset(offset * perPage).
|
||||
Find(&topics).Error
|
||||
return topics, err
|
||||
}
|
||||
|
||||
@@ -124,6 +124,70 @@ func GetAllUsers(offset int) ([]*User, error) {
|
||||
return users, err
|
||||
}
|
||||
|
||||
// UserWithNbGists is a user together with the number of gists they own that are
|
||||
// visible to the viewer (public gists, plus the viewer's own private ones).
|
||||
type UserWithNbGists struct {
|
||||
*User
|
||||
NbGists int64
|
||||
}
|
||||
|
||||
// GetUsersWithGistCounts returns a paginated, sorted page of users for the
|
||||
// explore "Users" list, each annotated with how many gists they own that are
|
||||
// visible to currentUserId. When query is non-empty, users are filtered to those
|
||||
// whose username contains it (case-insensitive). sortColumn must be a
|
||||
// whitelisted column name ("username_normalized" or "created_at") and order
|
||||
// "asc" or "desc"; both are validated by the caller. limit is typically
|
||||
// perPage+1 so callers can detect a following page.
|
||||
func GetUsersWithGistCounts(currentUserId uint, query string, offset int, limit int, perPage int, sortColumn string, order string) ([]*UserWithNbGists, error) {
|
||||
var users []*User
|
||||
stmt := db.Model(&User{})
|
||||
if query != "" {
|
||||
stmt = stmt.Where("username_normalized LIKE ?", "%"+strings.ToLower(query)+"%")
|
||||
}
|
||||
if err := stmt.
|
||||
Order(sortColumn + " " + order).
|
||||
Limit(limit).
|
||||
Offset(offset * perPage).
|
||||
Find(&users).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make([]*UserWithNbGists, 0, len(users))
|
||||
if len(users) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
ids := make([]uint, len(users))
|
||||
for i, u := range users {
|
||||
ids[i] = u.ID
|
||||
}
|
||||
|
||||
type countRow struct {
|
||||
UserID uint
|
||||
Count int64
|
||||
}
|
||||
var rows []countRow
|
||||
if err := db.Model(&Gist{}).
|
||||
Select("user_id, count(*) as count").
|
||||
Where("user_id IN ?", ids).
|
||||
Where("private = 0 or user_id = ?", currentUserId).
|
||||
Group("user_id").
|
||||
Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
counts := make(map[uint]int64, len(rows))
|
||||
for _, r := range rows {
|
||||
counts[r.UserID] = r.Count
|
||||
}
|
||||
|
||||
for _, u := range users {
|
||||
result = append(result, &UserWithNbGists{User: u, NbGists: counts[u.ID]})
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func GetUserByUsername(username string) (*User, error) {
|
||||
user := new(User)
|
||||
err := db.
|
||||
@@ -301,6 +365,7 @@ type UserUsernameDTO struct {
|
||||
|
||||
type UserStyleDTO struct {
|
||||
SoftWrap bool `form:"softwrap" json:"soft_wrap"`
|
||||
ThemeColor string `form:"themecolor" json:"theme_color" validate:"omitempty,themecolor"`
|
||||
RemovedLineColor string `form:"removedlinecolor" json:"removed_line_color" validate:"min=0,max=7"`
|
||||
AddedLineColor string `form:"addedlinecolor" json:"added_line_color" validate:"min=0,max=7"`
|
||||
GitLineColor string `form:"gitlinecolor" json:"git_line_color" validate:"min=0,max=7"`
|
||||
|
||||
@@ -21,8 +21,7 @@ func (mt MimeType) IsText() bool {
|
||||
}
|
||||
|
||||
func (mt MimeType) IsCSV() bool {
|
||||
return strings.HasPrefix(mt.ContentType, "text/csv") &&
|
||||
(strings.HasSuffix(mt.extension, ".csv"))
|
||||
return strings.HasSuffix(mt.extension, ".csv") && mt.IsText()
|
||||
}
|
||||
|
||||
func (mt MimeType) IsImage() bool {
|
||||
|
||||
@@ -289,8 +289,6 @@ admin.versions: الإصدارات
|
||||
admin.ssh_keys: مفاتيح SSH
|
||||
admin.stats: الإحصائيات
|
||||
admin.actions: الإجراءات
|
||||
admin.actions.sync-fs: مزامنة المقاطع من نظام الملفات
|
||||
admin.actions.sync-db: مزامنة المقاطع من قاعدة البيانات
|
||||
admin.actions.git-gc: تنفيذ جمع القمامة لجميع مستودعات Git
|
||||
admin.actions.sync-previews: مزامنة معاينات جميع المقاطع
|
||||
admin.actions.reset-hooks: إعادة تعيين خطافات خادم Git لجميع المستودعات
|
||||
@@ -335,13 +333,6 @@ flash.admin.user-deleted: تم حذف المستخدم
|
||||
flash.admin.gist-deleted: تم حذف المقطع
|
||||
flash.admin.invitation-created: تم إنشاء الدعوة
|
||||
flash.admin.invitation-deleted: تم حذف الدعوة
|
||||
flash.admin.sync-fs: تتم مزامنة المستودعات من نظام الملفات...
|
||||
flash.admin.sync-db: تتم مزامنة المستودعات من قاعدة البيانات...
|
||||
flash.admin.git-gc: جارٍ تنفيذ جمع القمامة للمستودعات...
|
||||
flash.admin.sync-previews: تتم مزامنة معاينات المقاطع...
|
||||
flash.admin.reset-hooks: جارٍ إعادة تعيين خطافات خادم Git لجميع المستودعات...
|
||||
flash.admin.index-gists: جارٍ إعادة بناء فهرس البحث...
|
||||
flash.admin.sync-gist-languages: تتم مزامنة لغات المقاطع...
|
||||
|
||||
flash.auth.username-exists: اسم المستخدم موجود بالفعل
|
||||
flash.auth.invalid-credentials: بيانات الاعتماد غير صالحة
|
||||
|
||||
@@ -155,8 +155,6 @@ admin.versions: Verze
|
||||
admin.ssh_keys: SSH klíče
|
||||
admin.stats: Statistiky
|
||||
admin.actions: Akce
|
||||
admin.actions.sync-fs: Synchronizovat gisty ze souborového systému
|
||||
admin.actions.sync-db: Synchronizovat gisty z databáze
|
||||
admin.actions.git-gc: Garbage collect git repozitářů
|
||||
admin.id: ID
|
||||
admin.user: Uživatel
|
||||
@@ -201,14 +199,8 @@ flash.admin.user-deleted: ''
|
||||
flash.admin.gist-deleted: ''
|
||||
flash.admin.invitation-created: ''
|
||||
flash.admin.invitation-deleted: ''
|
||||
flash.admin.sync-fs: ''
|
||||
flash.admin.sync-db: ''
|
||||
flash.admin.git-gc: ''
|
||||
flash.admin.sync-previews: ''
|
||||
gist.new.create-a-new-gist: ''
|
||||
gist.edit.edit-gist: ''
|
||||
flash.admin.reset-hooks: ''
|
||||
flash.admin.index-gists: ''
|
||||
flash.auth.username-exists: ''
|
||||
flash.auth.invalid-credentials: ''
|
||||
flash.auth.account-linked-oauth: ''
|
||||
|
||||
@@ -187,8 +187,6 @@ admin.versions: 'Versionen'
|
||||
admin.ssh_keys: 'SSH Schlüssel'
|
||||
admin.stats: 'Statistiken'
|
||||
admin.actions: 'Aktionen'
|
||||
admin.actions.sync-fs: 'Gists auf dem Dateisystem sychronisieren'
|
||||
admin.actions.sync-db: 'Gists von der Datenbank synchronisieren'
|
||||
admin.actions.git-gc: '„garbage collection“ bei allen git Repositories ausführen'
|
||||
admin.actions.sync-previews: 'Alle Gist Vorschauen synchronisieren'
|
||||
admin.actions.reset-hooks: 'Alle Git server Hooks für alle Repositories synchronisieren'
|
||||
@@ -231,12 +229,6 @@ flash.admin.user-deleted: 'Benutzer wurde gelöscht'
|
||||
flash.admin.gist-deleted: 'Gist wurde gelöscht'
|
||||
flash.admin.invitation-created: 'Einladung wurde erstellt'
|
||||
flash.admin.invitation-deleted: 'Einladung wurde gelöscht'
|
||||
flash.admin.sync-fs: 'Synchronisiere Repositories vom Dateisystem...'
|
||||
flash.admin.sync-db: 'Synchronisiere Repositories aus der Datenbank...'
|
||||
flash.admin.git-gc: 'Sammle Repositories...'
|
||||
flash.admin.sync-previews: 'Synchronisiere Gist-Vorschauen...'
|
||||
flash.admin.reset-hooks: 'Setze Git-Server-Hooks für alle Repositories zurück...'
|
||||
flash.admin.index-gists: 'Suchindex wird neu aufgebaut...'
|
||||
|
||||
flash.auth.username-exists: 'Benutzername existiert bereits'
|
||||
flash.auth.invalid-credentials: 'Ungültige Anmeldeinformationen'
|
||||
@@ -305,7 +297,6 @@ auth.totp.save-recovery-codes: Speichere deine Wiederherstellungscodes an einem
|
||||
error.not-in-mfa-session: Nutzer ist nicht in einer Zwei-Faktor-Sitzung
|
||||
gist.revision.binary-file-changes: Änderungen an Binärdateien werden nicht angezeigt
|
||||
error.no-file-uploaded: Keine Datei hochgeladen
|
||||
flash.admin.sync-gist-languages: Gist-Sprachen werden synchronisiert …
|
||||
validation.invalid-gist-topics: Ungültige Gist-Themen. Sie müssen mit einem Buchstaben oder einer Zahl beginnen, dürfen maximal 50 Zeichen lang sein und dürfen Bindestriche enthalten
|
||||
gist.new.topics: Themen (durch Leerzeichen getrennt)
|
||||
gist.preview-non-available: Vorschau nicht verfügbar
|
||||
|
||||
@@ -6,6 +6,7 @@ gist.header.like: Like
|
||||
gist.header.unlike: Unlike
|
||||
gist.header.fork: Fork
|
||||
gist.header.edit: Edit
|
||||
gist.header.edit-files: Edit files
|
||||
gist.header.delete: Delete
|
||||
gist.header.archive: Archive
|
||||
gist.header.unarchive: Unarchive
|
||||
@@ -16,6 +17,7 @@ gist.header.last-active: Last active
|
||||
gist.header.expires: Expires
|
||||
gist.header.select-tab: Select a tab
|
||||
gist.header.code: Code
|
||||
gist.header.more-actions: More actions
|
||||
gist.header.revisions: Revisions
|
||||
gist.header.revision: Revision
|
||||
gist.header.clone-http: Clone via %s
|
||||
@@ -35,6 +37,7 @@ gist.watch-full-file: View the full file.
|
||||
gist.file-not-valid: This file is not a valid CSV file.
|
||||
gist.no-content: No files found
|
||||
gist.preview-non-available: Preview not available
|
||||
gist.embed.view-all-files: View all files
|
||||
|
||||
gist.new.new_gist: New gist
|
||||
gist.new.title: Title
|
||||
@@ -74,9 +77,18 @@ gist.edit.cancel: Cancel
|
||||
gist.edit.save: Save
|
||||
gist.delete.confirm: Are you sure you want to delete this gist ?
|
||||
|
||||
gist.settings.visibility-help: Control who can see and find this gist.
|
||||
gist.settings.archive-help: Archived gists become read-only. You can unarchive at any time.
|
||||
gist.settings.danger-zone: Danger zone
|
||||
gist.settings.delete-gist: Delete this gist
|
||||
gist.settings.delete-help: Once deleted, this gist and all its revisions are gone for good.
|
||||
|
||||
gist.list.joined: Joined
|
||||
gist.list.all: All gists
|
||||
gist.list.recently-liked: Recently liked
|
||||
gist.list.recently-forked: Recently forked
|
||||
gist.list.search-results: Search results
|
||||
gist.list.search-for: Results for
|
||||
gist.list.sort: Sort
|
||||
gist.list.sort-by-created: created
|
||||
gist.list.sort-by-updated: updated
|
||||
@@ -84,19 +96,31 @@ gist.list.order-by-asc: Least recently
|
||||
gist.list.order-by-desc: Recently
|
||||
gist.list.select-tab: Select a tab
|
||||
gist.list.liked: Liked
|
||||
gist.list.likes: likes
|
||||
gist.list.likes: Likes
|
||||
gist.list.forked: Forked
|
||||
gist.list.forked-from: Forked from
|
||||
gist.list.forks: forks
|
||||
gist.list.forks: Forks
|
||||
gist.list.files: files
|
||||
gist.list.last-active: Last active
|
||||
gist.list.no-gists: No gists
|
||||
gist.list.topics: Topics
|
||||
gist.list.users: Users
|
||||
gist.list.no-topics: No topics
|
||||
gist.list.topic: Topic
|
||||
gist.list.gists: Gists
|
||||
gist.list.all-liked-by: All gists liked by %s
|
||||
gist.list.all-forked-by: All gists forked by %s
|
||||
gist.list.all-from: All gists from %s
|
||||
gist.list.topic-results-topic: All gists matching topic %s
|
||||
gist.list.topic-results: All gists matching topic
|
||||
|
||||
explore.users.no-users: No users
|
||||
explore.users.search-placeholder: Search users
|
||||
explore.users.sort-username-asc: Username (A → Z)
|
||||
explore.users.sort-username-desc: Username (Z → A)
|
||||
explore.users.sort-joined-desc: Newest
|
||||
explore.users.sort-joined-asc: Oldest
|
||||
|
||||
|
||||
gist.search.found: gists found
|
||||
gist.search.no-results: No gists found
|
||||
@@ -175,6 +199,7 @@ settings.change-password-help: Change your password to login to Opengist via HTT
|
||||
settings.password-label-title: Password
|
||||
settings.header.account: Account
|
||||
settings.header.mfa: MFA
|
||||
settings.header.authentication: Authentication
|
||||
settings.header.ssh: SSH
|
||||
settings.header.tokens: Access tokens
|
||||
settings.header.style: Style
|
||||
@@ -270,6 +295,7 @@ auth.totp.scan-qr-code: Scan the QR code below with your authenticator app to en
|
||||
|
||||
|
||||
error: Error
|
||||
error.go-home: Back to home
|
||||
error.page-not-found: Page not found
|
||||
error.bad-request: Bad request
|
||||
error.signup-disabled: Signing up is disabled
|
||||
@@ -287,7 +313,11 @@ error.cannot-open-file: Cannot open uploaded file
|
||||
|
||||
header.menu.all: All
|
||||
header.menu.new: New
|
||||
header.menu.topics: Topics
|
||||
header.menu.users: Users
|
||||
header.menu.search: Search
|
||||
header.menu.recently-liked: Recently liked
|
||||
header.menu.recently-forked: Recently forked
|
||||
header.menu.my-gists: My gists
|
||||
header.menu.liked: Liked
|
||||
header.menu.admin: Admin
|
||||
@@ -299,6 +329,8 @@ header.menu.light: Light
|
||||
header.menu.dark: Dark
|
||||
header.menu.system: System
|
||||
footer.powered-by: Powered by %s
|
||||
ui.feedback-new: Give feedback on the new UI
|
||||
sidebar.collapse: Collapse
|
||||
|
||||
pagination.older: Older
|
||||
pagination.newer: Newer
|
||||
@@ -316,8 +348,11 @@ admin.versions: Versions
|
||||
admin.ssh_keys: SSH keys
|
||||
admin.stats: Stats
|
||||
admin.actions: Actions
|
||||
admin.actions.sync-fs: Synchronize gists from filesystem
|
||||
admin.actions.sync-db: Synchronize gists from database
|
||||
admin.actions.subtitle: Run maintenance tasks manually. Some tasks also run automatically on a schedule.
|
||||
admin.actions.run: Run
|
||||
admin.actions.running: Running
|
||||
admin.actions.sync-fs: Delete gists without a repository on disk
|
||||
admin.actions.sync-db: Delete repositories without a gist in the database
|
||||
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
|
||||
@@ -366,15 +401,6 @@ flash.admin.user-deleted: User has been deleted
|
||||
flash.admin.gist-deleted: Gist has been deleted
|
||||
flash.admin.invitation-created: Invitation has been created
|
||||
flash.admin.invitation-deleted: Invitation has been deleted
|
||||
flash.admin.sync-fs: Syncing repositories from filesystem...
|
||||
flash.admin.sync-db: Syncing repositories from database...
|
||||
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: Rebuilding search index...
|
||||
flash.admin.sync-gist-languages: Syncing Gist languages...
|
||||
flash.admin.delete-expired-gists: Deleting expired gists...
|
||||
flash.admin.sync-ssh-keys: Regenerating the authorized_keys file...
|
||||
|
||||
flash.auth.username-exists: Username already exists
|
||||
flash.auth.invalid-credentials: Invalid credentials
|
||||
@@ -392,6 +418,7 @@ flash.gist.visibility-changed: Gist visibility has been changed
|
||||
flash.gist.archived: Gist has been archived
|
||||
flash.gist.unarchived: Gist has been unarchived
|
||||
flash.gist.deleted: Gist has been deleted
|
||||
flash.gist.updated: Gist has been updated
|
||||
flash.gist.fork-own-gist: Unable to fork own gists
|
||||
flash.gist.forked: Gist has been forked
|
||||
|
||||
|
||||
@@ -148,8 +148,6 @@ admin.versions: Versiones
|
||||
admin.ssh_keys: Claves SSH
|
||||
admin.stats: Estadísticas
|
||||
admin.actions: Acciones
|
||||
admin.actions.sync-fs: Sincronizar gists desde el sistema de archivos
|
||||
admin.actions.sync-db: Sincronizar gists desde la base de datos
|
||||
admin.actions.git-gc: Recolectar basura en los repositorios Git
|
||||
admin.id: ID
|
||||
admin.user: Usuario
|
||||
@@ -226,12 +224,6 @@ flash.admin.user-deleted: 'El usuario ha sido eliminado'
|
||||
flash.admin.gist-deleted: 'El gist ha sido eliminado'
|
||||
flash.admin.invitation-created: 'La invitación ha sido creada'
|
||||
flash.admin.invitation-deleted: 'La invitación ha sido eliminada'
|
||||
flash.admin.sync-fs: 'Sincronizando repositorios desde el sistema de archivos...'
|
||||
flash.admin.sync-db: 'Sincronizando repositorios desde la base de datos...'
|
||||
flash.admin.git-gc: 'Recolectando basura en los repositorios...'
|
||||
flash.admin.sync-previews: 'Sincronizando vistas previas de gists...'
|
||||
flash.admin.reset-hooks: 'Reseteando hooks del servidor Git en todos los repositorios...'
|
||||
flash.admin.index-gists: 'Reconstruyendo índice de búsqueda...'
|
||||
flash.auth.username-exists: 'El nombre de usuario ya existe'
|
||||
flash.auth.invalid-credentials: 'Credenciales incorrectas'
|
||||
flash.auth.account-linked-oauth: 'Cuenta vinculada a %s'
|
||||
|
||||
@@ -148,8 +148,6 @@ admin.versions: Versions
|
||||
admin.ssh_keys: Clés SSH
|
||||
admin.stats: Statistiques
|
||||
admin.actions: Actions
|
||||
admin.actions.sync-fs: Synchroniser les gists depuis le système de fichiers
|
||||
admin.actions.sync-db: Synchroniser les gists depuis la base de données
|
||||
admin.actions.git-gc: Nettoyage des dépôts git
|
||||
admin.id: ID
|
||||
admin.user: Utilisateur
|
||||
@@ -226,12 +224,6 @@ flash.admin.user-deleted: 'L''utilisateur a été supprimé'
|
||||
flash.admin.gist-deleted: 'Le gist a été supprimée'
|
||||
flash.admin.invitation-created: 'L''invitation a été créée'
|
||||
flash.admin.invitation-deleted: 'L''invitation a été supprimée'
|
||||
flash.admin.sync-fs: 'Synchronisation des dépôts à partir du système de fichiers...'
|
||||
flash.admin.sync-db: 'Synchronisation des dépôts à partir de la base de données...'
|
||||
flash.admin.git-gc: 'Nettoyage des dépôts...'
|
||||
flash.admin.sync-previews: 'Synchronisation des aperçus du Gist...'
|
||||
flash.admin.reset-hooks: 'Réinitialisation des hooks du serveur Git pour tous les dépôts...'
|
||||
flash.admin.index-gists: 'Reconstruction de l''index de recherche...'
|
||||
flash.auth.username-exists: 'Nom d''utilisateur déjà utilisé'
|
||||
flash.auth.invalid-credentials: 'Identifiants non valides'
|
||||
flash.auth.account-linked-oauth: 'Compte lié à %s'
|
||||
|
||||
@@ -165,8 +165,6 @@ admin.versions: Verziók
|
||||
admin.ssh_keys: SSH kulcsok
|
||||
admin.stats: Statisztikák
|
||||
admin.actions: Műveletek
|
||||
admin.actions.sync-fs: Gistek szinkronizálása a fájlrendszerrel
|
||||
admin.actions.sync-db: Gistek szinkronizálása az adatbázissal
|
||||
admin.actions.git-gc: Használatlan git repository-k eltávolítása
|
||||
admin.actions.sync-previews: Gist előnézetek szinkronizálása
|
||||
admin.actions.reset-hooks: Git server hook-ok alaphelyzetbe állítása minden repository-nál
|
||||
@@ -227,12 +225,6 @@ flash.admin.user-deleted: ''
|
||||
flash.admin.gist-deleted: ''
|
||||
flash.admin.invitation-created: ''
|
||||
flash.admin.invitation-deleted: ''
|
||||
flash.admin.sync-fs: ''
|
||||
flash.admin.sync-db: ''
|
||||
flash.admin.git-gc: ''
|
||||
flash.admin.sync-previews: ''
|
||||
flash.admin.reset-hooks: ''
|
||||
flash.admin.index-gists: ''
|
||||
flash.auth.username-exists: ''
|
||||
flash.auth.invalid-credentials: ''
|
||||
flash.auth.account-linked-oauth: ''
|
||||
|
||||
@@ -186,8 +186,6 @@ admin.versions: 'Versioni'
|
||||
admin.ssh_keys: 'Chiavi SSH'
|
||||
admin.stats: 'Statistiche'
|
||||
admin.actions: 'Azioni'
|
||||
admin.actions.sync-fs: 'Sincronizza gists dal filesystem'
|
||||
admin.actions.sync-db: 'Sincronizza gists dal database'
|
||||
admin.actions.git-gc: 'Esegui la garbage collection da tutti i repositories'
|
||||
admin.actions.sync-previews: 'Sincronizza tutte le anteprime dei gists'
|
||||
admin.actions.reset-hooks: 'Resetta tutti gli hook del server Git per tutti i repositories'
|
||||
@@ -230,12 +228,6 @@ flash.admin.user-deleted: 'L''utente è stato eliminato'
|
||||
flash.admin.gist-deleted: 'Il gist è stato eliminato'
|
||||
flash.admin.invitation-created: 'L''invito è stato creato'
|
||||
flash.admin.invitation-deleted: 'L''invito è stato eliminato'
|
||||
flash.admin.sync-fs: 'Sincronizzando i repositories dal filesystem...'
|
||||
flash.admin.sync-db: 'Sincronizzando i repositories dal database...'
|
||||
flash.admin.git-gc: 'Eseguendo il garbage collector dei repositories...'
|
||||
flash.admin.sync-previews: 'Sincronizzando le anteprime dei gists...'
|
||||
flash.admin.reset-hooks: 'Resettando gli hook di Git per tutti i repositories...'
|
||||
flash.admin.index-gists: 'Ricostruzione indice di ricerca...'
|
||||
|
||||
flash.auth.username-exists: 'Il nome utente esiste già'
|
||||
flash.auth.invalid-credentials: 'Credenziali errate'
|
||||
@@ -295,7 +287,6 @@ auth.totp: Time based one-time password (TOTP)
|
||||
auth.totp.help: Il TOTP è un metodo di autenticazione a due fattori che usa una chiave segreta per generare una one-time password (OTP).
|
||||
error.not-in-mfa-session: Non stai usando l'autenticazione a due fattori
|
||||
admin.actions.sync-gist-languages: Sincronizza tutte le lingue dei gist
|
||||
flash.admin.sync-gist-languages: Sincronizzazione delle lingue gist...
|
||||
flash.auth.passkey-registred: Passkey %s registrata
|
||||
flash.auth.passkey-deleted: Passkey eliminata
|
||||
validation.invalid-gist-topics: 'Argomenti del gist non validi: devono iniziare con una lettera o un numero ed essere composti da al massimo 50 caratteri. Possono includere trattini'
|
||||
|
||||
@@ -236,8 +236,6 @@ admin.versions: ''
|
||||
admin.ssh_keys: ''
|
||||
admin.stats: ''
|
||||
admin.actions: ''
|
||||
admin.actions.sync-fs: ''
|
||||
admin.actions.sync-db: ''
|
||||
admin.actions.git-gc: ''
|
||||
admin.actions.sync-previews: ''
|
||||
admin.actions.reset-hooks: ''
|
||||
@@ -282,13 +280,6 @@ flash.admin.user-deleted: ''
|
||||
flash.admin.gist-deleted: ''
|
||||
flash.admin.invitation-created: ''
|
||||
flash.admin.invitation-deleted: ''
|
||||
flash.admin.sync-fs: ''
|
||||
flash.admin.sync-db: ''
|
||||
flash.admin.git-gc: ''
|
||||
flash.admin.sync-previews: ''
|
||||
flash.admin.reset-hooks: ''
|
||||
flash.admin.index-gists: ''
|
||||
flash.admin.sync-gist-languages: ''
|
||||
|
||||
flash.auth.username-exists: ''
|
||||
flash.auth.invalid-credentials: ''
|
||||
|
||||
@@ -366,15 +366,6 @@ flash.admin.user-deleted: 사용자가 삭제되었습니다
|
||||
flash.admin.gist-deleted: Gist가 삭제되었습니다
|
||||
flash.admin.invitation-created: 초대가 생성되었습니다
|
||||
flash.admin.invitation-deleted: 초대가 삭제되었습니다
|
||||
flash.admin.sync-fs: 파일 시스템에서 리포지토리를 동기화하는 중...
|
||||
flash.admin.sync-db: 데이터베이스에서 리포지토리를 동기화하는 중...
|
||||
flash.admin.git-gc: 리포지토리를 가비지 컬렉션하는 중...
|
||||
flash.admin.sync-previews: Gist 미리보기를 동기화하는 중...
|
||||
flash.admin.reset-hooks: 모든 리포지토리의 Git 서버 훅을 초기화하는 중...
|
||||
flash.admin.index-gists: 검색 인덱스를 재구성하는 중...
|
||||
flash.admin.sync-gist-languages: Gist 언어를 동기화하는 중...
|
||||
flash.admin.delete-expired-gists: 만료된 gist를 삭제하는 중...
|
||||
flash.admin.sync-ssh-keys: authorized_keys 파일을 재생성하는 중...
|
||||
|
||||
flash.auth.username-exists: 이미 존재하는 사용자 이름입니다
|
||||
flash.auth.invalid-credentials: 잘못된 인증 정보입니다
|
||||
|
||||
@@ -222,8 +222,6 @@ admin.versions: 'Wersje'
|
||||
admin.ssh_keys: 'Klucze SSH'
|
||||
admin.stats: 'Statystyki'
|
||||
admin.actions: 'Akcje'
|
||||
admin.actions.sync-fs: 'Synchronizuj Gisty z systemu plików'
|
||||
admin.actions.sync-db: 'Synchronizuj Gisty z bazy danych'
|
||||
admin.actions.git-gc: 'Zbierz śmieci we wszystkich repozytoriach Git'
|
||||
admin.actions.sync-previews: 'Synchronizuj podglądy wszystkich Gistów'
|
||||
admin.actions.reset-hooks: 'Zresetuj hooki serwera Git dla wszystkich repozytoriów'
|
||||
@@ -266,12 +264,6 @@ flash.admin.user-deleted: 'Użytkownik został usunięty'
|
||||
flash.admin.gist-deleted: 'Gist został usunięty'
|
||||
flash.admin.invitation-created: 'Zaproszenie zostało stworzone'
|
||||
flash.admin.invitation-deleted: 'Zaproszenie zostało usunięte'
|
||||
flash.admin.sync-fs: 'Synchronizowanie repozytoriów z systemu plików...'
|
||||
flash.admin.sync-db: 'Synchronizowanie repozytoriów z bazy danych...'
|
||||
flash.admin.git-gc: 'Zbieranie śmieci w repozytoriach...'
|
||||
flash.admin.sync-previews: 'Synchronizowanie podglądów Gistów...'
|
||||
flash.admin.reset-hooks: 'Resetowanie hooków serwera Git dla wszystkich repozytoriów...'
|
||||
flash.admin.index-gists: 'Przebudowywanie indeksu wyszukiwania...'
|
||||
|
||||
flash.auth.username-exists: 'Nazwa użytkownika już istnieje'
|
||||
flash.auth.invalid-credentials: 'Niepoprawne dane logowania'
|
||||
|
||||
@@ -146,8 +146,6 @@ admin.versions: Versões
|
||||
admin.ssh_keys: Chaves SSH
|
||||
admin.stats: Estatísticas
|
||||
admin.actions: Ações
|
||||
admin.actions.sync-fs: Sincronizar gists do sistema de arquivos
|
||||
admin.actions.sync-db: Sincronizar gists do banco de dados
|
||||
admin.actions.git-gc: Coletar lixo nos repositórios Git
|
||||
admin.id: ID
|
||||
admin.user: Usuário
|
||||
@@ -172,7 +170,6 @@ admin.gists.private: Privado
|
||||
admin.gists.nb-files: Núm. de arquivos
|
||||
admin.gists.nb-likes: Núm. de curtidas
|
||||
admin.gists.delete_confirm: Quer excluir este gist?
|
||||
flash.admin.index-gists: ''
|
||||
gist.header.embed: ''
|
||||
gist.header.embed-help: ''
|
||||
gist.new.url: ''
|
||||
@@ -228,11 +225,6 @@ flash.admin.user-deleted: ''
|
||||
flash.admin.gist-deleted: ''
|
||||
flash.admin.invitation-created: ''
|
||||
flash.admin.invitation-deleted: ''
|
||||
flash.admin.sync-fs: ''
|
||||
flash.admin.sync-db: ''
|
||||
flash.admin.git-gc: ''
|
||||
flash.admin.sync-previews: ''
|
||||
flash.admin.reset-hooks: ''
|
||||
flash.auth.username-exists: ''
|
||||
flash.auth.invalid-credentials: ''
|
||||
flash.auth.account-linked-oauth: ''
|
||||
|
||||
@@ -148,8 +148,6 @@ admin.versions: Версии
|
||||
admin.ssh_keys: Ключи SSH
|
||||
admin.stats: Статистика
|
||||
admin.actions: Действия
|
||||
admin.actions.sync-fs: Синхронизировать фрагменты из файловой системы
|
||||
admin.actions.sync-db: Синхронизировать фрагменты с базой данных
|
||||
admin.actions.git-gc: Сборка мусора в репозиториях Git
|
||||
admin.id: ID
|
||||
admin.user: Пользователь
|
||||
@@ -227,12 +225,6 @@ flash.admin.user-deleted: 'Пользователь удалён'
|
||||
flash.admin.gist-deleted: 'Фрагмент удалён'
|
||||
flash.admin.invitation-created: 'Приглашение создано'
|
||||
flash.admin.invitation-deleted: 'Приглашение удалено'
|
||||
flash.admin.sync-fs: 'Выполняется синхронизация репозиториев с файловой системой…'
|
||||
flash.admin.sync-db: 'Выполняется синхронизация репозиториев с базой данных…'
|
||||
flash.admin.git-gc: 'Сборка мусора в репозиториях…'
|
||||
flash.admin.sync-previews: 'Обновление предпросмотров фрагментов…'
|
||||
flash.admin.reset-hooks: 'Пересоздание Git-хуков для всех репозиториев…'
|
||||
flash.admin.index-gists: 'Перестроение поискового индекса…'
|
||||
flash.auth.username-exists: 'Такое имя пользователя уже занято'
|
||||
flash.auth.invalid-credentials: 'Некорректные данные для входа'
|
||||
flash.auth.account-linked-oauth: 'Учётная запись связана с %s'
|
||||
@@ -334,7 +326,6 @@ auth.totp.help: TOTP — это метод двухфакторной аутен
|
||||
auth.totp.use: Использовать TOTP
|
||||
auth.totp.regenerate-recovery-codes: Сгенерировать коды восстановления заново
|
||||
auth.totp: Одноразовый пароль по времени (TOTP)
|
||||
flash.admin.sync-gist-languages: Обновление языков фрагментов…
|
||||
settings.token-created: Токен создан, обязательно сохраните его, повторно он показан не будет!
|
||||
settings.token-last-used: Последнее использование
|
||||
settings.token-no-expiration: Бессрочно
|
||||
|
||||
@@ -186,8 +186,6 @@ admin.versions: Sürümler
|
||||
admin.ssh_keys: SSH anahtarları
|
||||
admin.stats: İstatistikler
|
||||
admin.actions: Eylemler
|
||||
admin.actions.sync-fs: Gistleri dosya sisteminden senkronize et
|
||||
admin.actions.sync-db: Gistleri veri tabanından senkronize et
|
||||
admin.actions.git-gc: Tüm Git depolarındaki gereksiz verileri temizle
|
||||
admin.actions.sync-previews: Tüm gist önizlemelerini senkronize et
|
||||
admin.actions.reset-hooks: Tüm depolar için Git sunucu kancalarını sıfırla
|
||||
@@ -229,12 +227,6 @@ flash.admin.user-deleted: Kullanıcı silindi
|
||||
flash.admin.gist-deleted: Gist silindi
|
||||
flash.admin.invitation-created: Davetiye oluşturuldu
|
||||
flash.admin.invitation-deleted: Davetiye silindi
|
||||
flash.admin.sync-fs: Depolar dosya sisteminden senkronize ediliyor...
|
||||
flash.admin.sync-db: Depolar veri tabanından senkronize ediliyor...
|
||||
flash.admin.git-gc: Depolardan gereksiz veriler temizleniyor...
|
||||
flash.admin.sync-previews: Gist önizlemeleri senkronize ediliyor...
|
||||
flash.admin.reset-hooks: Tüm depolar için Git sunucusu kancaları sıfırlanıyor...
|
||||
flash.admin.index-gists: Arama dizini yeniden oluşturuluyor...
|
||||
|
||||
flash.auth.username-exists: Kullanıcı adı zaten mevcut
|
||||
flash.auth.invalid-credentials: Geçersiz kimlik bilgileri
|
||||
@@ -274,7 +266,6 @@ auth.mfa.waiting-for-passkey-input: Tarayıcı etkileşiminden gelecek girdi bek
|
||||
settings.header.account: Hesap
|
||||
settings.style.no-soft-wrap: Yumuşak Satır Kaydırma Yok
|
||||
auth.totp: Zamana Dayalı Tek Kullanımlık Parola (TOTP)
|
||||
flash.admin.sync-gist-languages: Gist dilleri senkronize ediliyor...
|
||||
auth.mfa.passkeys-help: Hesabınıza giriş yapmak ve çok faktörlü kimlik doğrulama yöntemi olarak kullanmak için bir geçiş anahtarı ekleyin.
|
||||
validation.invalid-gist-topics: Geçersiz gist konuları, harf veya rakamla başlamalı, 50 karakterden uzun olmamalı ve tire içerebilir.
|
||||
auth.totp.enter-recovery-key: veya cihazınızı kaybettiyseniz kurtarma anahtarını kullanın
|
||||
|
||||
@@ -187,8 +187,6 @@ admin.versions: Версії
|
||||
admin.ssh_keys: Ключі SSH
|
||||
admin.stats: Статистика
|
||||
admin.actions: Дії
|
||||
admin.actions.sync-fs: Синхронізувати gists з файлової системи
|
||||
admin.actions.sync-db: Синхронізувати gists з базою даних
|
||||
admin.actions.git-gc: Збір сміття з репозиторіїв Git
|
||||
admin.actions.sync-previews: Синхронізувати всі gists перегляди
|
||||
admin.actions.reset-hooks: Скинути серверні Git hooks для всіх репозиторіїв
|
||||
@@ -231,12 +229,6 @@ flash.admin.user-deleted: Користувач був видалений
|
||||
flash.admin.gist-deleted: Gist був видалений
|
||||
flash.admin.invitation-created: Запрошення було створено
|
||||
flash.admin.invitation-deleted: Запрошення було видалено
|
||||
flash.admin.sync-fs: Синхронізація репозиторіїв за файловою системою...
|
||||
flash.admin.sync-db: Синхронізація репозиторіїв за базою даних...
|
||||
flash.admin.git-gc: Збір сміття з репозиторіїв...
|
||||
flash.admin.sync-previews: Синхронізація Gist переглядів...
|
||||
flash.admin.reset-hooks: Скидання cерверниз Git hooks для всіх репозиторіїв...
|
||||
flash.admin.index-gists: Перебудова пошукового індексу...
|
||||
|
||||
flash.auth.username-exists: Це ім'я користувача вже існує
|
||||
flash.auth.invalid-credentials: Недійсні облікові дані
|
||||
|
||||
@@ -177,8 +177,6 @@ admin.versions: 版本
|
||||
admin.ssh_keys: SSH 密钥
|
||||
admin.stats: 状态
|
||||
admin.actions: 动作
|
||||
admin.actions.sync-fs: 从文件系统同步 Gist
|
||||
admin.actions.sync-db: 从数据库同步 Gist
|
||||
admin.actions.git-gc: 对 Git 仓库执行垃圾回收
|
||||
admin.id: ID
|
||||
admin.user: 用户
|
||||
@@ -257,12 +255,6 @@ flash.admin.user-deleted: '用户已被删除'
|
||||
flash.admin.gist-deleted: 'Gist 已被删除'
|
||||
flash.admin.invitation-created: '邀请已被创建'
|
||||
flash.admin.invitation-deleted: '邀请已被删除'
|
||||
flash.admin.sync-fs: '正在从文件系统同步存储库...'
|
||||
flash.admin.sync-db: '正在从数据库同步存储库...'
|
||||
flash.admin.git-gc: '正在进行存储库垃圾回收...'
|
||||
flash.admin.sync-previews: '正在同步 Gist 预览...'
|
||||
flash.admin.reset-hooks: '正在重置所有存储库的 Git 服务挂钩...'
|
||||
flash.admin.index-gists: '正在重建搜索索引...'
|
||||
flash.auth.username-exists: '用户名已存在'
|
||||
flash.auth.invalid-credentials: '无效的凭证'
|
||||
flash.auth.account-linked-oauth: '帐户已关联到 %s'
|
||||
@@ -330,7 +322,6 @@ gist.new.topics: 主题(用空格分隔)
|
||||
validation.invalid-gist-topics: 无效的 Gists 主题,它们必须以字母或数字开头,长度不超过50个字符,并且可以包含连字符
|
||||
gist.list.topic-results-topic: '%s 与主题匹配的所有 Gists'
|
||||
admin.actions.sync-gist-languages: 同步所有 gists 语言
|
||||
flash.admin.sync-gist-languages: 正在同步 Gist 语言...
|
||||
gist.list.topic-results: 所有匹配主题的 Gist
|
||||
gist.search.help.topic: 具有给定主题的 Gists
|
||||
gist.search.placeholder.title: 标题
|
||||
|
||||
@@ -157,8 +157,6 @@ admin.versions: 版本
|
||||
admin.ssh_keys: SSH 金鑰
|
||||
admin.stats: 統計
|
||||
admin.actions: 操作
|
||||
admin.actions.sync-fs: 從系統同步 Gists
|
||||
admin.actions.sync-db: 從資料庫同步 Gists
|
||||
admin.actions.git-gc: 清理所有的 git 儲存庫
|
||||
admin.actions.sync-previews: 同步所有 Gists 預覽
|
||||
admin.actions.reset-hooks: 重置 Git 伺服器所有儲存庫的 Git hooks
|
||||
@@ -226,12 +224,6 @@ flash.admin.user-deleted: ''
|
||||
flash.admin.gist-deleted: ''
|
||||
flash.admin.invitation-created: ''
|
||||
flash.admin.invitation-deleted: ''
|
||||
flash.admin.sync-fs: ''
|
||||
flash.admin.sync-db: ''
|
||||
flash.admin.git-gc: ''
|
||||
flash.admin.sync-previews: ''
|
||||
flash.admin.reset-hooks: ''
|
||||
flash.admin.index-gists: ''
|
||||
flash.auth.username-exists: ''
|
||||
flash.auth.invalid-credentials: ''
|
||||
flash.auth.account-linked-oauth: ''
|
||||
|
||||
@@ -23,9 +23,14 @@ func NewValidator() *OpengistValidator {
|
||||
_ = v.RegisterValidation("alphanumdashunderorempty", validateAlphaNumDashUnderOrEmpty)
|
||||
_ = v.RegisterValidation("gisttopics", validateGistTopics)
|
||||
_ = v.RegisterValidation("expirationdate", validateExpirationDate)
|
||||
_ = v.RegisterValidation("themecolor", validateThemeColor)
|
||||
return &OpengistValidator{v}
|
||||
}
|
||||
|
||||
var ThemeColors = []string{
|
||||
"red", "amber", "emerald", "sky", "indigo", "purple", "neutral",
|
||||
}
|
||||
|
||||
func (cv *OpengistValidator) Validate(i interface{}) error {
|
||||
return cv.v.Struct(i)
|
||||
}
|
||||
@@ -69,7 +74,7 @@ func validateReservedKeywords(fl validator.FieldLevel) bool {
|
||||
name := fl.Field().String()
|
||||
|
||||
restrictedNames := map[string]struct{}{}
|
||||
for _, restrictedName := range []string{"assets", "register", "login", "logout", "settings", "admin-panel", "all", "search", "init", "healthcheck", "preview", "metrics", "mfa", "webauthn", "oauth"} {
|
||||
for _, restrictedName := range []string{"api", "assets", "init", "healthcheck", "preview", "metrics", "mfa", "webauthn", "oauth"} {
|
||||
restrictedNames[restrictedName] = struct{}{}
|
||||
}
|
||||
|
||||
@@ -79,7 +84,9 @@ func validateReservedKeywords(fl validator.FieldLevel) bool {
|
||||
}
|
||||
|
||||
func validateAlphaNumDash(fl validator.FieldLevel) bool {
|
||||
return regexp.MustCompile(`^[a-zA-Z0-9-]+$`).MatchString(fl.Field().String())
|
||||
value := fl.Field().String()
|
||||
return regexp.MustCompile(`^[a-zA-Z0-9-]+$`).MatchString(value) &&
|
||||
regexp.MustCompile(`[a-zA-Z0-9]`).MatchString(value)
|
||||
}
|
||||
|
||||
func validateAlphaNumDashOrEmpty(fl validator.FieldLevel) bool {
|
||||
@@ -87,13 +94,28 @@ func validateAlphaNumDashOrEmpty(fl validator.FieldLevel) bool {
|
||||
}
|
||||
|
||||
func validateAlphaNumDashUnder(fl validator.FieldLevel) bool {
|
||||
return regexp.MustCompile(`^[a-zA-Z0-9-_]+$`).MatchString(fl.Field().String())
|
||||
value := fl.Field().String()
|
||||
return regexp.MustCompile(`^[a-zA-Z0-9-_]+$`).MatchString(value) &&
|
||||
regexp.MustCompile(`[a-zA-Z0-9]`).MatchString(value)
|
||||
}
|
||||
|
||||
func validateAlphaNumDashUnderOrEmpty(fl validator.FieldLevel) bool {
|
||||
return regexp.MustCompile(`^$|^[a-zA-Z0-9-_]+$`).MatchString(fl.Field().String())
|
||||
}
|
||||
|
||||
func validateThemeColor(fl validator.FieldLevel) bool {
|
||||
color := fl.Field().String()
|
||||
if color == "" {
|
||||
return true
|
||||
}
|
||||
for _, c := range ThemeColors {
|
||||
if c == color {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func validateGistTopics(fl validator.FieldLevel) bool {
|
||||
topicsInput := fl.Field().String()
|
||||
if topicsInput == "" {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package validator
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestReservedUsernames(t *testing.T) {
|
||||
v := NewValidator()
|
||||
|
||||
for _, name := range []string{
|
||||
"register",
|
||||
"login",
|
||||
"logout",
|
||||
"settings",
|
||||
"admin-panel",
|
||||
"all",
|
||||
"search",
|
||||
} {
|
||||
t.Run("allowed "+name, func(t *testing.T) {
|
||||
require.NoError(t, v.Var(name, "notreserved"))
|
||||
})
|
||||
}
|
||||
|
||||
for _, name := range []string{
|
||||
"assets",
|
||||
"init",
|
||||
"healthcheck",
|
||||
"preview",
|
||||
"metrics",
|
||||
"mfa",
|
||||
"webauthn",
|
||||
"oauth",
|
||||
} {
|
||||
t.Run("reserved "+name, func(t *testing.T) {
|
||||
require.Error(t, v.Var(name, "notreserved"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsernameRequiresAlphanumericCharacter(t *testing.T) {
|
||||
v := NewValidator()
|
||||
|
||||
for _, name := range []string{"-", "---"} {
|
||||
require.Error(t, v.Var(name, "alphanumdash"))
|
||||
}
|
||||
for _, name := range []string{"-", "---", "_", "-_-"} {
|
||||
require.Error(t, v.Var(name, "alphanumdashunder"))
|
||||
}
|
||||
|
||||
require.NoError(t, v.Var("user-name", "alphanumdash"))
|
||||
require.NoError(t, v.Var("user_name", "alphanumdashunder"))
|
||||
}
|
||||
@@ -2,59 +2,114 @@ package admin
|
||||
|
||||
import (
|
||||
"github.com/thomiceli/opengist/internal/actions"
|
||||
"github.com/thomiceli/opengist/internal/config"
|
||||
"github.com/thomiceli/opengist/internal/web/context"
|
||||
)
|
||||
|
||||
// actionView is the template model for a single row on the admin actions page.
|
||||
type actionView struct {
|
||||
Path string // POST endpoint suffix under /-/admin-panel, e.g. "sync-fs"
|
||||
LabelKey string // i18n key for the action's label
|
||||
Running bool // currently in progress in this instance
|
||||
Periodic bool // also runs automatically on a schedule
|
||||
Spec string // raw schedule spec, e.g. "@every 72h"; "" if not periodic
|
||||
}
|
||||
|
||||
// adminActions lists every action shown on the actions page, in display order.
|
||||
// The Path values match the POST routes registered in the router.
|
||||
var adminActions = []struct {
|
||||
Type int
|
||||
Path string
|
||||
Key string
|
||||
}{
|
||||
{actions.SyncReposFromFS, "sync-fs", "admin.actions.sync-fs"},
|
||||
{actions.SyncReposFromDB, "sync-db", "admin.actions.sync-db"},
|
||||
{actions.GitGcRepos, "gc-repos", "admin.actions.git-gc"},
|
||||
{actions.SyncGistPreviews, "sync-previews", "admin.actions.sync-previews"},
|
||||
{actions.ResetHooks, "reset-hooks", "admin.actions.reset-hooks"},
|
||||
{actions.IndexGists, "index-gists", "admin.actions.index-gists"},
|
||||
{actions.SyncGistLanguages, "sync-languages", "admin.actions.sync-gist-languages"},
|
||||
{actions.DeleteExpiredGists, "delete-expired-gists", "admin.actions.delete-expired-gists"},
|
||||
{actions.SyncSSHKeys, "sync-ssh-keys", "admin.actions.sync-ssh-keys"},
|
||||
}
|
||||
|
||||
func AdminActions(ctx *context.Context) error {
|
||||
ctx.SetData("htmlTitle", ctx.TrH("admin.actions")+" - "+ctx.TrH("admin.admin_panel"))
|
||||
ctx.SetData("adminHeaderPage", "actions")
|
||||
|
||||
manageSSHKeys := config.C.SshManagesAuthorizedKeys()
|
||||
|
||||
views := make([]actionView, 0, len(adminActions))
|
||||
anyRunning := false
|
||||
for _, a := range adminActions {
|
||||
// The authorized_keys action is only relevant when Opengist manages the file.
|
||||
if a.Type == actions.SyncSSHKeys && !manageSSHKeys {
|
||||
continue
|
||||
}
|
||||
|
||||
running := actions.IsRunning(a.Type)
|
||||
if running {
|
||||
anyRunning = true
|
||||
}
|
||||
|
||||
views = append(views, actionView{
|
||||
Path: a.Path,
|
||||
LabelKey: a.Key,
|
||||
Running: running,
|
||||
Periodic: actions.IsPeriodic(a.Type),
|
||||
Spec: actions.Spec(a.Type),
|
||||
})
|
||||
}
|
||||
|
||||
ctx.SetData("actions", views)
|
||||
ctx.SetData("anyRunning", anyRunning)
|
||||
// Set when arriving right after triggering a run: keeps the list polling for
|
||||
// a moment even if the goroutine hasn't flipped the running flag yet.
|
||||
ctx.SetData("pollNow", ctx.QueryParam("run") == "1")
|
||||
return ctx.Html("admin_actions.html")
|
||||
}
|
||||
|
||||
func AdminSyncReposFromFS(ctx *context.Context) error {
|
||||
ctx.AddFlash(ctx.Tr("flash.admin.sync-fs"), "success")
|
||||
go actions.RunOnce(actions.SyncReposFromFS)
|
||||
return ctx.RedirectTo("/admin-panel")
|
||||
return runAdminAction(ctx, actions.SyncReposFromFS, "flash.admin.sync-fs")
|
||||
}
|
||||
|
||||
func AdminSyncReposFromDB(ctx *context.Context) error {
|
||||
ctx.AddFlash(ctx.Tr("flash.admin.sync-db"), "success")
|
||||
go actions.RunOnce(actions.SyncReposFromDB)
|
||||
return ctx.RedirectTo("/admin-panel")
|
||||
return runAdminAction(ctx, actions.SyncReposFromDB, "flash.admin.sync-db")
|
||||
}
|
||||
|
||||
func AdminGcRepos(ctx *context.Context) error {
|
||||
ctx.AddFlash(ctx.Tr("flash.admin.git-gc"), "success")
|
||||
go actions.RunOnce(actions.GitGcRepos)
|
||||
return ctx.RedirectTo("/admin-panel")
|
||||
return runAdminAction(ctx, actions.GitGcRepos, "flash.admin.git-gc")
|
||||
}
|
||||
|
||||
func AdminSyncGistPreviews(ctx *context.Context) error {
|
||||
ctx.AddFlash(ctx.Tr("flash.admin.sync-previews"), "success")
|
||||
go actions.RunOnce(actions.SyncGistPreviews)
|
||||
return ctx.RedirectTo("/admin-panel")
|
||||
return runAdminAction(ctx, actions.SyncGistPreviews, "flash.admin.sync-previews")
|
||||
}
|
||||
|
||||
func AdminResetHooks(ctx *context.Context) error {
|
||||
ctx.AddFlash(ctx.Tr("flash.admin.reset-hooks"), "success")
|
||||
go actions.RunOnce(actions.ResetHooks)
|
||||
return ctx.RedirectTo("/admin-panel")
|
||||
return runAdminAction(ctx, actions.ResetHooks, "flash.admin.reset-hooks")
|
||||
}
|
||||
|
||||
func AdminIndexGists(ctx *context.Context) error {
|
||||
ctx.AddFlash(ctx.Tr("flash.admin.index-gists"), "success")
|
||||
go actions.RunOnce(actions.IndexGists)
|
||||
return ctx.RedirectTo("/admin-panel")
|
||||
return runAdminAction(ctx, actions.IndexGists, "flash.admin.index-gists")
|
||||
}
|
||||
|
||||
func AdminSyncGistLanguages(ctx *context.Context) error {
|
||||
ctx.AddFlash(ctx.Tr("flash.admin.sync-gist-languages"), "success")
|
||||
go actions.RunOnce(actions.SyncGistLanguages)
|
||||
return ctx.RedirectTo("/admin-panel")
|
||||
return runAdminAction(ctx, actions.SyncGistLanguages, "flash.admin.sync-gist-languages")
|
||||
}
|
||||
|
||||
func AdminDeleteExpiredGists(ctx *context.Context) error {
|
||||
ctx.AddFlash(ctx.Tr("flash.admin.delete-expired-gists"), "success")
|
||||
go actions.RunOnce(actions.DeleteExpiredGists)
|
||||
return ctx.RedirectTo("/admin-panel")
|
||||
return runAdminAction(ctx, actions.DeleteExpiredGists, "flash.admin.delete-expired-gists")
|
||||
}
|
||||
|
||||
func AdminSyncSSHKeys(ctx *context.Context) error {
|
||||
ctx.AddFlash(ctx.Tr("flash.admin.sync-ssh-keys"), "success")
|
||||
go actions.RunOnce(actions.SyncSSHKeys)
|
||||
return ctx.RedirectTo("/admin-panel")
|
||||
return runAdminAction(ctx, actions.SyncSSHKeys, "flash.admin.sync-ssh-keys")
|
||||
}
|
||||
|
||||
func runAdminAction(ctx *context.Context, actionType int, legacyFlashKey string) error {
|
||||
go actions.RunOnce(actionType)
|
||||
if ctx.QueryParam("legacy") == "1" {
|
||||
ctx.AddFlash(ctx.Tr(legacyFlashKey), "success")
|
||||
return ctx.RedirectTo("/-/admin-panel")
|
||||
}
|
||||
return ctx.RedirectTo("/-/admin-panel/actions?run=1")
|
||||
}
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
package admin_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
webtest "github.com/thomiceli/opengist/internal/web/test"
|
||||
)
|
||||
|
||||
func TestAdminActions(t *testing.T) {
|
||||
s := webtest.Setup(t)
|
||||
defer webtest.Teardown(t)
|
||||
urls := []string{
|
||||
"/admin-panel/sync-fs",
|
||||
"/admin-panel/sync-db",
|
||||
"/admin-panel/gc-repos",
|
||||
"/admin-panel/sync-previews",
|
||||
"/admin-panel/reset-hooks",
|
||||
"/admin-panel/index-gists",
|
||||
"/admin-panel/sync-languages",
|
||||
}
|
||||
|
||||
s.Register(t, "thomas")
|
||||
s.Register(t, "nonadmin")
|
||||
|
||||
t.Run("NoUser", func(t *testing.T) {
|
||||
for _, url := range urls {
|
||||
s.Request(t, "POST", url, nil, 404)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("AdminUser", func(t *testing.T) {
|
||||
s.Login(t, "thomas")
|
||||
for _, url := range urls {
|
||||
resp := s.Request(t, "POST", url, nil, 302)
|
||||
require.Equal(t, "/admin-panel", resp.Header.Get("Location"))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("NonAdminUser", func(t *testing.T) {
|
||||
s.Login(t, "nonadmin")
|
||||
for _, url := range urls {
|
||||
s.Request(t, "POST", url, nil, 404)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -44,6 +44,8 @@ func AdminIndex(ctx *context.Context) error {
|
||||
}
|
||||
ctx.SetData("countKeys", countKeys)
|
||||
|
||||
// The legacy admin dashboard renders actions on this page. Keep supplying
|
||||
// their state while the new UI uses the dedicated actions page.
|
||||
ctx.SetData("syncReposFromFS", actions.IsRunning(actions.SyncReposFromFS))
|
||||
ctx.SetData("syncReposFromDB", actions.IsRunning(actions.SyncReposFromDB))
|
||||
ctx.SetData("gitGcRepos", actions.IsRunning(actions.GitGcRepos))
|
||||
@@ -108,7 +110,7 @@ func AdminUserDelete(ctx *context.Context) error {
|
||||
opengistssh.SyncAuthorizedKeysLogged()
|
||||
|
||||
ctx.AddFlash(ctx.Tr("flash.admin.user-deleted"), "success")
|
||||
return ctx.RedirectTo("/admin-panel/users")
|
||||
return ctx.RedirectTo("/-/admin-panel/users")
|
||||
}
|
||||
|
||||
func AdminGistDelete(ctx *context.Context) error {
|
||||
@@ -124,7 +126,7 @@ func AdminGistDelete(ctx *context.Context) error {
|
||||
gist.RemoveFromIndex()
|
||||
|
||||
ctx.AddFlash(ctx.Tr("flash.admin.gist-deleted"), "success")
|
||||
return ctx.RedirectTo("/admin-panel/gists")
|
||||
return ctx.RedirectTo("/-/admin-panel/gists")
|
||||
}
|
||||
|
||||
func AdminConfig(ctx *context.Context) error {
|
||||
@@ -187,7 +189,7 @@ func AdminInvitationsCreate(ctx *context.Context) error {
|
||||
}
|
||||
|
||||
ctx.AddFlash(ctx.Tr("flash.admin.invitation-created"), "success")
|
||||
return ctx.RedirectTo("/admin-panel/invitations")
|
||||
return ctx.RedirectTo("/-/admin-panel/invitations")
|
||||
}
|
||||
|
||||
func AdminInvitationsDelete(ctx *context.Context) error {
|
||||
@@ -202,5 +204,5 @@ func AdminInvitationsDelete(ctx *context.Context) error {
|
||||
}
|
||||
|
||||
ctx.AddFlash(ctx.Tr("flash.admin.invitation-deleted"), "success")
|
||||
return ctx.RedirectTo("/admin-panel/invitations")
|
||||
return ctx.RedirectTo("/-/admin-panel/invitations")
|
||||
}
|
||||
|
||||
@@ -20,11 +20,12 @@ func TestAdminPages(t *testing.T) {
|
||||
defer webtest.Teardown(t)
|
||||
|
||||
urls := []string{
|
||||
"/admin-panel",
|
||||
"/admin-panel/users",
|
||||
"/admin-panel/gists",
|
||||
"/admin-panel/invitations",
|
||||
"/admin-panel/configuration",
|
||||
"/-/admin-panel",
|
||||
"/-/admin-panel/users",
|
||||
"/-/admin-panel/gists",
|
||||
"/-/admin-panel/invitations",
|
||||
"/-/admin-panel/configuration",
|
||||
"/-/admin-panel/actions",
|
||||
}
|
||||
|
||||
s.Register(t, "thomas")
|
||||
@@ -67,12 +68,12 @@ func TestAdminSetConfig(t *testing.T) {
|
||||
s.Register(t, "nonadmin")
|
||||
|
||||
t.Run("NoUser", func(t *testing.T) {
|
||||
s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {db.SettingDisableSignup}, "value": {"1"}}, 404)
|
||||
s.Request(t, "PUT", "/-/admin-panel/set-config", url.Values{"key": {db.SettingDisableSignup}, "value": {"1"}}, 404)
|
||||
})
|
||||
|
||||
t.Run("NonAdminUser", func(t *testing.T) {
|
||||
s.Login(t, "nonadmin")
|
||||
s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {db.SettingDisableSignup}, "value": {"1"}}, 404)
|
||||
s.Request(t, "PUT", "/-/admin-panel/set-config", url.Values{"key": {db.SettingDisableSignup}, "value": {"1"}}, 404)
|
||||
})
|
||||
|
||||
t.Run("AdminUser", func(t *testing.T) {
|
||||
@@ -83,13 +84,13 @@ func TestAdminSetConfig(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "0", val)
|
||||
|
||||
s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {setting}, "value": {"1"}}, 200)
|
||||
s.Request(t, "PUT", "/-/admin-panel/set-config", url.Values{"key": {setting}, "value": {"1"}}, 200)
|
||||
|
||||
val, err = db.GetSetting(setting)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "1", val)
|
||||
|
||||
s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {setting}, "value": {"0"}}, 200)
|
||||
s.Request(t, "PUT", "/-/admin-panel/set-config", url.Values{"key": {setting}, "value": {"0"}}, 200)
|
||||
|
||||
val, err = db.GetSetting(setting)
|
||||
require.NoError(t, err)
|
||||
@@ -110,12 +111,12 @@ func TestAdminPagination(t *testing.T) {
|
||||
t.Run("Pagination", func(t *testing.T) {
|
||||
s.Login(t, "thomas")
|
||||
|
||||
s.Request(t, "GET", "/admin-panel/users", nil, 200)
|
||||
s.Request(t, "GET", "/admin-panel/users?page=2", nil, 200)
|
||||
s.Request(t, "GET", "/admin-panel/users?page=3", nil, 404)
|
||||
s.Request(t, "GET", "/admin-panel/users?page=0", nil, 200)
|
||||
s.Request(t, "GET", "/admin-panel/users?page=-1", nil, 200)
|
||||
s.Request(t, "GET", "/admin-panel/users?page=a", nil, 200)
|
||||
s.Request(t, "GET", "/-/admin-panel/users", nil, 200)
|
||||
s.Request(t, "GET", "/-/admin-panel/users?page=2", nil, 200)
|
||||
s.Request(t, "GET", "/-/admin-panel/users?page=3", nil, 404)
|
||||
s.Request(t, "GET", "/-/admin-panel/users?page=0", nil, 200)
|
||||
s.Request(t, "GET", "/-/admin-panel/users?page=-1", nil, 200)
|
||||
s.Request(t, "GET", "/-/admin-panel/users?page=a", nil, 200)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -147,11 +148,11 @@ func TestAdminUserOperations(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(2), count)
|
||||
|
||||
s.Request(t, "POST", "/admin-panel/users/2/delete", nil, 404)
|
||||
s.Request(t, "POST", "/-/admin-panel/users/2/delete", nil, 404)
|
||||
|
||||
s.Login(t, "thomas")
|
||||
|
||||
s.Request(t, "POST", "/admin-panel/users/2/delete", nil, 302)
|
||||
s.Request(t, "POST", "/-/admin-panel/users/2/delete", nil, 302)
|
||||
|
||||
count, err = db.CountAll(db.User{})
|
||||
require.NoError(t, err)
|
||||
@@ -192,11 +193,11 @@ func TestAdminGistOperations(t *testing.T) {
|
||||
_, err = os.Stat(filepath.Join(config.GetHomeDir(), git.ReposDirectory, "nonadmin", gist1Db.Identifier()))
|
||||
require.NoError(t, err)
|
||||
|
||||
s.Request(t, "POST", "/admin-panel/gists/1/delete", nil, 404)
|
||||
s.Request(t, "POST", "/-/admin-panel/gists/1/delete", nil, 404)
|
||||
|
||||
s.Login(t, "thomas")
|
||||
|
||||
s.Request(t, "POST", "/admin-panel/gists/1/delete", nil, 302)
|
||||
s.Request(t, "POST", "/-/admin-panel/gists/1/delete", nil, 302)
|
||||
|
||||
count, err = db.CountAll(db.Gist{})
|
||||
require.NoError(t, err)
|
||||
@@ -217,7 +218,7 @@ func TestAdminInvitationOperations(t *testing.T) {
|
||||
t.Run("Invitation", func(t *testing.T) {
|
||||
s.Login(t, "thomas")
|
||||
|
||||
s.Request(t, "POST", "/admin-panel/invitations", url.Values{
|
||||
s.Request(t, "POST", "/-/admin-panel/invitations", url.Values{
|
||||
"nbMax": {""},
|
||||
"expiredAtUnix": {""},
|
||||
}, 302)
|
||||
@@ -228,7 +229,7 @@ func TestAdminInvitationOperations(t *testing.T) {
|
||||
require.Equal(t, uint(10), invitation1.NbMax)
|
||||
require.InDelta(t, time.Now().Unix()+604800, invitation1.ExpiresAt, 10)
|
||||
|
||||
s.Request(t, "POST", "/admin-panel/invitations", url.Values{
|
||||
s.Request(t, "POST", "/-/admin-panel/invitations", url.Values{
|
||||
"nbMax": {"aa"},
|
||||
"expiredAtUnix": {"1735722000"},
|
||||
}, 302)
|
||||
@@ -242,7 +243,7 @@ func TestAdminInvitationOperations(t *testing.T) {
|
||||
NbMax: 10,
|
||||
})
|
||||
|
||||
s.Request(t, "POST", "/admin-panel/invitations", url.Values{
|
||||
s.Request(t, "POST", "/-/admin-panel/invitations", url.Values{
|
||||
"nbMax": {"20"},
|
||||
"expiredAtUnix": {"1735722000"},
|
||||
}, 302)
|
||||
@@ -260,7 +261,7 @@ func TestAdminInvitationOperations(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(3), count)
|
||||
|
||||
s.Request(t, "POST", "/admin-panel/invitations/1/delete", nil, 302)
|
||||
s.Request(t, "POST", "/-/admin-panel/invitations/1/delete", nil, 302)
|
||||
|
||||
count, err = db.CountAll(db.Invitation{})
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -48,7 +48,7 @@ func Oauth(ctx *context.Context) error {
|
||||
provider, err := oauth.DefineProvider(providerStr, opengistUrl)
|
||||
if err != nil {
|
||||
ctx.AddFlash(ctx.Tr("error.oauth-unsupported"), "error")
|
||||
return ctx.Redirect(302, "/login")
|
||||
return ctx.Redirect(302, "/-/login")
|
||||
}
|
||||
|
||||
if err = provider.RegisterProvider(); err != nil {
|
||||
@@ -63,7 +63,7 @@ func OauthCallback(ctx *context.Context) error {
|
||||
provider, err := oauth.CompleteUserAuth(ctx)
|
||||
if err != nil {
|
||||
ctx.AddFlash(fmt.Sprintf("%s: %s", ctx.Tr("auth.oauth.no-provider"), err.Error()), "error")
|
||||
return ctx.Redirect(302, "/login")
|
||||
return ctx.Redirect(302, "/-/login")
|
||||
}
|
||||
|
||||
currUser := ctx.User
|
||||
@@ -74,7 +74,7 @@ func OauthCallback(ctx *context.Context) error {
|
||||
// check if this OAuth account is already linked to another user
|
||||
if existingUser, err := db.GetUserByProvider(user.UserID, provider.GetProvider()); err == nil && existingUser != nil {
|
||||
ctx.AddFlash(ctx.Tr("flash.auth.oauth-already-linked", config.C.OIDCProviderName), "error")
|
||||
return ctx.RedirectTo("/settings")
|
||||
return ctx.RedirectTo("/-/settings")
|
||||
}
|
||||
|
||||
provider.UpdateUserDB(currUser)
|
||||
@@ -84,7 +84,7 @@ func OauthCallback(ctx *context.Context) error {
|
||||
}
|
||||
|
||||
ctx.AddFlash(ctx.Tr("flash.auth.account-linked-oauth", config.C.OIDCProviderName), "success")
|
||||
return ctx.RedirectTo("/settings")
|
||||
return ctx.RedirectTo("/-/settings")
|
||||
}
|
||||
|
||||
userDB, err := db.GetUserByProvider(user.UserID, provider.GetProvider())
|
||||
@@ -92,7 +92,7 @@ func OauthCallback(ctx *context.Context) error {
|
||||
if err != nil {
|
||||
if ctx.GetData("DisableSignup") == true {
|
||||
ctx.AddFlash(ctx.Tr("error.signup-disabled"), "error")
|
||||
return ctx.Redirect(302, "/login")
|
||||
return ctx.Redirect(302, "/-/login")
|
||||
}
|
||||
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
@@ -136,7 +136,7 @@ func OauthCallback(ctx *context.Context) error {
|
||||
func OauthRegister(ctx *context.Context) error {
|
||||
if ctx.GetData("DisableSignup") == true {
|
||||
ctx.AddFlash(ctx.Tr("error.signup-disabled"), "error")
|
||||
return ctx.Redirect(302, "/login")
|
||||
return ctx.Redirect(302, "/-/login")
|
||||
}
|
||||
|
||||
sess := ctx.GetSession()
|
||||
@@ -154,7 +154,7 @@ func OauthRegister(ctx *context.Context) error {
|
||||
func ProcessOauthRegister(ctx *context.Context) error {
|
||||
if ctx.GetData("DisableSignup") == true {
|
||||
ctx.AddFlash(ctx.Tr("error.signup-disabled"), "error")
|
||||
return ctx.Redirect(302, "/login")
|
||||
return ctx.Redirect(302, "/-/login")
|
||||
}
|
||||
|
||||
sess := ctx.GetSession()
|
||||
@@ -292,8 +292,8 @@ func OauthUnlink(ctx *context.Context) error {
|
||||
}
|
||||
|
||||
ctx.AddFlash(ctx.Tr("flash.auth.account-unlinked-oauth", config.C.OIDCProviderName), "success")
|
||||
return ctx.RedirectTo("/settings")
|
||||
return ctx.RedirectTo("/-/settings")
|
||||
}
|
||||
|
||||
return ctx.RedirectTo("/settings")
|
||||
return ctx.RedirectTo("/-/settings")
|
||||
}
|
||||
|
||||
@@ -103,6 +103,6 @@ func TestOIDCLoginPKCE(t *testing.T) {
|
||||
})
|
||||
defer resp.Body.Close()
|
||||
|
||||
require.Equal(t, "/login", resp.Request.URL.Path)
|
||||
require.Equal(t, "/-/login", resp.Request.URL.Path)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -131,7 +131,7 @@ func ProcessLogin(ctx *context.Context) error {
|
||||
if errors.As(err, &authErr) {
|
||||
log.Warn().Msg("Invalid HTTP authentication attempt from " + ctx.RealIP())
|
||||
ctx.AddFlash(ctx.Tr("flash.auth.invalid-credentials"), "error")
|
||||
return ctx.RedirectTo("/login")
|
||||
return ctx.RedirectTo("/-/login")
|
||||
}
|
||||
return ctx.ErrorRes(500, "Authentication system error", nil)
|
||||
}
|
||||
@@ -159,5 +159,5 @@ func ProcessLogin(ctx *context.Context) error {
|
||||
func Logout(ctx *context.Context) error {
|
||||
ctx.DeleteSession()
|
||||
ctx.DeleteCsrfCookie()
|
||||
return ctx.RedirectTo("/all")
|
||||
return ctx.RedirectTo("/-/all")
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ func TestRegisterPage(t *testing.T) {
|
||||
s.Register(t, "thomas")
|
||||
|
||||
t.Run("Form", func(t *testing.T) {
|
||||
s.Request(t, "GET", "/register", nil, 200)
|
||||
s.Request(t, "GET", "/-/register", nil, 200)
|
||||
s.TestCtxData(t, echo.Map{
|
||||
"isLoginPage": false,
|
||||
"disableForm": false,
|
||||
@@ -26,10 +26,10 @@ func TestRegisterPage(t *testing.T) {
|
||||
|
||||
t.Run("FormDisabled", func(t *testing.T) {
|
||||
s.Login(t, "thomas")
|
||||
s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {"disable-signup"}, "value": {"1"}}, 200)
|
||||
s.Request(t, "PUT", "/-/admin-panel/set-config", url.Values{"key": {"disable-signup"}, "value": {"1"}}, 200)
|
||||
s.Logout()
|
||||
|
||||
s.Request(t, "GET", "/register", nil, 200)
|
||||
s.Request(t, "GET", "/-/register", nil, 200)
|
||||
s.TestCtxData(t, echo.Map{
|
||||
"disableSignup": true,
|
||||
})
|
||||
@@ -37,9 +37,9 @@ func TestRegisterPage(t *testing.T) {
|
||||
|
||||
t.Run("FormDisabledWithInviteCode", func(t *testing.T) {
|
||||
s.Login(t, "thomas")
|
||||
s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {"disable-signup"}, "value": {"1"}}, 200)
|
||||
s.Request(t, "PUT", "/-/admin-panel/set-config", url.Values{"key": {"disable-signup"}, "value": {"1"}}, 200)
|
||||
|
||||
s.Request(t, "POST", "/admin-panel/invitations", url.Values{
|
||||
s.Request(t, "POST", "/-/admin-panel/invitations", url.Values{
|
||||
"nbMax": {"10"},
|
||||
"expiredAtUnix": {""},
|
||||
}, 302)
|
||||
@@ -49,11 +49,11 @@ func TestRegisterPage(t *testing.T) {
|
||||
|
||||
s.Logout()
|
||||
|
||||
s.Request(t, "GET", "/register", nil, 200)
|
||||
s.Request(t, "GET", "/-/register", nil, 200)
|
||||
s.TestCtxData(t, echo.Map{
|
||||
"disableSignup": true,
|
||||
})
|
||||
s.Request(t, "GET", "/register?code="+invitation.Code, nil, 200)
|
||||
s.Request(t, "GET", "/-/register?code="+invitation.Code, nil, 200)
|
||||
s.TestCtxData(t, echo.Map{
|
||||
"disableSignup": false,
|
||||
})
|
||||
@@ -73,7 +73,7 @@ func TestProcessRegister(t *testing.T) {
|
||||
require.True(t, user.IsAdmin)
|
||||
s.Logout()
|
||||
|
||||
s.Request(t, "POST", "/register", db.UserDTO{Username: "seconduser", Password: "password123"}, 302)
|
||||
s.Request(t, "POST", "/-/register", db.UserDTO{Username: "seconduser", Password: "password123"}, 302)
|
||||
user, err = db.GetUserByUsername("seconduser")
|
||||
require.NoError(t, err)
|
||||
require.False(t, user.IsAdmin)
|
||||
@@ -81,27 +81,32 @@ func TestProcessRegister(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("DuplicateUsername", func(t *testing.T) {
|
||||
s.Request(t, "POST", "/register", db.UserDTO{Username: "useraaa", Password: "password123"}, 302)
|
||||
s.Request(t, "POST", "/-/register", db.UserDTO{Username: "useraaa", Password: "password123"}, 302)
|
||||
s.Logout()
|
||||
s.Request(t, "POST", "/register", db.UserDTO{Username: "useraaa", Password: "password456"}, 200)
|
||||
s.Request(t, "POST", "/-/register", db.UserDTO{Username: "useraaa", Password: "password456"}, 200)
|
||||
s.Logout()
|
||||
})
|
||||
|
||||
t.Run("InvalidUsername", func(t *testing.T) {
|
||||
s.Request(t, "POST", "/register", db.UserDTO{Username: "", Password: "password123"}, 200)
|
||||
s.Request(t, "POST", "/register", db.UserDTO{Username: "aze@", Password: "password123"}, 200)
|
||||
s.Request(t, "POST", "/-/register", db.UserDTO{Username: "", Password: "password123"}, 200)
|
||||
s.Request(t, "POST", "/-/register", db.UserDTO{Username: "aze@", Password: "password123"}, 200)
|
||||
s.Request(t, "POST", "/-/register", db.UserDTO{Username: "-", Password: "password123"}, 200)
|
||||
|
||||
exists, err := db.UserExists("-")
|
||||
require.NoError(t, err)
|
||||
require.False(t, exists)
|
||||
})
|
||||
|
||||
t.Run("EmptyPassword", func(t *testing.T) {
|
||||
s.Request(t, "POST", "/register", db.UserDTO{Username: "newuser", Password: ""}, 200)
|
||||
s.Request(t, "POST", "/-/register", db.UserDTO{Username: "newuser", Password: ""}, 200)
|
||||
})
|
||||
|
||||
t.Run("RegisterDisabled", func(t *testing.T) {
|
||||
s.Login(t, "thomas")
|
||||
s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {"disable-signup"}, "value": {"1"}}, 200)
|
||||
s.Request(t, "PUT", "/-/admin-panel/set-config", url.Values{"key": {"disable-signup"}, "value": {"1"}}, 200)
|
||||
s.Logout()
|
||||
|
||||
s.Request(t, "POST", "/register", db.UserDTO{Username: "blocked", Password: "password123"}, 403)
|
||||
s.Request(t, "POST", "/-/register", db.UserDTO{Username: "blocked", Password: "password123"}, 403)
|
||||
|
||||
exists, err := db.UserExists("blocked")
|
||||
require.NoError(t, err)
|
||||
@@ -110,8 +115,8 @@ func TestProcessRegister(t *testing.T) {
|
||||
|
||||
t.Run("RegisterWithInvitationCode", func(t *testing.T) {
|
||||
s.Login(t, "thomas")
|
||||
s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {"disable-signup"}, "value": {"1"}}, 200)
|
||||
s.Request(t, "POST", "/admin-panel/invitations", url.Values{
|
||||
s.Request(t, "PUT", "/-/admin-panel/set-config", url.Values{"key": {"disable-signup"}, "value": {"1"}}, 200)
|
||||
s.Request(t, "POST", "/-/admin-panel/invitations", url.Values{
|
||||
"nbMax": {"10"},
|
||||
"expiredAtUnix": {""},
|
||||
}, 302)
|
||||
@@ -124,7 +129,7 @@ func TestProcessRegister(t *testing.T) {
|
||||
|
||||
s.Logout()
|
||||
|
||||
s.Request(t, "POST", "/register?code="+invitation.Code, db.UserDTO{Username: "inviteduser", Password: "password123"}, 302)
|
||||
s.Request(t, "POST", "/-/register?code="+invitation.Code, db.UserDTO{Username: "inviteduser", Password: "password123"}, 302)
|
||||
|
||||
user, err := db.GetUserByUsername("inviteduser")
|
||||
require.NoError(t, err)
|
||||
@@ -142,7 +147,7 @@ func TestLoginPage(t *testing.T) {
|
||||
s.Register(t, "thomas")
|
||||
|
||||
t.Run("Form", func(t *testing.T) {
|
||||
s.Request(t, "GET", "/login", nil, 200)
|
||||
s.Request(t, "GET", "/-/login", nil, 200)
|
||||
s.TestCtxData(t, echo.Map{
|
||||
"isLoginPage": true,
|
||||
"disableForm": false,
|
||||
@@ -151,10 +156,10 @@ func TestLoginPage(t *testing.T) {
|
||||
|
||||
t.Run("FormDisabled", func(t *testing.T) {
|
||||
s.Login(t, "thomas")
|
||||
s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {"disable-login-form"}, "value": {"1"}}, 200)
|
||||
s.Request(t, "PUT", "/-/admin-panel/set-config", url.Values{"key": {"disable-login-form"}, "value": {"1"}}, 200)
|
||||
s.Logout()
|
||||
|
||||
s.Request(t, "GET", "/login", nil, 200)
|
||||
s.Request(t, "GET", "/-/login", nil, 200)
|
||||
s.TestCtxData(t, echo.Map{
|
||||
"disableForm": true,
|
||||
})
|
||||
@@ -168,7 +173,7 @@ func TestProcessLogin(t *testing.T) {
|
||||
s.Register(t, "thomas")
|
||||
|
||||
t.Run("ValidCredentials", func(t *testing.T) {
|
||||
resp := s.Request(t, "POST", "/login", db.UserDTO{Username: "thomas", Password: "thomas"}, 302)
|
||||
resp := s.Request(t, "POST", "/-/login", db.UserDTO{Username: "thomas", Password: "thomas"}, 302)
|
||||
require.Equal(t, "/", resp.Header.Get("Location"))
|
||||
require.NotEmpty(t, s.SessionCookie)
|
||||
require.Equal(t, "thomas", s.User().Username)
|
||||
@@ -177,28 +182,28 @@ func TestProcessLogin(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("InvalidPassword", func(t *testing.T) {
|
||||
resp := s.Request(t, "POST", "/login", db.UserDTO{Username: "thomas", Password: "wrongpassword"}, 302)
|
||||
require.Equal(t, "/login", resp.Header.Get("Location"))
|
||||
resp := s.Request(t, "POST", "/-/login", db.UserDTO{Username: "thomas", Password: "wrongpassword"}, 302)
|
||||
require.Equal(t, "/-/login", resp.Header.Get("Location"))
|
||||
require.Nil(t, s.User())
|
||||
})
|
||||
|
||||
t.Run("NonExistentUser", func(t *testing.T) {
|
||||
resp := s.Request(t, "POST", "/login", db.UserDTO{Username: "nonexistent", Password: "password"}, 302)
|
||||
require.Equal(t, "/login", resp.Header.Get("Location"))
|
||||
resp := s.Request(t, "POST", "/-/login", db.UserDTO{Username: "nonexistent", Password: "password"}, 302)
|
||||
require.Equal(t, "/-/login", resp.Header.Get("Location"))
|
||||
require.Nil(t, s.User())
|
||||
})
|
||||
|
||||
t.Run("EmptyCredentials", func(t *testing.T) {
|
||||
s.Request(t, "POST", "/login", db.UserDTO{Username: "", Password: ""}, 302)
|
||||
s.Request(t, "POST", "/-/login", db.UserDTO{Username: "", Password: ""}, 302)
|
||||
require.Nil(t, s.User())
|
||||
})
|
||||
|
||||
t.Run("LoginFormDisabled", func(t *testing.T) {
|
||||
s.Login(t, "thomas")
|
||||
s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {"disable-login-form"}, "value": {"1"}}, 200)
|
||||
s.Request(t, "PUT", "/-/admin-panel/set-config", url.Values{"key": {"disable-login-form"}, "value": {"1"}}, 200)
|
||||
s.Logout()
|
||||
|
||||
s.Request(t, "POST", "/login", db.UserDTO{Username: "thomas", Password: "thomas"}, 403)
|
||||
s.Request(t, "POST", "/-/login", db.UserDTO{Username: "thomas", Password: "thomas"}, 403)
|
||||
require.Nil(t, s.User())
|
||||
})
|
||||
}
|
||||
@@ -213,8 +218,8 @@ func TestLogout(t *testing.T) {
|
||||
s.Login(t, "thomas")
|
||||
require.Equal(t, "thomas", s.User().Username)
|
||||
|
||||
resp := s.Request(t, "GET", "/logout", nil, 302)
|
||||
require.Equal(t, "/all", resp.Header.Get("Location"))
|
||||
resp := s.Request(t, "GET", "/-/logout", nil, 302)
|
||||
require.Equal(t, "/-/all", resp.Header.Get("Location"))
|
||||
require.Nil(t, s.User())
|
||||
s.Request(t, "GET", "/", nil, 302)
|
||||
})
|
||||
|
||||
@@ -15,7 +15,7 @@ func BeginTotp(ctx *context.Context) error {
|
||||
return ctx.ErrorRes(500, "Cannot check for user MFA", err)
|
||||
} else if hasTotp {
|
||||
ctx.AddFlash(ctx.Tr("auth.totp.already-enabled"), "error")
|
||||
return ctx.RedirectTo("/settings/mfa")
|
||||
return ctx.RedirectTo("/-/settings/authentication")
|
||||
}
|
||||
|
||||
ogUrl, err := url.Parse(ctx.GetData("baseHttpUrl").(string))
|
||||
@@ -36,6 +36,7 @@ func BeginTotp(ctx *context.Context) error {
|
||||
|
||||
ctx.SetData("totpSecret", totpSecret)
|
||||
ctx.SetData("totpQrcode", qrcode)
|
||||
ctx.SetData("settingsHeaderPage", "authentication")
|
||||
|
||||
return ctx.Html("totp.html")
|
||||
|
||||
@@ -48,7 +49,7 @@ func FinishTotp(ctx *context.Context) error {
|
||||
return ctx.ErrorRes(500, "Cannot check for user MFA", err)
|
||||
} else if hasTotp {
|
||||
ctx.AddFlash(ctx.Tr("auth.totp.already-enabled"), "error")
|
||||
return ctx.RedirectTo("/settings/mfa")
|
||||
return ctx.RedirectTo("/-/settings/authentication")
|
||||
}
|
||||
|
||||
dto := &db.TOTPDTO{}
|
||||
@@ -58,7 +59,7 @@ func FinishTotp(ctx *context.Context) error {
|
||||
|
||||
if err := ctx.Validate(dto); err != nil {
|
||||
ctx.AddFlash("Invalid secret", "error")
|
||||
return ctx.RedirectTo("/settings/totp/generate")
|
||||
return ctx.RedirectTo("/-/settings/totp/generate")
|
||||
}
|
||||
|
||||
sess := ctx.GetSession()
|
||||
@@ -70,7 +71,7 @@ func FinishTotp(ctx *context.Context) error {
|
||||
if !totp.Validate(dto.Code, secret) {
|
||||
ctx.AddFlash(ctx.Tr("auth.totp.invalid-code"), "error")
|
||||
|
||||
return ctx.RedirectTo("/settings/totp/generate")
|
||||
return ctx.RedirectTo("/-/settings/totp/generate")
|
||||
}
|
||||
|
||||
userTotp := &db.TOTP{
|
||||
@@ -95,6 +96,7 @@ func FinishTotp(ctx *context.Context) error {
|
||||
ctx.SaveSession(sess)
|
||||
|
||||
ctx.SetData("recoveryCodes", codes)
|
||||
ctx.SetData("settingsHeaderPage", "authentication")
|
||||
return ctx.Html("totp.html")
|
||||
}
|
||||
|
||||
@@ -135,7 +137,7 @@ func AssertTotp(ctx *context.Context) error {
|
||||
}
|
||||
|
||||
ctx.AddFlash(ctx.Tr("auth.totp.code-used", dto.Code), "warning")
|
||||
redirectUrl = "/settings/mfa"
|
||||
redirectUrl = "/-/settings/authentication"
|
||||
}
|
||||
|
||||
sess.Values["user"] = userId
|
||||
@@ -158,7 +160,7 @@ func DisableTotp(ctx *context.Context) error {
|
||||
}
|
||||
|
||||
ctx.AddFlash(ctx.Tr("auth.totp.disabled"), "success")
|
||||
return ctx.RedirectTo("/settings/mfa")
|
||||
return ctx.RedirectTo("/-/settings/authentication")
|
||||
}
|
||||
|
||||
func RegenerateTotpRecoveryCodes(ctx *context.Context) error {
|
||||
@@ -174,5 +176,6 @@ func RegenerateTotpRecoveryCodes(ctx *context.Context) error {
|
||||
}
|
||||
|
||||
ctx.SetData("recoveryCodes", codes)
|
||||
ctx.SetData("settingsHeaderPage", "authentication")
|
||||
return ctx.Html("totp.html")
|
||||
}
|
||||
|
||||
@@ -21,10 +21,19 @@ func AllGists(ctx *context.Context) error {
|
||||
fromUserStr := ctx.Param("user")
|
||||
userLogged := ctx.User
|
||||
pageInt := handlers.GetPage(ctx)
|
||||
mode := ctx.GetData("mode")
|
||||
|
||||
sort := "created"
|
||||
order := "desc"
|
||||
|
||||
// Some feeds (e.g. "recently liked" / "recently forked") have a fixed order:
|
||||
// creation time descending, ignoring the user's default-sort style and the
|
||||
// sort/order query params. The template hides the sort dropdown when sortable
|
||||
// is false.
|
||||
sortable := mode != "all-liked" && mode != "all-forked"
|
||||
ctx.SetData("sortable", sortable)
|
||||
|
||||
if sortable {
|
||||
if userLogged != nil {
|
||||
if style := userLogged.GetStyle(); style != nil {
|
||||
if style.DefaultSort == "updated" {
|
||||
@@ -47,6 +56,7 @@ func AllGists(ctx *context.Context) error {
|
||||
} else if ctx.QueryParam("order") == "desc" {
|
||||
order = "desc"
|
||||
}
|
||||
}
|
||||
|
||||
sortText := ctx.TrH("gist.list.sort-by-" + sort)
|
||||
orderText := ctx.TrH("gist.list.order-by-" + order)
|
||||
@@ -67,24 +77,50 @@ func AllGists(ctx *context.Context) error {
|
||||
currentUserId = 0
|
||||
}
|
||||
|
||||
mode := ctx.GetData("mode")
|
||||
if fromUserStr == "" {
|
||||
switch mode {
|
||||
case "search":
|
||||
ctx.SetData("htmlTitle", ctx.TrH("gist.list.search-results"))
|
||||
ctx.SetData("searchQuery", ctx.QueryParam("q"))
|
||||
pagination.Query = ctx.QueryParam("q")
|
||||
urlPage = "search"
|
||||
urlPage = "-/search"
|
||||
gists, err = db.GetAllGistsFromSearch(currentUserId, ctx.QueryParam("q"), pageInt-1, sort, order, "")
|
||||
case "topics":
|
||||
ctx.SetData("htmlTitle", ctx.TrH("gist.list.topic-results-topic", ctx.Param("topic")))
|
||||
ctx.SetData("topic", ctx.Param("topic"))
|
||||
urlPage = "topics/" + ctx.Param("topic")
|
||||
gists, err = db.GetAllGistsFromSearch(currentUserId, "", pageInt-1, sort, order, ctx.Param("topic"))
|
||||
urlPage = "-/topics/" + ctx.Param("topic")
|
||||
|
||||
if languages, err := db.GetGistLanguagesByTopic(currentUserId, ctx.Param("topic")); err != nil {
|
||||
return ctx.ErrorRes(500, "Error fetching languages", err)
|
||||
} else {
|
||||
ctx.SetData("languages", languages)
|
||||
}
|
||||
|
||||
title, language, visibility, topics := readGistFilters(ctx, pagination)
|
||||
gists, _, err = db.GetAllGistsByTopicFiltered(currentUserId, ctx.Param("topic"), title, language, visibility, topics, pageInt-1, sort, order)
|
||||
case "all":
|
||||
ctx.SetData("currentPage", "all")
|
||||
ctx.SetData("htmlTitle", ctx.TrH("gist.list.all"))
|
||||
urlPage = "all"
|
||||
gists, err = db.GetAllGistsForCurrentUser(currentUserId, nil, pageInt-1, sort, order, 11, 10)
|
||||
urlPage = "-/all"
|
||||
|
||||
if languages, err := db.GetGistLanguages(currentUserId); err != nil {
|
||||
return ctx.ErrorRes(500, "Error fetching languages", err)
|
||||
} else {
|
||||
ctx.SetData("languages", languages)
|
||||
}
|
||||
|
||||
title, language, visibility, topics := readGistFilters(ctx, pagination)
|
||||
gists, _, err = db.GetAllGistsForCurrentUserFiltered(currentUserId, title, language, visibility, topics, pageInt-1, sort, order)
|
||||
case "all-liked":
|
||||
ctx.SetData("currentPage", "recently-liked")
|
||||
ctx.SetData("htmlTitle", ctx.TrH("gist.list.recently-liked"))
|
||||
urlPage = "-/liked"
|
||||
gists, err = db.GetAllGistsLiked(currentUserId, nil, pageInt-1, sort, order, 11, 10)
|
||||
case "all-forked":
|
||||
ctx.SetData("currentPage", "recently-forked")
|
||||
ctx.SetData("htmlTitle", ctx.TrH("gist.list.recently-forked"))
|
||||
urlPage = "-/forked"
|
||||
gists, err = db.GetAllGistsForked(currentUserId, nil, pageInt-1, sort, order, 11, 10)
|
||||
}
|
||||
} else {
|
||||
var fromUser *db.User
|
||||
@@ -99,6 +135,12 @@ func AllGists(ctx *context.Context) error {
|
||||
}
|
||||
ctx.SetData("fromUser", fromUser)
|
||||
|
||||
// Highlight "My gists" on any of the logged-in user's own tabs
|
||||
// (gists, liked, forked).
|
||||
if userLogged != nil && fromUserStr == userLogged.Username {
|
||||
ctx.SetData("currentPage", "mine")
|
||||
}
|
||||
|
||||
if countFromUser, err := db.CountAllGistsFromUser(fromUser.ID, currentUserId); err != nil {
|
||||
return ctx.ErrorRes(500, "Error counting gists", err)
|
||||
} else {
|
||||
@@ -134,25 +176,7 @@ func AllGists(ctx *context.Context) error {
|
||||
} 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)
|
||||
title, language, visibility, topics := readGistFilters(ctx, pagination)
|
||||
ctx.SetData("htmlTitle", ctx.TrH("gist.list.all-from", fromUserStr))
|
||||
gists, count, err = db.GetAllGistsFromUser(fromUser.ID, currentUserId, title, language, visibility, topics, pageInt-1, sort, order)
|
||||
ctx.SetData("countFromUser", count)
|
||||
@@ -178,6 +202,34 @@ func AllGists(ctx *context.Context) error {
|
||||
return ctx.Html("all.html")
|
||||
}
|
||||
|
||||
// readGistFilters pulls the faceted filter query params (title, language,
|
||||
// visibility, topics) shared by the explore "all"/"topics" feeds and the
|
||||
// per-user gist list, mirrors them into pagination and template data, and
|
||||
// returns the parsed values.
|
||||
func readGistFilters(ctx *context.Context, pagination *handlers.PaginationParams) (title, language, visibility string, topics []string) {
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
// Search handles the search page for gists.
|
||||
//
|
||||
// It takes a query parameter "q" which is a search query in the format:
|
||||
|
||||
@@ -17,6 +17,8 @@ import (
|
||||
)
|
||||
|
||||
func Create(ctx *context.Context) error {
|
||||
ctx.SetData("currentPage", "new")
|
||||
ctx.SetData("dto", new(db.GistDTO))
|
||||
ctx.SetData("htmlTitle", ctx.TrH("gist.new.create-a-new-gist"))
|
||||
return ctx.Html("create.html")
|
||||
}
|
||||
@@ -139,7 +141,9 @@ func ProcessCreate(ctx *context.Context) error {
|
||||
if isCreate {
|
||||
gist = dto.ToGist()
|
||||
gist.ExpiresAt = dto.ExpiresAtTimestamp()
|
||||
} else {
|
||||
} else if ctx.FormValue("_edit_metadata") == "1" {
|
||||
// The legacy editor still edits files and metadata in one form. The new
|
||||
// editor omits this marker because metadata has its own settings page.
|
||||
gist = dto.ToExistingGist(gist)
|
||||
}
|
||||
|
||||
|
||||
@@ -139,3 +139,41 @@ func TestArchive(t *testing.T) {
|
||||
}, 302)
|
||||
})
|
||||
}
|
||||
|
||||
func TestLegacyEditorUpdatesMetadata(t *testing.T) {
|
||||
s := webtest.Setup(t)
|
||||
defer webtest.Teardown(t)
|
||||
|
||||
s.Register(t, "thomas")
|
||||
_, original, username, identifier := s.CreateGist(t, "0")
|
||||
|
||||
s.Login(t, "thomas")
|
||||
s.Request(t, "POST", "/"+username+"/"+identifier+"/edit", url.Values{
|
||||
"_edit_metadata": {"1"},
|
||||
"title": {"Updated from legacy UI"},
|
||||
"description": {"Updated description"},
|
||||
"url": {"legacy-url"},
|
||||
"topics": {"legacy compatibility"},
|
||||
"name": {"file.txt"},
|
||||
"content": {"updated content"},
|
||||
}, 302)
|
||||
|
||||
updated, err := db.GetGist(username, "legacy-url")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, original.ID, updated.ID)
|
||||
require.Equal(t, "Updated from legacy UI", updated.Title)
|
||||
require.Equal(t, "Updated description", updated.Description)
|
||||
require.ElementsMatch(t, []string{"legacy", "compatibility"}, updated.TopicsSlice())
|
||||
|
||||
// A file-only edit from the new UI must keep metadata unchanged.
|
||||
s.Request(t, "POST", "/"+username+"/legacy-url/edit", url.Values{
|
||||
"name": {"file.txt"},
|
||||
"content": {"new UI content"},
|
||||
}, 302)
|
||||
|
||||
updated, err = db.GetGist(username, "legacy-url")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "Updated from legacy UI", updated.Title)
|
||||
require.Equal(t, "Updated description", updated.Description)
|
||||
require.ElementsMatch(t, []string{"legacy", "compatibility"}, updated.TopicsSlice())
|
||||
}
|
||||
|
||||
@@ -82,6 +82,7 @@ func Forks(ctx *context.Context) error {
|
||||
return ctx.ErrorRes(404, ctx.Tr("error.page-not-found"), nil)
|
||||
}
|
||||
|
||||
ctx.SetData("page", "forks")
|
||||
ctx.SetData("htmlTitle", ctx.TrH("gist.forks.for", gist.Title))
|
||||
ctx.SetData("revision", "HEAD")
|
||||
return ctx.Html("forks.html")
|
||||
|
||||
@@ -144,6 +144,13 @@ func GistJs(ctx *context.Context) error {
|
||||
autoMode = false
|
||||
}
|
||||
|
||||
// In auto mode the card design follows the OS via a prefers-color-scheme
|
||||
// media query (see .theme-auto in embed.css), so it no longer depends on the
|
||||
// JS class-toggle alone.
|
||||
if autoMode {
|
||||
ctx.SetData("themeAuto", true)
|
||||
}
|
||||
|
||||
gist := ctx.GetData("gist").(*db.Gist)
|
||||
|
||||
var files []*git.File
|
||||
@@ -259,7 +266,7 @@ func escapeJavaScriptContent(htmlContent, cssUrl, themeUrl string, autoMode bool
|
||||
<style>
|
||||
@import url(${css1});
|
||||
@import url(${css2});
|
||||
:host { display: block; all: initial; font-family: sans-serif; }
|
||||
:host { display: block; font-family: sans-serif; font-size: 16px; line-height: 1.5; text-align: left; }
|
||||
</style>
|
||||
<div class="container">${content}</div>
|
||||
%s;
|
||||
@@ -313,7 +320,10 @@ func setGistCSP(ctx *context.Context) {
|
||||
"default-src 'self'; "+
|
||||
"script-src 'self' 'nonce-"+nonce+"'; "+
|
||||
"style-src 'self' 'unsafe-inline'; "+
|
||||
"img-src 'self' data:; "+
|
||||
// https: (not just 'self') so remote avatars — Gravatar, GitHub/GitLab/
|
||||
// Gitea/OIDC OAuth avatars — and images embedded in rendered gist
|
||||
// markdown are allowed.
|
||||
"img-src 'self' data: https:; "+
|
||||
"font-src 'self' data:; "+
|
||||
"connect-src 'self'; "+
|
||||
// 'self' (not 'none') so same-origin PDF previews keep working via
|
||||
|
||||
@@ -293,7 +293,7 @@ func TestGistAccess(t *testing.T) {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
s.Login(t, "thomas")
|
||||
for k, v := range tt.settings {
|
||||
s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {k}, "value": {v}}, 200)
|
||||
s.Request(t, "PUT", "/-/admin-panel/set-config", url.Values{"key": {k}, "value": {v}}, 200)
|
||||
}
|
||||
|
||||
t.Run("Owner", func(t *testing.T) {
|
||||
@@ -321,7 +321,7 @@ func TestGistAccess(t *testing.T) {
|
||||
|
||||
s.Login(t, "thomas")
|
||||
for k := range tt.settings {
|
||||
s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {k}, "value": {"0"}}, 200)
|
||||
s.Request(t, "PUT", "/-/admin-panel/set-config", url.Values{"key": {k}, "value": {"0"}}, 200)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ func Likes(ctx *context.Context) error {
|
||||
return ctx.ErrorRes(404, ctx.Tr("error.page-not-found"), nil)
|
||||
}
|
||||
|
||||
ctx.SetData("page", "likes")
|
||||
ctx.SetData("htmlTitle", ctx.TrH("gist.likes.for", gist.Title))
|
||||
ctx.SetData("revision", "HEAD")
|
||||
return ctx.Html("likes.html")
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package gist
|
||||
|
||||
import (
|
||||
"github.com/thomiceli/opengist/internal/db"
|
||||
"github.com/thomiceli/opengist/internal/i18n"
|
||||
"github.com/thomiceli/opengist/internal/validator"
|
||||
"github.com/thomiceli/opengist/internal/web/context"
|
||||
)
|
||||
|
||||
// GistSettings renders the per-gist settings page (visibility, archive, delete).
|
||||
// It is owner-only (gated by the writePermission middleware). The actions
|
||||
// themselves POST to the existing /visibility, /archive and /delete routes.
|
||||
func GistSettings(ctx *context.Context) error {
|
||||
gist := ctx.GetData("gist").(*db.Gist)
|
||||
|
||||
ctx.SetData("page", "settings")
|
||||
ctx.SetData("htmlTitle", gist.Title)
|
||||
return ctx.Html("gist_settings.html")
|
||||
}
|
||||
|
||||
// EditMetadata updates a gist's title, URL path, description and topics without
|
||||
// touching its files. Owner-only and blocked on archived gists (router guards).
|
||||
func EditMetadata(ctx *context.Context) error {
|
||||
gist := ctx.GetData("gist").(*db.Gist)
|
||||
|
||||
dto := new(db.GistMetadataDTO)
|
||||
if err := ctx.Bind(dto); err != nil {
|
||||
return ctx.ErrorRes(400, ctx.Tr("error.cannot-bind-data"), err)
|
||||
}
|
||||
if err := ctx.Validate(dto); err != nil {
|
||||
ctx.AddFlash(validator.ValidationMessages(&err, ctx.GetData("locale").(*i18n.Locale)), "error")
|
||||
return ctx.RedirectTo("/" + gist.User.Username + "/" + gist.Identifier() + "/settings")
|
||||
}
|
||||
|
||||
dto.ToExistingGist(gist)
|
||||
if err := gist.Update(); err != nil {
|
||||
return ctx.ErrorRes(500, "Error updating this gist", err)
|
||||
}
|
||||
gist.AddInIndex()
|
||||
|
||||
ctx.AddFlash(ctx.Tr("flash.gist.updated"), "success")
|
||||
return ctx.RedirectTo("/" + gist.User.Username + "/" + gist.Identifier())
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package gist
|
||||
|
||||
import (
|
||||
"github.com/thomiceli/opengist/internal/db"
|
||||
"github.com/thomiceli/opengist/internal/web/context"
|
||||
"github.com/thomiceli/opengist/internal/web/handlers"
|
||||
)
|
||||
|
||||
// Topics lists topics in use, ordered from most to least used, each linking to
|
||||
// the gists tagged with it. The list is not sortable and is paginated to 20
|
||||
// topics per page.
|
||||
func Topics(ctx *context.Context) error {
|
||||
var currentUserId uint
|
||||
if ctx.User != nil {
|
||||
currentUserId = ctx.User.ID
|
||||
}
|
||||
|
||||
pageInt := handlers.GetPage(ctx)
|
||||
|
||||
topics, err := db.GetTopicsWithCount(currentUserId, pageInt-1, 21, 20)
|
||||
if err != nil {
|
||||
return ctx.ErrorRes(500, "Error fetching topics", err)
|
||||
}
|
||||
|
||||
if err = handlers.Paginate(ctx, topics, pageInt, 20, "topics", "-/topics", 1, nil); err != nil {
|
||||
return ctx.ErrorRes(404, ctx.Tr("error.page-not-found"), nil)
|
||||
}
|
||||
|
||||
ctx.SetData("currentPage", "topics")
|
||||
ctx.SetData("htmlTitle", ctx.TrH("gist.list.topics"))
|
||||
return ctx.Html("topics.html")
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package gist
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/thomiceli/opengist/internal/db"
|
||||
"github.com/thomiceli/opengist/internal/web/context"
|
||||
"github.com/thomiceli/opengist/internal/web/handlers"
|
||||
)
|
||||
|
||||
// Users lists all users for the explore "Users" page, each with their avatar,
|
||||
// join date and number of visible gists. The list is paginated to 10 users per
|
||||
// page and sortable by username or join date, ascending or descending.
|
||||
func Users(ctx *context.Context) error {
|
||||
var currentUserId uint
|
||||
if ctx.User != nil {
|
||||
currentUserId = ctx.User.ID
|
||||
}
|
||||
|
||||
pageInt := handlers.GetPage(ctx)
|
||||
|
||||
// Resolve the sort field and order, defaulting to username ascending.
|
||||
sort := "username"
|
||||
sortColumn := "username_normalized"
|
||||
if ctx.QueryParam("sort") == "joined" {
|
||||
sort = "joined"
|
||||
sortColumn = "created_at"
|
||||
}
|
||||
|
||||
order := "asc"
|
||||
if ctx.QueryParam("order") == "desc" {
|
||||
order = "desc"
|
||||
}
|
||||
|
||||
query := strings.TrimSpace(ctx.QueryParam("q"))
|
||||
|
||||
users, err := db.GetUsersWithGistCounts(currentUserId, query, pageInt-1, 11, 10, sortColumn, order)
|
||||
if err != nil {
|
||||
return ctx.ErrorRes(500, "Error fetching users", err)
|
||||
}
|
||||
|
||||
pagination := &handlers.PaginationParams{
|
||||
Sort: sort,
|
||||
Order: order,
|
||||
Query: query,
|
||||
}
|
||||
|
||||
if err = handlers.Paginate(ctx, users, pageInt, 10, "users", "-/users", 1, pagination); err != nil {
|
||||
return ctx.ErrorRes(404, ctx.Tr("error.page-not-found"), nil)
|
||||
}
|
||||
|
||||
ctx.SetData("sort", sort)
|
||||
ctx.SetData("order", order)
|
||||
ctx.SetData("searchQuery", query)
|
||||
ctx.SetData("currentPage", "users")
|
||||
ctx.SetData("htmlTitle", ctx.TrH("gist.list.users"))
|
||||
return ctx.Html("explore_users.html")
|
||||
}
|
||||
@@ -100,7 +100,7 @@ func TestGitClonePull(t *testing.T) {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
s.Login(t, "thomas")
|
||||
for k, v := range tt.settings {
|
||||
s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {k}, "value": {v}}, 200)
|
||||
s.Request(t, "PUT", "/-/admin-panel/set-config", url.Values{"key": {k}, "value": {v}}, 200)
|
||||
}
|
||||
|
||||
for _, ct := range tt.creds {
|
||||
@@ -124,7 +124,7 @@ func TestGitClonePull(t *testing.T) {
|
||||
// Reset settings
|
||||
s.Login(t, "thomas")
|
||||
for k := range tt.settings {
|
||||
s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {k}, "value": {"0"}}, 200)
|
||||
s.Request(t, "PUT", "/-/admin-panel/set-config", url.Values{"key": {k}, "value": {"0"}}, 200)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ func TestMetrics(t *testing.T) {
|
||||
Topics: "",
|
||||
}, 302)
|
||||
|
||||
s.Request(t, "POST", "/settings/ssh-keys", db.SSHKeyDTO{
|
||||
s.Request(t, "POST", "/-/settings/ssh-keys", db.SSHKeyDTO{
|
||||
Title: "Test SSH Key",
|
||||
Content: `ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAklOUpkDHrfHY17SbrmTIpNLTGK9Tjom/BWDSUGPl+nafzlHDTYW7hdI4yZ5ew18JH4JW9jbhUFrviQzM7xlELEVf4h9lFX5QVkbPppSwg0cda3Pbv7kOdJ/MTyBlWXFCR+HAo3FXRitBqxiX1nKhXpHAZsMciLq8V6RjsNAQwdsdMFvSlVK/7XAt3FaoJoAsncM1Q9x5+3V0Ww68/eIFmb1zuUFljQJKprrX88XypNDvjYNby6vw/Pb0rwert/EnmZ+AW4OZPnTPI89ZPmVMLuayrD2cE86Z/il8b+gw3r3+1nKatmIkjn2so1d01QraTlMqVSsbxNrRFi9wrf+M7Q== admin@admin.local`,
|
||||
}, 302)
|
||||
|
||||
@@ -36,7 +36,7 @@ func AccessTokensProcess(ctx *context.Context) error {
|
||||
|
||||
if err := ctx.Validate(dto); err != nil {
|
||||
ctx.AddFlash(validator.ValidationMessages(&err, ctx.GetData("locale").(*i18n.Locale)), "error")
|
||||
return ctx.RedirectTo("/settings/access-tokens")
|
||||
return ctx.RedirectTo("/-/settings/access-tokens")
|
||||
}
|
||||
|
||||
token := dto.ToAccessToken()
|
||||
@@ -54,19 +54,19 @@ func AccessTokensProcess(ctx *context.Context) error {
|
||||
// Show the token once to the user
|
||||
ctx.AddFlash(ctx.Tr("settings.token-created"), "success")
|
||||
ctx.AddFlash(plainToken, "success")
|
||||
return ctx.RedirectTo("/settings/access-tokens")
|
||||
return ctx.RedirectTo("/-/settings/access-tokens")
|
||||
}
|
||||
|
||||
func AccessTokensDelete(ctx *context.Context) error {
|
||||
user := ctx.User
|
||||
tokenID, err := strconv.Atoi(ctx.Param("id"))
|
||||
if err != nil {
|
||||
return ctx.RedirectTo("/settings/access-tokens")
|
||||
return ctx.RedirectTo("/-/settings/access-tokens")
|
||||
}
|
||||
|
||||
token, err := db.GetAccessTokenByID(uint(tokenID))
|
||||
if err != nil || token.UserID != user.ID {
|
||||
return ctx.RedirectTo("/settings/access-tokens")
|
||||
return ctx.RedirectTo("/-/settings/access-tokens")
|
||||
}
|
||||
|
||||
if err := token.Delete(); err != nil {
|
||||
@@ -74,5 +74,5 @@ func AccessTokensDelete(ctx *context.Context) error {
|
||||
}
|
||||
|
||||
ctx.AddFlash(ctx.Tr("settings.token-deleted"), "success")
|
||||
return ctx.RedirectTo("/settings/access-tokens")
|
||||
return ctx.RedirectTo("/-/settings/access-tokens")
|
||||
}
|
||||
|
||||
@@ -18,17 +18,17 @@ func TestAccessTokensCRUD(t *testing.T) {
|
||||
|
||||
t.Run("RequiresAuth", func(t *testing.T) {
|
||||
s.Logout()
|
||||
s.Request(t, "GET", "/settings/access-tokens", nil, 302)
|
||||
s.Request(t, "GET", "/-/settings/access-tokens", nil, 302)
|
||||
})
|
||||
|
||||
t.Run("AccessTokensPage", func(t *testing.T) {
|
||||
s.Login(t, "thomas")
|
||||
s.Request(t, "GET", "/settings/access-tokens", nil, 200)
|
||||
s.Request(t, "GET", "/-/settings/access-tokens", nil, 200)
|
||||
})
|
||||
|
||||
t.Run("CreateReadToken", func(t *testing.T) {
|
||||
s.Login(t, "thomas")
|
||||
s.Request(t, "POST", "/settings/access-tokens", db.AccessTokenDTO{
|
||||
s.Request(t, "POST", "/-/settings/access-tokens", db.AccessTokenDTO{
|
||||
Name: "test-token",
|
||||
ScopeGist: db.ReadPermission,
|
||||
}, 302)
|
||||
@@ -44,7 +44,7 @@ func TestAccessTokensCRUD(t *testing.T) {
|
||||
t.Run("CreateExpiringToken", func(t *testing.T) {
|
||||
s.Login(t, "thomas")
|
||||
tomorrow := time.Now().AddDate(0, 0, 1).Format("2006-01-02")
|
||||
s.Request(t, "POST", "/settings/access-tokens", db.AccessTokenDTO{
|
||||
s.Request(t, "POST", "/-/settings/access-tokens", db.AccessTokenDTO{
|
||||
Name: "expiring-token",
|
||||
ScopeGist: db.ReadWritePermission,
|
||||
ExpiresAt: tomorrow,
|
||||
@@ -57,7 +57,7 @@ func TestAccessTokensCRUD(t *testing.T) {
|
||||
|
||||
t.Run("DeleteToken", func(t *testing.T) {
|
||||
s.Login(t, "thomas")
|
||||
s.Request(t, "DELETE", "/settings/access-tokens/1", nil, 302)
|
||||
s.Request(t, "DELETE", "/-/settings/access-tokens/1", nil, 302)
|
||||
|
||||
tokens, err := db.GetAccessTokensByUserID(1)
|
||||
require.NoError(t, err)
|
||||
@@ -279,7 +279,7 @@ func TestCreateTokenWithUserScope(t *testing.T) {
|
||||
s.Register(t, "thomas")
|
||||
s.Login(t, "thomas")
|
||||
|
||||
s.Request(t, "POST", "/settings/access-tokens", db.AccessTokenDTO{
|
||||
s.Request(t, "POST", "/-/settings/access-tokens", db.AccessTokenDTO{
|
||||
Name: "with-user",
|
||||
ScopeGist: db.ReadPermission,
|
||||
ScopeUser: db.ReadPermission,
|
||||
@@ -311,7 +311,7 @@ func TestAccessTokenWithRequireLogin(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, token.Create())
|
||||
|
||||
s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {db.SettingRequireLogin}, "value": {"1"}}, 200)
|
||||
s.Request(t, "PUT", "/-/admin-panel/set-config", url.Values{"key": {db.SettingRequireLogin}, "value": {"1"}}, 200)
|
||||
s.Logout()
|
||||
|
||||
headers := map[string]string{"Authorization": "Token " + plainToken}
|
||||
|
||||
@@ -37,7 +37,7 @@ func EmailProcess(ctx *context.Context) error {
|
||||
}
|
||||
|
||||
ctx.AddFlash(ctx.Tr("flash.user.email-updated"), "success")
|
||||
return ctx.RedirectTo("/settings")
|
||||
return ctx.RedirectTo("/-/settings")
|
||||
}
|
||||
|
||||
func AccountDeleteProcess(ctx *context.Context) error {
|
||||
@@ -48,7 +48,7 @@ func AccountDeleteProcess(ctx *context.Context) error {
|
||||
}
|
||||
opengistssh.SyncAuthorizedKeysLogged()
|
||||
|
||||
return ctx.RedirectTo("/all")
|
||||
return ctx.RedirectTo("/-/all")
|
||||
}
|
||||
|
||||
func UsernameProcess(ctx *context.Context) error {
|
||||
@@ -61,13 +61,13 @@ func UsernameProcess(ctx *context.Context) error {
|
||||
|
||||
if err := ctx.Validate(dto); err != nil {
|
||||
ctx.AddFlash(validator.ValidationMessages(&err, ctx.GetData("locale").(*i18n.Locale)), "error")
|
||||
return ctx.RedirectTo("/settings")
|
||||
return ctx.RedirectTo("/-/settings")
|
||||
}
|
||||
|
||||
if !strings.EqualFold(dto.Username, user.Username) {
|
||||
if exists, err := db.UserExists(dto.Username); err != nil || exists {
|
||||
ctx.AddFlash(ctx.Tr("flash.auth.username-exists"), "error")
|
||||
return ctx.RedirectTo("/settings")
|
||||
return ctx.RedirectTo("/-/settings")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,5 +90,5 @@ func UsernameProcess(ctx *context.Context) error {
|
||||
}
|
||||
|
||||
ctx.AddFlash(ctx.Tr("flash.user.username-updated"), "success")
|
||||
return ctx.RedirectTo("/settings")
|
||||
return ctx.RedirectTo("/-/settings")
|
||||
}
|
||||
|
||||
@@ -13,12 +13,12 @@ func PasskeyDelete(ctx *context.Context) error {
|
||||
user := ctx.User
|
||||
keyId, err := strconv.Atoi(ctx.Param("id"))
|
||||
if err != nil {
|
||||
return ctx.RedirectTo("/settings")
|
||||
return ctx.RedirectTo("/-/settings")
|
||||
}
|
||||
|
||||
passkey, err := db.GetCredentialByIDDB(uint(keyId))
|
||||
if err != nil || passkey.UserID != user.ID {
|
||||
return ctx.RedirectTo("/settings")
|
||||
return ctx.RedirectTo("/-/settings")
|
||||
}
|
||||
|
||||
if err := passkey.Delete(); err != nil {
|
||||
@@ -26,7 +26,7 @@ func PasskeyDelete(ctx *context.Context) error {
|
||||
}
|
||||
|
||||
ctx.AddFlash(ctx.Tr("flash.auth.passkey-deleted"), "success")
|
||||
return ctx.RedirectTo("/settings")
|
||||
return ctx.RedirectTo("/-/settings")
|
||||
}
|
||||
|
||||
func PasswordProcess(ctx *context.Context) error {
|
||||
@@ -40,7 +40,10 @@ func PasswordProcess(ctx *context.Context) error {
|
||||
|
||||
if err := ctx.Validate(dto); err != nil {
|
||||
ctx.AddFlash(validator.ValidationMessages(&err, ctx.GetData("locale").(*i18n.Locale)), "error")
|
||||
return ctx.Html("settings.html")
|
||||
if ctx.FormValue("_legacy_account") == "1" {
|
||||
return ctx.RedirectTo("/-/settings")
|
||||
}
|
||||
return ctx.RedirectTo("/-/settings/authentication")
|
||||
}
|
||||
|
||||
password, err := passwordpkg.HashPassword(dto.Password)
|
||||
@@ -54,5 +57,5 @@ func PasswordProcess(ctx *context.Context) error {
|
||||
}
|
||||
|
||||
ctx.AddFlash(ctx.Tr("flash.user.password-updated"), "success")
|
||||
return ctx.RedirectTo("/settings")
|
||||
return ctx.RedirectTo("/-/settings")
|
||||
}
|
||||
|
||||
@@ -32,12 +32,12 @@ func AvatarProcess(ctx *context.Context) error {
|
||||
header, err := ctx.FormFile("avatar")
|
||||
if err != nil {
|
||||
ctx.AddFlash(ctx.Tr("flash.user.avatar-invalid"), "error")
|
||||
return ctx.RedirectTo("/settings")
|
||||
return ctx.RedirectTo("/-/settings")
|
||||
}
|
||||
|
||||
if header.Size > maxAvatarSize {
|
||||
ctx.AddFlash(ctx.Tr("flash.user.avatar-too-large"), "error")
|
||||
return ctx.RedirectTo("/settings")
|
||||
return ctx.RedirectTo("/-/settings")
|
||||
}
|
||||
|
||||
src, err := header.Open()
|
||||
@@ -54,7 +54,7 @@ func AvatarProcess(ctx *context.Context) error {
|
||||
ext, ok := allowedAvatarTypes[contentType]
|
||||
if !ok {
|
||||
ctx.AddFlash(ctx.Tr("flash.user.avatar-invalid"), "error")
|
||||
return ctx.RedirectTo("/settings")
|
||||
return ctx.RedirectTo("/-/settings")
|
||||
}
|
||||
|
||||
if _, err = src.Seek(0, io.SeekStart); err != nil {
|
||||
@@ -85,14 +85,14 @@ func AvatarProcess(ctx *context.Context) error {
|
||||
}
|
||||
|
||||
ctx.AddFlash(ctx.Tr("flash.user.avatar-updated"), "success")
|
||||
return ctx.RedirectTo("/settings")
|
||||
return ctx.RedirectTo("/-/settings")
|
||||
}
|
||||
|
||||
func AvatarDelete(ctx *context.Context) error {
|
||||
user := ctx.User
|
||||
|
||||
if !user.HasUploadedAvatar() {
|
||||
return ctx.RedirectTo("/settings")
|
||||
return ctx.RedirectTo("/-/settings")
|
||||
}
|
||||
|
||||
removeAvatarFile(user)
|
||||
@@ -103,7 +103,7 @@ func AvatarDelete(ctx *context.Context) error {
|
||||
}
|
||||
|
||||
ctx.AddFlash(ctx.Tr("flash.user.avatar-deleted"), "success")
|
||||
return ctx.RedirectTo("/settings")
|
||||
return ctx.RedirectTo("/-/settings")
|
||||
}
|
||||
|
||||
func removeAvatarFile(user *db.User) {
|
||||
|
||||
@@ -2,6 +2,7 @@ package settings
|
||||
|
||||
import (
|
||||
"github.com/thomiceli/opengist/internal/db"
|
||||
"github.com/thomiceli/opengist/internal/validator"
|
||||
"github.com/thomiceli/opengist/internal/web/context"
|
||||
)
|
||||
|
||||
@@ -9,6 +10,9 @@ func UserAccount(ctx *context.Context) error {
|
||||
user := ctx.User
|
||||
|
||||
ctx.SetData("email", user.Email)
|
||||
// The legacy account page still contains the password form. The new UI
|
||||
// moved it to Authentication, but keeping this data preserves old-UI
|
||||
// behavior while both interfaces are available.
|
||||
ctx.SetData("hasPassword", user.Password != "")
|
||||
ctx.SetData("disableForm", ctx.GetData("DisableLoginForm"))
|
||||
ctx.SetData("settingsHeaderPage", "account")
|
||||
@@ -16,7 +20,7 @@ func UserAccount(ctx *context.Context) error {
|
||||
return ctx.Html("settings_account.html")
|
||||
}
|
||||
|
||||
func UserMFA(ctx *context.Context) error {
|
||||
func UserAuthentication(ctx *context.Context) error {
|
||||
user := ctx.User
|
||||
|
||||
passkeys, err := db.GetAllCredentialsForUser(user.ID)
|
||||
@@ -31,9 +35,11 @@ func UserMFA(ctx *context.Context) error {
|
||||
|
||||
ctx.SetData("passkeys", passkeys)
|
||||
ctx.SetData("hasTotp", hasTotp)
|
||||
ctx.SetData("settingsHeaderPage", "mfa")
|
||||
ctx.SetData("hasPassword", user.Password != "")
|
||||
ctx.SetData("disableForm", ctx.GetData("DisableLoginForm"))
|
||||
ctx.SetData("settingsHeaderPage", "authentication")
|
||||
ctx.SetData("htmlTitle", ctx.TrH("settings"))
|
||||
return ctx.Html("settings_mfa.html")
|
||||
return ctx.Html("settings_authentication.html")
|
||||
}
|
||||
|
||||
func UserSSHKeys(ctx *context.Context) error {
|
||||
@@ -52,6 +58,7 @@ func UserSSHKeys(ctx *context.Context) error {
|
||||
|
||||
func UserStyle(ctx *context.Context) error {
|
||||
ctx.SetData("settingsHeaderPage", "style")
|
||||
ctx.SetData("themeColors", validator.ThemeColors)
|
||||
ctx.SetData("htmlTitle", ctx.TrH("settings"))
|
||||
return ctx.Html("settings_style.html")
|
||||
}
|
||||
@@ -72,5 +79,5 @@ func ProcessUserStyle(ctx *context.Context) error {
|
||||
}
|
||||
|
||||
ctx.AddFlash("Updated style", "success")
|
||||
return ctx.RedirectTo("/settings/style")
|
||||
return ctx.RedirectTo("/-/settings/style")
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ func SshKeysProcess(ctx *context.Context) error {
|
||||
|
||||
if err := ctx.Validate(dto); err != nil {
|
||||
ctx.AddFlash(validator.ValidationMessages(&err, ctx.GetData("locale").(*i18n.Locale)), "error")
|
||||
return ctx.RedirectTo("/settings/ssh")
|
||||
return ctx.RedirectTo("/-/settings/ssh")
|
||||
}
|
||||
key := dto.ToSSHKey()
|
||||
|
||||
@@ -30,7 +30,7 @@ func SshKeysProcess(ctx *context.Context) error {
|
||||
pubKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(key.Content))
|
||||
if err != nil {
|
||||
ctx.AddFlash(ctx.Tr("flash.user.invalid-ssh-key"), "error")
|
||||
return ctx.RedirectTo("/settings/ssh")
|
||||
return ctx.RedirectTo("/-/settings/ssh")
|
||||
}
|
||||
key.Content = strings.TrimSpace(string(ssh.MarshalAuthorizedKey(pubKey)))
|
||||
|
||||
@@ -39,7 +39,7 @@ func SshKeysProcess(ctx *context.Context) error {
|
||||
return ctx.ErrorRes(500, "Cannot check if SSH key exists", err)
|
||||
}
|
||||
ctx.AddFlash(ctx.Tr("settings.ssh-key-exists"), "error")
|
||||
return ctx.RedirectTo("/settings/ssh")
|
||||
return ctx.RedirectTo("/-/settings/ssh")
|
||||
}
|
||||
|
||||
if err := key.Create(); err != nil {
|
||||
@@ -48,20 +48,20 @@ func SshKeysProcess(ctx *context.Context) error {
|
||||
opengistssh.SyncAuthorizedKeysLogged()
|
||||
|
||||
ctx.AddFlash(ctx.Tr("flash.user.ssh-key-added"), "success")
|
||||
return ctx.RedirectTo("/settings/ssh")
|
||||
return ctx.RedirectTo("/-/settings/ssh")
|
||||
}
|
||||
|
||||
func SshKeysDelete(ctx *context.Context) error {
|
||||
user := ctx.User
|
||||
keyId, err := strconv.Atoi(ctx.Param("id"))
|
||||
if err != nil {
|
||||
return ctx.RedirectTo("/settings/ssh")
|
||||
return ctx.RedirectTo("/-/settings/ssh")
|
||||
}
|
||||
|
||||
key, err := db.GetSSHKeyByID(uint(keyId))
|
||||
|
||||
if err != nil || key.UserID != user.ID {
|
||||
return ctx.RedirectTo("/settings/ssh")
|
||||
return ctx.RedirectTo("/-/settings/ssh")
|
||||
}
|
||||
|
||||
if err := key.Delete(); err != nil {
|
||||
@@ -70,5 +70,5 @@ func SshKeysDelete(ctx *context.Context) error {
|
||||
opengistssh.SyncAuthorizedKeysLogged()
|
||||
|
||||
ctx.AddFlash(ctx.Tr("flash.user.ssh-key-deleted"), "success")
|
||||
return ctx.RedirectTo("/settings/ssh")
|
||||
return ctx.RedirectTo("/-/settings/ssh")
|
||||
}
|
||||
|
||||
@@ -121,7 +121,7 @@ func (s *Server) errorHandler(err error, ctx echo.Context) {
|
||||
if acceptJson || data["err_render"] == "json" {
|
||||
renderErr = ctx.JSON(httpErr.Code, httpErr)
|
||||
} else {
|
||||
renderErr = ctx.Render(httpErr.Code, "error", data)
|
||||
renderErr = ctx.Render(httpErr.Code, "error.html", data)
|
||||
}
|
||||
|
||||
if renderErr != nil && !isClientGone(renderErr) {
|
||||
@@ -209,7 +209,7 @@ func logged(next Handler) Handler {
|
||||
if user != nil {
|
||||
return next(ctx)
|
||||
}
|
||||
return ctx.RedirectTo("/all")
|
||||
return ctx.RedirectTo("/-/all")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,7 +229,7 @@ func inOAuthRegisterSession(next Handler) Handler {
|
||||
sess := ctx.GetSession()
|
||||
_, ok := sess.Values["oauthProvider"].(string)
|
||||
if !ok {
|
||||
return ctx.RedirectTo("/login")
|
||||
return ctx.RedirectTo("/-/login")
|
||||
}
|
||||
return next(ctx)
|
||||
}
|
||||
@@ -252,7 +252,7 @@ func makeCheckRequireLogin(isSingleGistAccess bool) Middleware {
|
||||
|
||||
if !allow {
|
||||
ctx.AddFlash(ctx.Tr("flash.auth.must-be-logged-in"), "error")
|
||||
return ctx.RedirectTo("/login")
|
||||
return ctx.RedirectTo("/-/login")
|
||||
}
|
||||
return next(ctx)
|
||||
}
|
||||
@@ -275,7 +275,7 @@ func checkFileUploadEnabled(next Handler) Handler {
|
||||
// makeApiCheckRequireLogin is the /api/v1 counterpart of makeCheckRequireLogin:
|
||||
// it enforces the instance's RequireLogin / AllowGistsWithoutLogin settings on
|
||||
// anonymous gist reads, but responds with a JSON 401 instead of redirecting to
|
||||
// /login. ctx.User is already resolved from the Authorization header by
|
||||
// /-/login. ctx.User is already resolved from the Authorization header by
|
||||
// apiBindAuth, so there is no token fallback to do here.
|
||||
func makeApiCheckRequireLogin(isSingleGistAccess bool) Middleware {
|
||||
return func(next Handler) Handler {
|
||||
@@ -360,7 +360,7 @@ func sessionInit(next Handler) Handler {
|
||||
ctx.SaveSession(sess)
|
||||
ctx.User = nil
|
||||
ctx.SetData("userLogged", nil)
|
||||
return ctx.RedirectTo("/all")
|
||||
return ctx.RedirectTo("/-/all")
|
||||
}
|
||||
if user != nil {
|
||||
ctx.User = user
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
htmlpkg "html"
|
||||
"html/template"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
@@ -29,9 +30,15 @@ import (
|
||||
|
||||
type Template struct {
|
||||
templates *template.Template
|
||||
// pages holds layout-based templates, keyed by file name (e.g. "all.html").
|
||||
// Each entry is a clone of the base layout with that page's "content" block parsed in.
|
||||
pages map[string]*template.Template
|
||||
}
|
||||
|
||||
func (t *Template) Render(w io.Writer, name string, data interface{}, _ echo.Context) error {
|
||||
if tmpl, ok := t.pages[name]; ok {
|
||||
return tmpl.ExecuteTemplate(w, "base", data)
|
||||
}
|
||||
return t.templates.ExecuteTemplate(w, name, data)
|
||||
}
|
||||
|
||||
@@ -230,33 +237,59 @@ func (s *Server) setFuncMap() {
|
||||
},
|
||||
}
|
||||
|
||||
t := template.Must(template.New("t").Funcs(fm).ParseFS(templates.Files, "*/*.html"))
|
||||
base := template.Must(template.New("base").Funcs(fm).ParseFS(templates.Files, "layouts/*.html", "partials/*.html"))
|
||||
pagePaths, err := fs.Glob(templates.Files, "pages/*.html")
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("Failed to glob new page templates")
|
||||
}
|
||||
pages := make(map[string]*template.Template, len(pagePaths))
|
||||
for _, p := range pagePaths {
|
||||
cloned := template.Must(base.Clone())
|
||||
pages[filepath.Base(p)] = template.Must(cloned.ParseFS(templates.Files, p))
|
||||
}
|
||||
|
||||
customTemplates := template.Must(base.Clone())
|
||||
customPattern := filepath.Join(config.GetHomeDir(), "custom", "*.html")
|
||||
matches, err := filepath.Glob(customPattern)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("Failed to check for custom templates")
|
||||
}
|
||||
if len(matches) > 0 {
|
||||
t, err = t.ParseGlob(customPattern)
|
||||
customTemplates, err = customTemplates.ParseGlob(customPattern)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("Failed to parse custom templates")
|
||||
}
|
||||
}
|
||||
|
||||
s.echo.Renderer = &Template{
|
||||
templates: t,
|
||||
templates: customTemplates,
|
||||
pages: pages,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) parseManifestEntries() {
|
||||
file, err := public.Files.Open(".vite/manifest.json")
|
||||
entries, err := parseManifest(public.Files)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("Failed to open manifest.json")
|
||||
log.Fatal().Err(err).Msg("Failed to load manifest.json")
|
||||
}
|
||||
context.ManifestEntries = entries
|
||||
}
|
||||
|
||||
func parseManifest(files fs.FS) (map[string]context.Asset, error) {
|
||||
file, err := files.Open(".vite/manifest.json")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
byteValue, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("Failed to read manifest.json")
|
||||
return nil, err
|
||||
}
|
||||
if err = gojson.Unmarshal(byteValue, &context.ManifestEntries); err != nil {
|
||||
log.Fatal().Err(err).Msg("Failed to unmarshal manifest.json")
|
||||
|
||||
entries := make(map[string]context.Asset)
|
||||
if err = gojson.Unmarshal(byteValue, &entries); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
@@ -42,11 +42,11 @@ func (s *Server) registerRoutes() {
|
||||
|
||||
r.Static("/avatar", settings.AvatarsDir())
|
||||
|
||||
r.GET("/register", auth.Register)
|
||||
r.POST("/register", auth.ProcessRegister)
|
||||
r.GET("/login", auth.Login)
|
||||
r.POST("/login", auth.ProcessLogin)
|
||||
r.GET("/logout", auth.Logout)
|
||||
r.GET("/-/register", auth.Register)
|
||||
r.POST("/-/register", auth.ProcessRegister)
|
||||
r.GET("/-/login", auth.Login)
|
||||
r.POST("/-/login", auth.ProcessLogin)
|
||||
r.GET("/-/logout", auth.Logout)
|
||||
r.GET("/oauth/register", auth.OauthRegister, inOAuthRegisterSession)
|
||||
r.POST("/oauth/register", auth.ProcessOauthRegister, inOAuthRegisterSession)
|
||||
r.GET("/oauth/:provider", auth.Oauth)
|
||||
@@ -61,11 +61,11 @@ func (s *Server) registerRoutes() {
|
||||
r.GET("/mfa", auth.Mfa, inMFASession)
|
||||
r.POST("/mfa/totp/assertion", auth.AssertTotp, inMFASession)
|
||||
|
||||
sA := r.SubGroup("/settings")
|
||||
sA := r.SubGroup("/-/settings")
|
||||
{
|
||||
sA.Use(logged)
|
||||
sA.GET("", settings.UserAccount)
|
||||
sA.GET("/mfa", settings.UserMFA)
|
||||
sA.GET("/authentication", settings.UserAuthentication)
|
||||
sA.GET("/ssh", settings.UserSSHKeys)
|
||||
sA.GET("/style", settings.UserStyle)
|
||||
sA.POST("/style", settings.ProcessUserStyle)
|
||||
@@ -87,7 +87,7 @@ func (s *Server) registerRoutes() {
|
||||
sA.POST("/totp/regenerate", auth.RegenerateTotpRecoveryCodes)
|
||||
}
|
||||
|
||||
sB := r.SubGroup("/admin-panel")
|
||||
sB := r.SubGroup("/-/admin-panel")
|
||||
{
|
||||
sB.Use(adminPermission)
|
||||
sB.GET("", admin.AdminIndex)
|
||||
@@ -98,6 +98,7 @@ func (s *Server) registerRoutes() {
|
||||
sB.GET("/invitations", admin.AdminInvitations)
|
||||
sB.POST("/invitations", admin.AdminInvitationsCreate)
|
||||
sB.POST("/invitations/:id/delete", admin.AdminInvitationsDelete)
|
||||
sB.GET("/actions", admin.AdminActions)
|
||||
sB.POST("/sync-fs", admin.AdminSyncReposFromFS)
|
||||
sB.POST("/sync-db", admin.AdminSyncReposFromDB)
|
||||
sB.POST("/gc-repos", admin.AdminGcRepos)
|
||||
@@ -172,19 +173,23 @@ func (s *Server) registerRoutes() {
|
||||
|
||||
r.Any("/api/*", noRouteFoundApi)
|
||||
|
||||
r.GET("/all", gist.AllGists, checkRequireLogin, setAllGistsMode("all"))
|
||||
r.GET("/-/all", gist.AllGists, checkRequireLogin, setAllGistsMode("all"))
|
||||
r.GET("/-/liked", gist.AllGists, checkRequireLogin, setAllGistsMode("all-liked"))
|
||||
r.GET("/-/forked", gist.AllGists, checkRequireLogin, setAllGistsMode("all-forked"))
|
||||
r.GET("/-/topics", gist.Topics, checkRequireLogin)
|
||||
r.GET("/-/users", gist.Users, checkRequireLogin)
|
||||
|
||||
if index.IndexEnabled() {
|
||||
r.GET("/search", gist.Search, checkRequireLogin)
|
||||
r.GET("/-/search", gist.Search, checkRequireLogin)
|
||||
} else {
|
||||
r.GET("/search", gist.AllGists, checkRequireLogin, setAllGistsMode("search"))
|
||||
r.GET("/-/search", gist.AllGists, checkRequireLogin, setAllGistsMode("search"))
|
||||
}
|
||||
|
||||
r.GET("/:user", gist.AllGists, checkRequireLogin, setAllGistsMode("fromUser"))
|
||||
r.GET("/:user/liked", gist.AllGists, checkRequireLogin, setAllGistsMode("liked"))
|
||||
r.GET("/:user/forked", gist.AllGists, checkRequireLogin, setAllGistsMode("forked"))
|
||||
r.GET("/:user/-/liked", gist.AllGists, checkRequireLogin, setAllGistsMode("liked"))
|
||||
r.GET("/:user/-/forked", gist.AllGists, checkRequireLogin, setAllGistsMode("forked"))
|
||||
|
||||
r.GET("/topics/:topic", gist.AllGists, checkRequireLogin, setAllGistsMode("topics"))
|
||||
r.GET("/-/topics/:topic", gist.AllGists, checkRequireLogin, setAllGistsMode("topics"))
|
||||
|
||||
sC := r.SubGroup("/:user/:gistname")
|
||||
{
|
||||
@@ -200,6 +205,8 @@ func (s *Server) registerRoutes() {
|
||||
sC.HEAD("/raw/:revision/:file", gist.RawFile)
|
||||
sC.GET("/download/:revision/:file", gist.DownloadFile)
|
||||
sC.HEAD("/download/:revision/:file", gist.DownloadFile)
|
||||
sC.GET("/settings", gist.GistSettings, logged, writePermission)
|
||||
sC.POST("/metadata", gist.EditMetadata, logged, writePermission, notArchived)
|
||||
sC.GET("/edit", gist.Edit, logged, writePermission, notArchived)
|
||||
sC.POST("/edit", gist.ProcessCreate, logged, writePermission, notArchived)
|
||||
sC.POST("/like", gist.Like, logged)
|
||||
|
||||
@@ -37,12 +37,12 @@ func NewServer(isDev bool) *Server {
|
||||
}
|
||||
|
||||
s.registerMiddlewares()
|
||||
s.setFuncMap()
|
||||
s.echo.HTTPErrorHandler = s.errorHandler
|
||||
|
||||
if !s.dev {
|
||||
s.parseManifestEntries()
|
||||
}
|
||||
s.setFuncMap()
|
||||
|
||||
s.registerRoutes()
|
||||
|
||||
|
||||
@@ -154,11 +154,11 @@ func (s *Server) TestCtxData(t *testing.T, expected echo.Map) {
|
||||
}
|
||||
|
||||
func (s *Server) Register(t *testing.T, user string) {
|
||||
s.Request(t, "POST", "/register", db.UserDTO{Username: user, Password: user}, 302)
|
||||
s.Request(t, "POST", "/-/register", db.UserDTO{Username: user, Password: user}, 302)
|
||||
}
|
||||
|
||||
func (s *Server) Login(t *testing.T, user string) {
|
||||
s.Request(t, "POST", "/login", db.UserDTO{Username: user, Password: user}, 302)
|
||||
s.Request(t, "POST", "/-/login", db.UserDTO{Username: user, Password: user}, 302)
|
||||
}
|
||||
|
||||
func (s *Server) Logout() {
|
||||
@@ -166,7 +166,7 @@ func (s *Server) Logout() {
|
||||
}
|
||||
|
||||
func (s *Server) CreateGist(t *testing.T, visibility string) (gistPath string, gist *db.Gist, username, identifier string) {
|
||||
s.Request(t, "POST", "/register", db.UserDTO{Username: "thomas", Password: "thomas"}, 0)
|
||||
s.Request(t, "POST", "/-/register", db.UserDTO{Username: "thomas", Password: "thomas"}, 0)
|
||||
s.Login(t, "thomas")
|
||||
|
||||
resp := s.Request(t, "POST", "/", url.Values{
|
||||
|
||||
Generated
+27
@@ -18,10 +18,13 @@
|
||||
"@tailwindcss/forms": "^0.5.11",
|
||||
"@tailwindcss/typography": "^0.5.20",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"basecoat-css": "^0.3.11",
|
||||
"codemirror": "^6.0.2",
|
||||
"dompurify": "^3.4.12",
|
||||
"github-markdown-css": "^5.9.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"htmx.org": "^2.0.10",
|
||||
"hyperscript.org": "^0.9.93",
|
||||
"jdenticon": "^3.3.0",
|
||||
"katex": "^0.18.1",
|
||||
"marked": "^18.0.6",
|
||||
@@ -1451,6 +1454,13 @@
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/basecoat-css": {
|
||||
"version": "0.3.11",
|
||||
"resolved": "https://registry.npmjs.org/basecoat-css/-/basecoat-css-0.3.11.tgz",
|
||||
"integrity": "sha512-cU91Egcg9AhKJ1HswpKFItHC//FWDt1T8AlL5Yk9Fh5I2WYe+QhN3MtiVJ2vemqFF4iAM1vfbikhhjc+vkr2zA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/binary-extensions": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||
@@ -1694,6 +1704,23 @@
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/htmx.org": {
|
||||
"version": "2.0.10",
|
||||
"resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.10.tgz",
|
||||
"integrity": "sha512-kdeJe7ZVwaS6QMz/ebBIVtZdpwen6L0OQ5GOhPV9MKBb196TCZeZu4yA7ZIQsaLKv7EpXz+So7KSXNuHXhj7Cw==",
|
||||
"dev": true,
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/hyperscript.org": {
|
||||
"version": "0.9.93",
|
||||
"resolved": "https://registry.npmjs.org/hyperscript.org/-/hyperscript.org-0.9.93.tgz",
|
||||
"integrity": "sha512-9Lr9SisBgUW5AjeTekwXErZJLlnFFfq8BiXDzaMGpmX8MA64XdSM3MFYDaWsSFj/ALxeWwtteVgWobKn8DYIkA==",
|
||||
"dev": true,
|
||||
"license": "BSD 2-Clause",
|
||||
"bin": {
|
||||
"hyperscript.org": "dist/platform/node-hyperscript.js"
|
||||
}
|
||||
},
|
||||
"node_modules/ignore-by-default": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz",
|
||||
|
||||
+4
-1
@@ -13,17 +13,20 @@
|
||||
"@codemirror/lang-javascript": "^6.2.5",
|
||||
"@codemirror/language": "^6.12.4",
|
||||
"@codemirror/language-data": "^6.5.2",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"@codemirror/state": "^6.7.1",
|
||||
"@codemirror/text": "^0.19.6",
|
||||
"@codemirror/view": "^6.43.6",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"@tailwindcss/forms": "^0.5.11",
|
||||
"@tailwindcss/typography": "^0.5.20",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"basecoat-css": "^0.3.11",
|
||||
"codemirror": "^6.0.2",
|
||||
"dompurify": "^3.4.12",
|
||||
"github-markdown-css": "^5.9.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"htmx.org": "^2.0.10",
|
||||
"hyperscript.org": "^0.9.93",
|
||||
"jdenticon": "^3.3.0",
|
||||
"katex": "^0.18.1",
|
||||
"marked": "^18.0.6",
|
||||
|
||||
Vendored
+17
@@ -0,0 +1,17 @@
|
||||
@import "katex/dist/katex.min.css";
|
||||
|
||||
.jupyter.notebook {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.jupyter.notebook pre {
|
||||
font-size: 0.8em !important;
|
||||
}
|
||||
|
||||
.jupyter.notebook .jupyter-cell {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.jupyter.notebook .jupyter-cell.code-cell {
|
||||
filter: drop-shadow(0 0 0.1rem rgba(0, 0, 0, 0.5));
|
||||
}
|
||||
Vendored
+24
-4
@@ -1,9 +1,29 @@
|
||||
/* Self-contained stylesheet for the embeddable gist widget
|
||||
(templates/partials/gist_embed.html). It is loaded inside the
|
||||
<opengist-embed> web component's shadow DOM and deliberately does NOT depend
|
||||
on the app's new token system — it ships its own scoped Tailwind + palette so
|
||||
the embed renders identically wherever it is included. */
|
||||
@plugin "@tailwindcss/typography";
|
||||
@plugin "@tailwindcss/forms";
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
/* Dark styling fires on EITHER:
|
||||
- the .dark class (explicit ?dark, or the auto-mode JS toggle), OR
|
||||
- the OS preference, but only when .theme-auto is present (auto mode) so an
|
||||
explicit ?light embed on a dark-OS page stays light.
|
||||
The media path means the card background follows the OS even if the JS
|
||||
class-toggle never runs — matching how the syntax highlighting already works. */
|
||||
@custom-variant dark {
|
||||
&:where(.dark, .dark *) {
|
||||
@slot;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
&:where(.theme-auto, .theme-auto *) {
|
||||
@slot;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@source "../../templates/pages/gist_embed.html";
|
||||
@source "../../templates/partials/gist_embed.html";
|
||||
|
||||
@theme {
|
||||
/* Custom gray palette */
|
||||
@@ -55,7 +75,7 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@import './ipynb.css';
|
||||
@import "./style.css";
|
||||
@import './embed-ipynb.css';
|
||||
@import "./embed-style.css";
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+402
@@ -0,0 +1,402 @@
|
||||
/* Expose custom surfaces as Tailwind colors so their utilities exist. */
|
||||
@theme inline {
|
||||
--color-content: var(--content);
|
||||
--color-sidebar-hover: var(--sidebar-hover);
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.141 0.005 285.823);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.141 0.005 285.823);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.141 0.005 285.823);
|
||||
--theme-color: var(--color-indigo-500);
|
||||
/* Primary is a tuned derivation of the accent (darker + less saturated than
|
||||
the raw Tailwind -500). The factors are variables so the theme-picker
|
||||
swatches can preview the exact button color with the same formula and
|
||||
adapt per light/dark. */
|
||||
--primary-l: 0.82;
|
||||
--primary-c: 0.5;
|
||||
--primary: oklch(from var(--theme-color) calc(l * var(--primary-l)) calc(c * var(--primary-c)) h);
|
||||
--primary-foreground: oklch(0.969 0.016 293.756);
|
||||
--secondary: oklch(0.967 0.001 286.375);
|
||||
--secondary-foreground: oklch(0.21 0.006 285.885);
|
||||
--muted: oklch(0.967 0.001 286.375);
|
||||
--muted-foreground: oklch(0.552 0.016 285.938);
|
||||
--accent: oklch(0.967 0.001 286.375);
|
||||
--accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.92 0.004 286.32);
|
||||
--input: oklch(0.92 0.004 286.32);
|
||||
--ring: oklch(0.705 0.015 286.067);
|
||||
--chart-1: oklch(0.871 0.006 286.286);
|
||||
--chart-2: oklch(0.552 0.016 285.938);
|
||||
--chart-3: oklch(0.442 0.017 285.786);
|
||||
--chart-4: oklch(0.37 0.013 285.805);
|
||||
--chart-5: oklch(0.274 0.006 286.033);
|
||||
--radius: 0.45rem;
|
||||
/* Tints of the seed: keep its hue, scale chroma down, pin lightness.
|
||||
<main> is a faint tint; the sidebar is a touch deeper. */
|
||||
--content: oklch(from var(--theme-color) 0.985 calc(c * 0.04) h);
|
||||
--sidebar: oklch(from var(--theme-color) 0.97 calc(c * 0.10) h);
|
||||
--sidebar-foreground: oklch(0.141 0.005 285.823);
|
||||
--sidebar-primary-foreground: oklch(0.969 0.016 293.756);
|
||||
/* Active/current item: same hue/chroma as the sidebar, a touch darker. */
|
||||
--sidebar-accent: oklch(from var(--theme-color) 0.93 calc(c * 0.18) h);
|
||||
/* Hover: same hue but mostly desaturated (grayer). */
|
||||
--sidebar-hover: oklch(from var(--theme-color) 0.94 calc(c * 0.04) h);
|
||||
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--sidebar-border: oklch(0.92 0.004 286.32);
|
||||
--sidebar-ring: oklch(0.705 0.015 286.067);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.141 0.005 285.823);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.21 0.006 285.885);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.21 0.006 285.885);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--theme-color: var(--color-indigo-500);
|
||||
--primary-l: 0.62;
|
||||
--primary-c: 0.5;
|
||||
--primary: oklch(from var(--theme-color) calc(l * var(--primary-l)) calc(c * var(--primary-c)) h);
|
||||
--primary-foreground: oklch(0.969 0.016 293.756);
|
||||
--secondary: oklch(0.274 0.006 286.033);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.274 0.006 286.033);
|
||||
--muted-foreground: oklch(0.705 0.015 286.067);
|
||||
--accent: oklch(0.274 0.006 286.033);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.552 0.016 285.938);
|
||||
--chart-1: oklch(0.871 0.006 286.286);
|
||||
--chart-2: oklch(0.552 0.016 285.938);
|
||||
--chart-3: oklch(0.442 0.017 285.786);
|
||||
--chart-4: oklch(0.37 0.013 285.805);
|
||||
--chart-5: oklch(0.274 0.006 286.033);
|
||||
--content: oklch(from var(--theme-color) 0.17 calc(c * 0.04) h);
|
||||
--sidebar: oklch(from var(--theme-color) 0.23 calc(c * 0.10) h);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.969 0.016 293.756);
|
||||
--sidebar-accent: oklch(from var(--theme-color) 0.30 calc(c * 0.18) h);
|
||||
--sidebar-hover: oklch(from var(--theme-color) 0.30 calc(c * 0.04) h);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.552 0.016 285.938);
|
||||
}
|
||||
|
||||
:root {
|
||||
--red-diff: rgba(239, 68, 68, 0.13);
|
||||
--green-diff: rgba(34, 197, 94, 0.14);
|
||||
--git-diff: rgba(143, 143, 143, 0.2);
|
||||
}
|
||||
|
||||
.dark .text-primary,
|
||||
.dark .hover\:text-primary:hover {
|
||||
color: oklch(from var(--theme-color) 0.8 calc(c * 0.5) h);
|
||||
}
|
||||
|
||||
/* Navbar user-account dropdown: a wider menu with roomier, clickable items.
|
||||
Scoped by #id so it outranks basecoat's .dropdown-menu [data-popover] rules
|
||||
(which otherwise pin the width to the trigger and use px-2 py-1.5 / cursor-default). */
|
||||
#user-menu {
|
||||
min-width: 13rem;
|
||||
}
|
||||
#user-menu [role='menuitem'] {
|
||||
@apply cursor-pointer px-3 py-2 text-sm;
|
||||
}
|
||||
|
||||
/* Gist visibility dropdown: its trigger is a narrow icon-only button, so
|
||||
basecoat's anchor-size() min-width leaves the menu too cramped. */
|
||||
#vis-menu {
|
||||
min-width: 12rem;
|
||||
}
|
||||
#vis-menu [role='menuitem'] {
|
||||
@apply cursor-pointer;
|
||||
}
|
||||
|
||||
/* Gist header "more actions" dropdown: menu items are links/submit buttons,
|
||||
so give them a pointer cursor instead of basecoat's default. */
|
||||
#more-menu [role='menuitem'] {
|
||||
@apply cursor-pointer;
|
||||
}
|
||||
|
||||
|
||||
.code {
|
||||
font-family: ui-monospace, Menlo, Consolas, "Liberation Mono", monospace;
|
||||
font-size: 0.8em;
|
||||
@apply px-1 py-2.5;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.code td {
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.code tbody {
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.code .line-num {
|
||||
width: 1%;
|
||||
white-space: nowrap;
|
||||
text-align: right;
|
||||
vertical-align: top;
|
||||
user-select: none;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.code .line-code {
|
||||
padding-left: 0.5rem;
|
||||
background: none !important;
|
||||
}
|
||||
|
||||
/* Line numbers in file views are clickable (permalink to the line). */
|
||||
.code .line-num[id] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Highlighted line (selected via its number / targeted by the URL hash). */
|
||||
.code .line-code.selected {
|
||||
background-color: rgb(255, 247, 190) !important;
|
||||
box-shadow: inset 4px 0 0 rgb(255, 213, 65) !important;
|
||||
}
|
||||
.dark .code .line-code.selected {
|
||||
background-color: rgb(54, 49, 32) !important;
|
||||
box-shadow: inset 4px 0 0 rgb(161, 128, 21) !important;
|
||||
}
|
||||
|
||||
/* Copy buttons swap the copy icon for a check while [data-copied] is set. */
|
||||
.copy-btn .icon-check {
|
||||
display: none;
|
||||
}
|
||||
.copy-btn[data-copied] .icon-copy {
|
||||
display: none;
|
||||
}
|
||||
.copy-btn[data-copied] .icon-check {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Diff tables: fixed-width line-number and +/- sign columns. */
|
||||
.code.diff td.line-num {
|
||||
width: 2.75rem;
|
||||
min-width: 2.75rem;
|
||||
}
|
||||
.code.diff td.diff-sign {
|
||||
width: 1.25rem;
|
||||
min-width: 1.25rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.green-diff {
|
||||
background-color: var(--green-diff);
|
||||
}
|
||||
.red-diff {
|
||||
background-color: var(--red-diff);
|
||||
}
|
||||
.gray-diff {
|
||||
background-color: var(--git-diff);
|
||||
}
|
||||
|
||||
/* basecoat only styles <input> by type; mirror it for <textarea> so they match. */
|
||||
textarea.input {
|
||||
@apply border-input dark:bg-input/30 placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 flex w-full min-w-0 rounded-md border bg-transparent px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50;
|
||||
}
|
||||
|
||||
/* github-markdown-css paints .markdown-body with its own canvas background
|
||||
(background-color: var(--bgColor-default)), and its stylesheet loads after
|
||||
this one, so a plain override loses the cascade. Force transparent so the
|
||||
rendered markdown blends into the surrounding card background with no flash. */
|
||||
.markdown-body {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
/* Toggled by the gist editor (editor.ts). */
|
||||
.hidden-important {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* PDF preview: pdf.ts embeds an <iframe> (via PDFObject) into .pdf-embed, which
|
||||
PDFObject also tags with .pdfobject-container. Give it a fixed height so the
|
||||
iframe has something to fill. */
|
||||
.pdf-embed,
|
||||
.pdfobject-container {
|
||||
height: 700px;
|
||||
}
|
||||
.pdf-embed iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* When a gist has a single file, there's nothing to delete: hide its delete
|
||||
button and round the filename input's right edge (it normally butts up against
|
||||
the button). Pure CSS so it never flashes on load. */
|
||||
#editors > .editor:only-child .delete-file {
|
||||
display: none;
|
||||
}
|
||||
#editors > .editor:only-child .form-filename {
|
||||
border-top-right-radius: var(--radius-md);
|
||||
border-bottom-right-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
/* CodeMirror editor (gist create/edit). Mirror the gist view's code styling and
|
||||
drive colors from theme variables so it adapts to dark mode automatically. */
|
||||
.editor .cm-editor {
|
||||
font-family: ui-monospace, Menlo, Consolas, "Liberation Mono", monospace;
|
||||
background: transparent;
|
||||
color: var(--foreground);
|
||||
/* Outer padding matches the gist viewer's .code (5px 10px). */
|
||||
padding: 5px 10px;
|
||||
/* Default height on load is 20rem; can be dragged down to 8rem or taller. */
|
||||
height: 20rem;
|
||||
min-height: 8rem;
|
||||
resize: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.editor .cm-editor.cm-focused {
|
||||
outline: none;
|
||||
}
|
||||
/* The extra .cm-editor in these selectors is deliberate: CodeMirror's base
|
||||
theme scopes its rules as `.cm-<genId> .cm-content` (same specificity as a
|
||||
plain `.editor .cm-content`), so we add `.cm-editor` to reliably outrank it. */
|
||||
.editor .cm-editor .cm-scroller {
|
||||
line-height: 1.45;
|
||||
overflow: auto;
|
||||
}
|
||||
/* Outer padding lives on .cm-editor; here we only zero CodeMirror's defaults and
|
||||
keep the 0.5rem gap after the line number (matches .line-code padding-left). */
|
||||
.editor .cm-editor .cm-content {
|
||||
padding: 0;
|
||||
caret-color: var(--foreground);
|
||||
}
|
||||
.editor .cm-editor .cm-line {
|
||||
padding-left: 0.5rem;
|
||||
padding-right: 0;
|
||||
}
|
||||
.editor .cm-editor .cm-cursor,
|
||||
.editor .cm-editor .cm-cursor-primary {
|
||||
border-left-color: var(--foreground);
|
||||
}
|
||||
.editor .cm-editor .cm-gutters {
|
||||
/* Opaque, matching the editor's card background, so horizontally-scrolled
|
||||
code doesn't bleed through behind the sticky line-number gutter. */
|
||||
background: var(--card);
|
||||
border-right: none;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
.editor .cm-editor .cm-lineNumbers .cm-gutterElement {
|
||||
@apply px-4;
|
||||
}
|
||||
.editor .cm-editor .cm-activeLine,
|
||||
.editor .cm-editor .cm-activeLineGutter {
|
||||
background: transparent;
|
||||
}
|
||||
.editor .cm-editor .cm-focused .cm-selectionBackground,
|
||||
.editor .cm-editor .cm-selectionBackground {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
/* CSV files rendered as tables. */
|
||||
table.csv-table {
|
||||
width: 100%;
|
||||
font-size: 0.8em;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
table.csv-table thead {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
table.csv-table th,
|
||||
table.csv-table td {
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
|
||||
table.csv-table thead th {
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
/* Color helpers for badges, banners and alerts. The `& > section` rule is so
|
||||
the color also wins inside a basecoat `.alert`, whose body lives in a
|
||||
<section> that basecoat forces to text-muted-foreground. */
|
||||
.og-orange {
|
||||
@apply bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300;
|
||||
& > section { @apply text-amber-800 dark:text-amber-300; }
|
||||
}
|
||||
|
||||
.og-green {
|
||||
@apply bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300;
|
||||
& > section { @apply text-emerald-800 dark:text-emerald-300; }
|
||||
}
|
||||
|
||||
.og-red {
|
||||
@apply bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300;
|
||||
& > section { @apply text-red-800 dark:text-red-300; }
|
||||
}
|
||||
|
||||
.og-blue {
|
||||
@apply bg-sky-100 text-sky-800 dark:bg-sky-900/40 dark:text-sky-300;
|
||||
& > section { @apply text-sky-800 dark:text-sky-300; }
|
||||
}
|
||||
|
||||
/* Success flash: neutral gray surface with a blue accent bar; text follows the
|
||||
theme (dark in light mode, light in dark mode). */
|
||||
.og-success {
|
||||
@apply bg-muted text-foreground border-l-4 border-l-sky-500;
|
||||
& > section { @apply text-foreground; }
|
||||
}
|
||||
|
||||
/* Top loading bar for boosted navigations. Hidden until JS adds .is-loading,
|
||||
which drives its width via the --progress custom property. */
|
||||
#page-progress {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 60;
|
||||
height: 3px;
|
||||
width: 100%;
|
||||
transform-origin: 0 50%;
|
||||
transform: scaleX(var(--progress, 0));
|
||||
background: var(--primary);
|
||||
box-shadow: 0 0 8px var(--primary);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: transform 200ms ease-out, opacity 200ms ease-out;
|
||||
}
|
||||
|
||||
#page-progress.is-loading {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Desktop sidebar collapse into an icon-only rail. Toggled by the
|
||||
`sidebar-collapsed` class on <html> (restored from localStorage in the head
|
||||
script + toggled in main.ts). Only applies at md+ where the sidebar is docked;
|
||||
the mobile drawer keeps its own behavior. The rail keeps icons visible so the
|
||||
collapse button at the bottom of the sidebar stays reachable to expand again. */
|
||||
@media (min-width: 768px) {
|
||||
/* Rail width (matches Tailwind's w-16 = 4rem). */
|
||||
html.sidebar-collapsed #sidebar {
|
||||
width: 4rem;
|
||||
}
|
||||
|
||||
html.sidebar-collapsed #content-wrapper {
|
||||
margin-left: 4rem;
|
||||
}
|
||||
|
||||
/* Hide text: nav labels, badges, section headings, logo name. Icons stay. */
|
||||
html.sidebar-collapsed #sidebar :is(span, p) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Center the remaining icons within the narrow rail. */
|
||||
html.sidebar-collapsed #sidebar :is(a, button) {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
Vendored
+9
@@ -15,3 +15,12 @@
|
||||
.jupyter.notebook .jupyter-cell.code-cell {
|
||||
filter: drop-shadow(0 0 0.1rem rgba(0, 0, 0, 0.5));
|
||||
}
|
||||
|
||||
/*
|
||||
* A hack to ensure that Jupyter output images are always rendered with a
|
||||
* neutral background color, even if the image itself does not have one, since
|
||||
* Jupyter usually outputs images with transparent or light backgrounds.
|
||||
*/
|
||||
.dark .jupyter-output img {
|
||||
background-color: #888;
|
||||
}
|
||||
|
||||
Vendored
+16
@@ -0,0 +1,16 @@
|
||||
@import "tailwindcss";
|
||||
@import "basecoat-css";
|
||||
|
||||
@import "globals.css";
|
||||
@import "ipynb.css";
|
||||
|
||||
@source "../../templates/**/*.html";
|
||||
|
||||
/* User-selectable accent colors (settings → style). Tailwind tree-shakes color
|
||||
variables, so --color-<name>-500 only exists for colors referenced by a class.
|
||||
The theme picker and --theme-color reference the whole -500 palette via var(),
|
||||
which the scanner can't see, so force-generate the bg-*-500 utilities: that
|
||||
emits the matching --color-<name>-500 variables we rely on.
|
||||
Keep in sync with validator.ThemeColors. */
|
||||
@source inline("bg-{red,orange,amber,yellow,lime,green,emerald,teal,cyan,sky,blue,indigo,violet,purple,fuchsia,pink,rose,slate,gray,zinc,neutral,stone}-500");
|
||||
|
||||
Vendored
-57
@@ -1,57 +0,0 @@
|
||||
@import "tailwindcss";
|
||||
@import './ipynb.css';
|
||||
|
||||
@plugin "@tailwindcss/typography";
|
||||
@plugin "@tailwindcss/forms";
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@source "../../templates/**/*.html";
|
||||
|
||||
@theme {
|
||||
/* Custom gray palette */
|
||||
--color-gray-50: #EEEFF1;
|
||||
--color-gray-100: #DEDFE3;
|
||||
--color-gray-200: #BABCC5;
|
||||
--color-gray-300: #999CA8;
|
||||
--color-gray-400: #75798A;
|
||||
--color-gray-500: #585B68;
|
||||
--color-gray-600: #464853;
|
||||
--color-gray-700: #363840;
|
||||
--color-gray-800: #232429;
|
||||
--color-gray-900: #131316;
|
||||
|
||||
/* Primary color palette */
|
||||
--color-primary-50: #d6e1ff;
|
||||
--color-primary-100: #d1dfff;
|
||||
--color-primary-200: #b9d2fe;
|
||||
--color-primary-300: #84b1fb;
|
||||
--color-primary-400: #74a4f6;
|
||||
--color-primary-500: #588fee;
|
||||
--color-primary-600: #3c79e2;
|
||||
--color-primary-700: #356fc0;
|
||||
--color-primary-800: #2b5da3;
|
||||
--color-primary-900: #1f4b8c;
|
||||
--color-primary-950: #192b57;
|
||||
|
||||
/* Border width extension */
|
||||
--border-width-1: 1px;
|
||||
}
|
||||
|
||||
|
||||
@layer base {
|
||||
ul, ol {
|
||||
list-style: revert;
|
||||
}
|
||||
|
||||
a {
|
||||
@apply text-primary-500;
|
||||
}
|
||||
|
||||
button:not(:disabled),
|
||||
[role="button"]:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@import "./style.css";
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
let elems = Array.from(document.getElementsByClassName("toggle-button"));
|
||||
for (let elem of elems) {
|
||||
elem.addEventListener('click', () => {
|
||||
registerDomSetting(elem as HTMLElement)
|
||||
})
|
||||
}
|
||||
|
||||
let copyInviteButtons = Array.from(document.getElementsByClassName("copy-invitation-link"));
|
||||
for (let button of copyInviteButtons) {
|
||||
button.addEventListener('click', () => {
|
||||
navigator.clipboard.writeText((button as HTMLElement).dataset.link).catch((err) => {
|
||||
console.error('Could not copy text: ', err);
|
||||
});
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
const setSetting = (key: string, value: string) => {
|
||||
// @ts-ignore
|
||||
const baseUrl = window.opengist_base_url || '';
|
||||
const data = new URLSearchParams();
|
||||
data.append('key', key);
|
||||
data.append('value', value);
|
||||
if (document.getElementsByName('_csrf').length !== 0) {
|
||||
data.append('_csrf', ((document.getElementsByName('_csrf')[0] as HTMLInputElement).value));
|
||||
}
|
||||
return fetch(`${baseUrl}/admin-panel/set-config`, {
|
||||
method: 'PUT',
|
||||
credentials: 'same-origin',
|
||||
body: data,
|
||||
});
|
||||
};
|
||||
|
||||
const registerDomSetting = (el: HTMLElement) => {
|
||||
// @ts-ignore
|
||||
el.dataset["bool"] = !(el.dataset["bool"] === 'true');
|
||||
setSetting(el.id, el.dataset["bool"] === 'true' ? '1' : '0')
|
||||
.then(() => {
|
||||
el.classList.toggle("bg-primary-600");
|
||||
el.classList.toggle("dark:bg-gray-400");
|
||||
el.classList.toggle("bg-gray-300");
|
||||
(el.childNodes.item(1) as HTMLElement).classList.toggle("translate-x-5");
|
||||
});
|
||||
};
|
||||
|
||||
+35
-55
@@ -5,14 +5,21 @@ import {HighlightStyle, LanguageDescription, syntaxHighlighting} from "@codemirr
|
||||
import {languages} from "@codemirror/language-data";
|
||||
import {tags} from "@lezer/highlight";
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
function initEditorPage() {
|
||||
let editorsParentdom = document.getElementById("editors");
|
||||
// Not on an editor page, or already initialized for this DOM.
|
||||
if (!editorsParentdom || editorsParentdom.dataset.editorReady) return;
|
||||
editorsParentdom.dataset.editorReady = "1";
|
||||
|
||||
// Clear any editors rendered into a restored hx-boost history snapshot so we
|
||||
// rebuild fresh CodeMirror instances instead of stacking on dead DOM.
|
||||
editorsParentdom.querySelectorAll(".cm-editor").forEach((el) => el.remove());
|
||||
|
||||
EditorView.theme({}, {dark: true});
|
||||
|
||||
let editorsjs: EditorView[] = [];
|
||||
let editorHighlightCompartments: {editor: EditorView, conf: Compartment}[] = [];
|
||||
let editorsParentdom = document.getElementById("editors")!;
|
||||
let allEditorsdom = document.querySelectorAll("#editors > .editor");
|
||||
let firstEditordom = allEditorsdom[0];
|
||||
let allEditorsdom = editorsParentdom.querySelectorAll(":scope > .editor");
|
||||
|
||||
const txtFacet = Facet.define<string>({
|
||||
combine(values) {
|
||||
@@ -101,6 +108,9 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
],
|
||||
});
|
||||
|
||||
// CodeMirror is mounted; drop the loading spinner.
|
||||
dom.querySelector(".editor-loading")?.remove();
|
||||
|
||||
let mdpreview = dom.querySelector(".md-preview") as HTMLElement;
|
||||
|
||||
let formfilename = dom.querySelector<HTMLInputElement>(".form-filename");
|
||||
@@ -184,13 +194,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
setLineWrapping(editor, newWrapMode === "soft");
|
||||
};
|
||||
|
||||
dom.addEventListener("drop", (e) => {
|
||||
e.preventDefault(); // prevent the browser from opening the dropped file
|
||||
(e.target as HTMLInputElement)
|
||||
.closest(".editor")
|
||||
.querySelector<HTMLInputElement>("input.form-filename")!.value =
|
||||
e.dataTransfer.files[0].name;
|
||||
});
|
||||
// drop-to-set-filename is handled by hyperscript on the .editor element.
|
||||
|
||||
// remove editor on delete
|
||||
let deleteBtns = dom.querySelector<HTMLButtonElement>("button.delete-file");
|
||||
@@ -205,7 +209,6 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
if (hIdx !== -1) editorHighlightCompartments.splice(hIdx, 1);
|
||||
}
|
||||
dom.remove();
|
||||
checkForFirstDeleteButton();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -295,14 +298,11 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
deleteBtn.onclick = () => {
|
||||
if (!confirm("Are you sure you want to delete this file?")) return;
|
||||
el.remove();
|
||||
checkForFirstDeleteButton();
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
checkForFirstDeleteButton();
|
||||
|
||||
// Update syntax highlight theme when dark/light mode changes
|
||||
new MutationObserver(() => {
|
||||
const hl = currentHighlight();
|
||||
@@ -318,7 +318,6 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
// creating the new codemirror editor and append it in the editor div
|
||||
editorsjs.push(newEditor(newEditorDom));
|
||||
editorsParentdom.append(newEditorDom);
|
||||
showDeleteButton(newEditorDom);
|
||||
};
|
||||
|
||||
document.querySelector<HTMLFormElement>("form#create")!.onsubmit = () => {
|
||||
@@ -375,45 +374,9 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
window.onbeforeunload = null;
|
||||
};
|
||||
|
||||
document.getElementById('gist-metadata-btn')!.onclick = (el) => {
|
||||
let metadata = document.getElementById('gist-metadata')!;
|
||||
metadata.classList.toggle('hidden');
|
||||
|
||||
let btn = el.target as HTMLButtonElement;
|
||||
if (btn.innerText.endsWith('▼')) {
|
||||
btn.innerText = btn.innerText.replace('▼', '▲');
|
||||
} else {
|
||||
btn.innerText = btn.innerText.replace('▲', '▼');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function checkForFirstDeleteButton() {
|
||||
// Count total files (both text and binary)
|
||||
const totalFiles = editorsParentdom.querySelectorAll('.editor').length;
|
||||
|
||||
// Hide/show all delete buttons based on total file count
|
||||
const deleteButtons = editorsParentdom.querySelectorAll<HTMLButtonElement>("button.delete-file");
|
||||
deleteButtons.forEach(deleteBtn => {
|
||||
if (totalFiles <= 1) {
|
||||
deleteBtn.classList.add("hidden");
|
||||
deleteBtn.previousElementSibling?.classList.remove("rounded-l-md");
|
||||
deleteBtn.previousElementSibling?.classList.add("rounded-md");
|
||||
} else {
|
||||
deleteBtn.classList.remove("hidden");
|
||||
deleteBtn.previousElementSibling?.classList.add("rounded-l-md");
|
||||
deleteBtn.previousElementSibling?.classList.remove("rounded-md");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function showDeleteButton(editorDom: HTMLElement) {
|
||||
let deleteBtn = editorDom.querySelector<HTMLButtonElement>("button.delete-file")!;
|
||||
deleteBtn.classList.remove("hidden");
|
||||
deleteBtn.previousElementSibling.classList.add("rounded-l-md");
|
||||
deleteBtn.previousElementSibling.classList.remove("rounded-md");
|
||||
checkForFirstDeleteButton();
|
||||
}
|
||||
// metadata toggle is handled by hyperscript on the button in create.html.
|
||||
// Single-file delete-button visibility / input rounding is handled in CSS
|
||||
// via #editors > .editor:only-child (no flash on load).
|
||||
|
||||
// File upload functionality
|
||||
let uploadedFileUUIDs: {uuid: string, filename: string}[] = [];
|
||||
@@ -557,4 +520,21 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
// The page can be reached either by a full load or via hx-boost, which swaps
|
||||
// the body content without firing DOMContentLoaded. Initialize for all paths,
|
||||
// matching how main.ts re-initializes its components.
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", initEditorPage);
|
||||
} else {
|
||||
initEditorPage();
|
||||
}
|
||||
document.body.addEventListener("htmx:afterSwap", initEditorPage);
|
||||
document.body.addEventListener("htmx:historyRestore", initEditorPage);
|
||||
|
||||
// hx-boost saves a DOM snapshot for back/forward navigation. Drop the ready
|
||||
// marker so the editor rebuilds on restore instead of looking dead.
|
||||
document.body.addEventListener("htmx:beforeHistorySave", () => {
|
||||
document.getElementById("editors")?.removeAttribute("data-editor-ready");
|
||||
});
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import "../css/embed.css"
|
||||
import '../css/embed.css';
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// Interactive markdown task-list checkboxes.
|
||||
//
|
||||
// The markdown renderer tags each task-list <li> with data-checkbox-nb="<n>"
|
||||
// (see internal/render/markdown_checkbox.go). When the gist is owned by the
|
||||
// logged-in user and not archived (#gist[data-own]), clicking a checkbox
|
||||
// persists the toggle by PUTting to <gist-url>/checkbox, which rewrites and
|
||||
// commits the file. Otherwise the checkboxes stay disabled and read-only.
|
||||
|
||||
function csrfToken(): string {
|
||||
const input = document.querySelector<HTMLInputElement>('input[name="_csrf"]');
|
||||
return input ? input.value : '';
|
||||
}
|
||||
|
||||
function bindFile(article: HTMLElement) {
|
||||
const filename = article.dataset.file;
|
||||
if (!filename) return;
|
||||
|
||||
const items = article.querySelectorAll<HTMLElement>('li[data-checkbox-nb]');
|
||||
items.forEach((item) => {
|
||||
const input = item.querySelector<HTMLInputElement>('input[type=checkbox]');
|
||||
if (!input || input.dataset.checkboxBound) return;
|
||||
input.dataset.checkboxBound = 'true';
|
||||
input.disabled = false;
|
||||
|
||||
input.addEventListener('change', () => {
|
||||
const checkboxNb = item.dataset.checkboxNb;
|
||||
if (checkboxNb === undefined) return;
|
||||
|
||||
// Disable every checkbox on the page while the write is in flight so
|
||||
// concurrent toggles can't race the file rewrite on the server.
|
||||
const all = document.querySelectorAll<HTMLInputElement>('li[data-checkbox-nb] input[type=checkbox]');
|
||||
all.forEach((el) => (el.disabled = true));
|
||||
|
||||
const data = new URLSearchParams();
|
||||
data.append('checkbox', checkboxNb);
|
||||
data.append('file', filename);
|
||||
const csrf = csrfToken();
|
||||
if (csrf) data.append('_csrf', csrf);
|
||||
|
||||
fetch(location.href.split('#')[0].split('?')[0] + '/checkbox', {
|
||||
method: 'PUT',
|
||||
credentials: 'same-origin',
|
||||
body: data,
|
||||
})
|
||||
.then((response) => {
|
||||
if (!response.ok) input.checked = !input.checked; // revert on failure
|
||||
})
|
||||
.catch(() => {
|
||||
input.checked = !input.checked;
|
||||
})
|
||||
.finally(() => {
|
||||
all.forEach((el) => (el.disabled = false));
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function initGistCheckboxes() {
|
||||
const gist = document.getElementById('gist');
|
||||
const editable = !!gist && gist.dataset.own === 'true';
|
||||
|
||||
document.querySelectorAll<HTMLElement>('article[data-file]').forEach((article) => {
|
||||
if (editable) {
|
||||
bindFile(article);
|
||||
} else {
|
||||
article
|
||||
.querySelectorAll<HTMLInputElement>('li[data-checkbox-nb] input[type=checkbox]')
|
||||
.forEach((input) => (input.disabled = true));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
// Faceted filter input for gist lists.
|
||||
//
|
||||
// A single box drives several backend query params. Free text is the title;
|
||||
// qualifiers (`visibility:`, `language:`, `topic:`) chosen from the dropdown
|
||||
// become code-styled chips. The chips + title are mirrored into the hidden
|
||||
// title/visibility/language/topics inputs the server reads.
|
||||
|
||||
type Option = { value: string; label: string };
|
||||
|
||||
const KEYS = ['visibility', 'language', 'topic'] as const;
|
||||
type Key = (typeof KEYS)[number];
|
||||
|
||||
interface Suggestion {
|
||||
label: string;
|
||||
qualifier?: Key; // completes "key:" in the input
|
||||
commit?: { key: Key; value: string }; // turns into a chip
|
||||
}
|
||||
|
||||
function readOptions(form: HTMLElement, facet: string): Option[] {
|
||||
const tpl = form.querySelector<HTMLTemplateElement>(`template[data-filter-options="${facet}"]`);
|
||||
if (!tpl) return [];
|
||||
return Array.from(tpl.content.querySelectorAll<HTMLElement>('[data-value]')).map((el) => ({
|
||||
value: el.dataset.value || '',
|
||||
label: (el.textContent || '').trim(),
|
||||
}));
|
||||
}
|
||||
|
||||
function setup(form: HTMLFormElement) {
|
||||
const box = form.querySelector<HTMLElement>('[data-filter-box]');
|
||||
const input = form.querySelector<HTMLInputElement>('[data-filter-input]');
|
||||
const menu = form.querySelector<HTMLElement>('[data-filter-menu]');
|
||||
if (!box || !input || !menu) return;
|
||||
|
||||
const hidden = {
|
||||
title: form.querySelector<HTMLInputElement>('[data-filter-hidden="title"]'),
|
||||
visibility: form.querySelector<HTMLInputElement>('[data-filter-hidden="visibility"]'),
|
||||
language: form.querySelector<HTMLInputElement>('[data-filter-hidden="language"]'),
|
||||
topics: form.querySelector<HTMLInputElement>('[data-filter-hidden="topics"]'),
|
||||
};
|
||||
|
||||
const options: Record<Key, Option[]> = {
|
||||
visibility: readOptions(form, 'visibility'),
|
||||
language: readOptions(form, 'language'),
|
||||
topic: [],
|
||||
};
|
||||
|
||||
// Chosen qualifiers. visibility/language are single; topic can repeat.
|
||||
let tokens: { key: Key; value: string }[] = [];
|
||||
if (form.dataset.initVisibility) tokens.push({ key: 'visibility', value: form.dataset.initVisibility });
|
||||
if (form.dataset.initLanguage) tokens.push({ key: 'language', value: form.dataset.initLanguage });
|
||||
(form.dataset.initTopics || '')
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.forEach((v) => tokens.push({ key: 'topic', value: v }));
|
||||
input.value = form.dataset.initTitle || '';
|
||||
|
||||
let suggestions: Suggestion[] = [];
|
||||
let active = -1;
|
||||
|
||||
const lastToken = () => {
|
||||
const v = input.value;
|
||||
const sp = v.lastIndexOf(' ');
|
||||
return { token: v.slice(sp + 1), start: sp + 1 };
|
||||
};
|
||||
|
||||
function commit(key: Key, value: string) {
|
||||
if (!value) return;
|
||||
if (key === 'topic') {
|
||||
if (!tokens.some((t) => t.key === 'topic' && t.value === value)) tokens.push({ key, value });
|
||||
} else {
|
||||
tokens = tokens.filter((t) => t.key !== key);
|
||||
tokens.push({ key, value });
|
||||
}
|
||||
renderChips();
|
||||
}
|
||||
|
||||
function renderChips() {
|
||||
box.querySelectorAll('[data-chip]').forEach((n) => n.remove());
|
||||
for (const t of tokens) {
|
||||
const chip = document.createElement('span');
|
||||
chip.dataset.chip = '';
|
||||
chip.className =
|
||||
'bg-muted text-foreground inline-flex items-center gap-1 rounded border px-1.5 py-0.5 font-mono text-xs';
|
||||
const label = document.createElement('span');
|
||||
label.textContent = `${t.key}:${t.value}`;
|
||||
const rm = document.createElement('button');
|
||||
rm.type = 'button';
|
||||
rm.textContent = '×';
|
||||
rm.className = 'text-muted-foreground hover:text-foreground -mr-0.5 leading-none';
|
||||
rm.addEventListener('click', () => {
|
||||
tokens = tokens.filter((x) => x !== t);
|
||||
renderChips();
|
||||
syncHidden();
|
||||
input.focus();
|
||||
});
|
||||
chip.append(label, rm);
|
||||
box.insertBefore(chip, input);
|
||||
}
|
||||
}
|
||||
|
||||
function build(): Suggestion[] {
|
||||
const { token } = lastToken();
|
||||
const colon = token.indexOf(':');
|
||||
if (colon === -1) {
|
||||
return KEYS.filter((k) => k.startsWith(token.toLowerCase())).map((k) => ({
|
||||
label: `${k}:`,
|
||||
qualifier: k,
|
||||
}));
|
||||
}
|
||||
const key = token.slice(0, colon).toLowerCase() as Key;
|
||||
const prefix = token.slice(colon + 1).toLowerCase();
|
||||
if (key === 'topic') {
|
||||
return prefix ? [{ label: `topic: ${prefix}`, commit: { key: 'topic', value: token.slice(colon + 1) } }] : [];
|
||||
}
|
||||
if (key === 'visibility' || key === 'language') {
|
||||
return options[key]
|
||||
.filter((o) => o.value.toLowerCase().includes(prefix))
|
||||
.slice(0, 30)
|
||||
.map((o) => ({ label: o.label, commit: { key, value: o.value } }));
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function render() {
|
||||
suggestions = build();
|
||||
active = -1;
|
||||
if (suggestions.length === 0) {
|
||||
menu.classList.add('hidden');
|
||||
menu.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
menu.innerHTML = '';
|
||||
suggestions.forEach((s, i) => {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.dataset.index = String(i);
|
||||
btn.textContent = s.label;
|
||||
btn.className =
|
||||
'flex w-full items-center rounded-sm px-2 py-1.5 text-left text-sm hover:bg-accent hover:text-accent-foreground aria-selected:bg-accent aria-selected:text-accent-foreground';
|
||||
menu.appendChild(btn);
|
||||
});
|
||||
menu.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function apply(s: Suggestion) {
|
||||
const { start } = lastToken();
|
||||
if (s.commit) {
|
||||
commit(s.commit.key, s.commit.value);
|
||||
input.value = input.value.slice(0, start); // drop the typed token; it's a chip now
|
||||
} else if (s.qualifier) {
|
||||
input.value = input.value.slice(0, start) + `${s.qualifier}:`;
|
||||
}
|
||||
input.focus();
|
||||
syncHidden();
|
||||
render();
|
||||
}
|
||||
|
||||
function highlight(next: number) {
|
||||
const btns = Array.from(menu.querySelectorAll<HTMLButtonElement>('button'));
|
||||
if (btns.length === 0) return;
|
||||
active = (next + btns.length) % btns.length;
|
||||
btns.forEach((b, i) => b.setAttribute('aria-selected', i === active ? 'true' : 'false'));
|
||||
btns[active]?.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
|
||||
// Commit a fully-typed "key:value " token (space-terminated) into a chip.
|
||||
function commitOnSpace() {
|
||||
if (!input.value.endsWith(' ')) return;
|
||||
const trimmed = input.value.slice(0, -1);
|
||||
const sp = trimmed.lastIndexOf(' ');
|
||||
const last = trimmed.slice(sp + 1);
|
||||
const m = last.match(/^(visibility|language|topic):(.+)$/i);
|
||||
if (m) {
|
||||
commit(m[1].toLowerCase() as Key, m[2]);
|
||||
input.value = trimmed.slice(0, sp + 1);
|
||||
}
|
||||
}
|
||||
|
||||
function syncHidden() {
|
||||
if (hidden.visibility) hidden.visibility.value = tokens.find((t) => t.key === 'visibility')?.value || '';
|
||||
if (hidden.language) hidden.language.value = tokens.find((t) => t.key === 'language')?.value || '';
|
||||
if (hidden.topics)
|
||||
hidden.topics.value = tokens
|
||||
.filter((t) => t.key === 'topic')
|
||||
.map((t) => t.value)
|
||||
.join(' ');
|
||||
// Title is the leftover free text, minus any half-typed qualifier token.
|
||||
if (hidden.title)
|
||||
hidden.title.value = input.value
|
||||
.split(/\s+/)
|
||||
.filter((w) => w && !/^(visibility|language|topic):/i.test(w))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
input.addEventListener('focus', render);
|
||||
input.addEventListener('input', () => {
|
||||
commitOnSpace();
|
||||
syncHidden();
|
||||
render();
|
||||
});
|
||||
|
||||
input.addEventListener('keydown', (e) => {
|
||||
const open = !menu.classList.contains('hidden');
|
||||
if (e.key === 'ArrowDown') {
|
||||
if (!open) render();
|
||||
highlight(active + 1);
|
||||
e.preventDefault();
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
if (open) {
|
||||
highlight(active - 1);
|
||||
e.preventDefault();
|
||||
}
|
||||
} else if (e.key === 'Enter') {
|
||||
if (open && active >= 0 && suggestions[active]) {
|
||||
apply(suggestions[active]);
|
||||
e.preventDefault();
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
menu.classList.add('hidden');
|
||||
} else if (e.key === 'Backspace' && input.value === '' && tokens.length > 0) {
|
||||
tokens.pop();
|
||||
renderChips();
|
||||
syncHidden();
|
||||
}
|
||||
});
|
||||
|
||||
menu.addEventListener('mousedown', (e) => {
|
||||
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>('button[data-index]');
|
||||
if (!btn) return;
|
||||
e.preventDefault(); // keep input focused
|
||||
const s = suggestions[Number(btn.dataset.index)];
|
||||
if (s) apply(s);
|
||||
});
|
||||
|
||||
// Clicking anywhere in the box focuses the input.
|
||||
box.addEventListener('mousedown', (e) => {
|
||||
if (e.target === box) {
|
||||
input.focus();
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!form.contains(e.target as Node)) menu.classList.add('hidden');
|
||||
});
|
||||
|
||||
form.addEventListener('submit', syncHidden);
|
||||
|
||||
renderChips();
|
||||
syncHidden();
|
||||
}
|
||||
|
||||
export function initGistFilters() {
|
||||
document
|
||||
.querySelectorAll<HTMLFormElement>('form[data-gist-filter]:not([data-filter-ready])')
|
||||
.forEach((form) => {
|
||||
form.setAttribute('data-filter-ready', '1');
|
||||
setup(form);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// Line selection + permalinks for gist file views.
|
||||
//
|
||||
// Each line-number cell has id="file-<slug>-<n>". Clicking a number highlights
|
||||
// that line and writes the hash #file-<slug>-<n> to the URL. Loading or
|
||||
// navigating to such a hash scrolls to and highlights the line.
|
||||
|
||||
function clearSelection() {
|
||||
document.querySelectorAll('.table-code .selected').forEach((el) => el.classList.remove('selected'));
|
||||
}
|
||||
|
||||
function select(numCell: HTMLElement) {
|
||||
clearSelection();
|
||||
const code = numCell.nextElementSibling;
|
||||
if (code) code.classList.add('selected');
|
||||
}
|
||||
|
||||
function highlightFromHash() {
|
||||
if (!location.hash.startsWith('#file-')) return;
|
||||
const numCell = document.getElementById(location.hash.slice(1));
|
||||
if (!numCell || !numCell.classList.contains('line-num')) return;
|
||||
select(numCell);
|
||||
numCell.scrollIntoView({ block: 'center' });
|
||||
}
|
||||
|
||||
export function initGistLines() {
|
||||
highlightFromHash();
|
||||
|
||||
const w = window as unknown as { __gistLinesBound?: boolean };
|
||||
if (w.__gistLinesBound) return; // delegated handlers are global; bind once
|
||||
w.__gistLinesBound = true;
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
const numCell = (e.target as HTMLElement).closest<HTMLElement>('.table-code td.line-num[id]');
|
||||
if (!numCell) return;
|
||||
select(numCell);
|
||||
history.replaceState(null, '', location.pathname + location.search + '#' + numCell.id);
|
||||
});
|
||||
|
||||
window.addEventListener('hashchange', highlightFromHash);
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
import '../ts/ipynb.ts';
|
||||
|
||||
document.querySelectorAll<HTMLElement>('.table-code').forEach((el) => {
|
||||
el.addEventListener('click', event => {
|
||||
if (event.target && (event.target as HTMLElement).matches('.line-num')) {
|
||||
Array.from(document.querySelectorAll('.table-code .selected')).forEach((el) => el.classList.remove('selected'));
|
||||
|
||||
const nextSibling = (event.target as HTMLElement).nextSibling;
|
||||
if (nextSibling instanceof HTMLElement) {
|
||||
nextSibling.classList.add('selected');
|
||||
}
|
||||
|
||||
const filename = el.dataset.filenameSlug;
|
||||
const line = (event.target as HTMLElement).textContent;
|
||||
const url = location.protocol + '//' + location.host + location.pathname;
|
||||
const hash = '#file-' + filename + '-' + line;
|
||||
window.history.pushState(null, null, url + hash);
|
||||
location.hash = hash;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
let copybtnhtml = `<button type="button" style="top: 1em !important; right: 1em !important;" class="md-code-copy-btn absolute focus-within:z-auto rounded-md dark:border-gray-600 px-2 py-2 opacity-80 font-medium text-slate-700 bg-gray-100 dark:bg-gray-700 dark:text-slate-300 hover:bg-gray-200 dark:hover:bg-gray-600 hover:border-gray-500 hover:text-slate-700 dark:hover:text-slate-300 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"><svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5"><path stroke-linecap="round" stroke-linejoin="round" d="M8.25 7.5V6.108c0-1.135.845-2.098 1.976-2.192.373-.03.748-.057 1.123-.08M15.75 18H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08M15.75 18.75v-1.875a3.375 3.375 0 00-3.375-3.375h-1.5a1.125 1.125 0 01-1.125-1.125v-1.5A3.375 3.375 0 006.375 7.5H5.25m11.9-3.664A2.251 2.251 0 0015 2.25h-1.5a2.251 2.251 0 00-2.15 1.586m5.8 0c.065.21.1.433.1.664v.75h-6V4.5c0-.231.035-.454.1-.664M6.75 7.5H4.875c-.621 0-1.125.504-1.125 1.125v12c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V16.5a9 9 0 00-9-9z" /></svg></button>`;
|
||||
|
||||
document.querySelectorAll<HTMLElement>('.markdown-body pre').forEach((el) => {
|
||||
if (el.classList.contains("mermaid")) {
|
||||
return;
|
||||
}
|
||||
el.innerHTML = copybtnhtml + `<span class="code-div">` + el.innerHTML + `</span>`;
|
||||
});
|
||||
|
||||
document.querySelectorAll('.md-code-copy-btn').forEach(button => {
|
||||
button.addEventListener('click', function() {
|
||||
let code = this.nextElementSibling.textContent;
|
||||
navigator.clipboard.writeText(code).catch((err) => {
|
||||
console.error('Could not copy text: ', err);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
let checkboxes = document.querySelectorAll('li[data-checkbox-nb] input[type=checkbox]');
|
||||
if (document.getElementById('gist').dataset.own) {
|
||||
document.querySelectorAll<HTMLElement>('li[data-checkbox-nb]').forEach((el) => {
|
||||
let input: HTMLButtonElement = el.querySelector('input[type=checkbox]');
|
||||
input.disabled = false;
|
||||
let checkboxNb = (el as HTMLElement).dataset.checkboxNb;
|
||||
let filename = input.closest<HTMLElement>('div[data-file]').dataset.file;
|
||||
|
||||
input.addEventListener('change', function () {
|
||||
const data = new URLSearchParams();
|
||||
data.append('checkbox', checkboxNb);
|
||||
data.append('file', filename);
|
||||
if (document.getElementsByName('_csrf').length !== 0) {
|
||||
data.append('_csrf', ((document.getElementsByName('_csrf')[0] as HTMLInputElement).value));
|
||||
}
|
||||
checkboxes.forEach((el: HTMLButtonElement) => {
|
||||
el.disabled = true;
|
||||
el.classList.add('text-gray-400')
|
||||
});
|
||||
fetch(window.location.href.split('#')[0] + '/checkbox', {
|
||||
method: 'PUT',
|
||||
credentials: 'same-origin',
|
||||
body: data,
|
||||
}).then((response) => {
|
||||
if (response.status === 200) {
|
||||
checkboxes.forEach((el: HTMLButtonElement) => {
|
||||
el.disabled = false;
|
||||
el.classList.remove('text-gray-400')
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
} else {
|
||||
checkboxes.forEach((el: HTMLButtonElement) => {
|
||||
el.disabled = true;
|
||||
});
|
||||
}
|
||||
+16
-2
@@ -131,7 +131,21 @@ class IPynb {
|
||||
}
|
||||
}
|
||||
|
||||
// Process Jupyter notebooks
|
||||
// Render every notebook on the page. Rendering replaces the source <pre> with
|
||||
// the rendered cells, so this is idempotent: once a notebook is mounted there is
|
||||
// no `.jupyter.notebook pre` left to match. That makes it safe to call again
|
||||
// after each hx-boost swap without double-processing already-rendered content.
|
||||
export function initIpynb() {
|
||||
document.querySelectorAll<HTMLElement>('.jupyter.notebook pre').forEach((el) => {
|
||||
new IPynb(el).mount();
|
||||
// The template ships a spinner (see gist.html) that shows the moment the
|
||||
// notebook is swapped in — including during an hx-boost navigation. Parsing
|
||||
// and highlighting a large notebook is synchronous and can block the main
|
||||
// thread, so defer past two frames: the browser paints the spinner first,
|
||||
// then we do the heavy work and swap in the rendered cells.
|
||||
requestAnimationFrame(() =>
|
||||
requestAnimationFrame(() => {
|
||||
if (el.isConnected) new IPynb(el).mount();
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import jdenticon from 'jdenticon/standalone';
|
||||
|
||||
// Render identicons for users without a real avatar. The server emits an empty
|
||||
// <svg data-jdenticon-value="username"> and jdenticon fills it in on the client.
|
||||
//
|
||||
// update() reads the value attribute and (re)writes the SVG's children, so it is
|
||||
// idempotent — safe to call again after each hx-boost swap without doubling up.
|
||||
export function initJdenticon() {
|
||||
jdenticon.update('[data-jdenticon-value]');
|
||||
}
|
||||
+145
-188
@@ -1,204 +1,161 @@
|
||||
import '../css/tailwind.css';
|
||||
import '../css/main.css';
|
||||
import '../img/favicon-32.png';
|
||||
import '../img/opengist.svg';
|
||||
import jdenticon from 'jdenticon/standalone';
|
||||
import PDFObject from 'pdfobject';
|
||||
|
||||
jdenticon.update("[data-jdenticon-value]")
|
||||
import 'htmx.org';
|
||||
import 'hyperscript.org';
|
||||
import 'basecoat-css/basecoat';
|
||||
import 'basecoat-css/dropdown-menu';
|
||||
import { initGistFilters } from './gist-filter';
|
||||
import { initGistLines } from './gist-lines';
|
||||
import { initGistCheckboxes } from './gist-checkbox';
|
||||
import { initJdenticon } from './jdenticon';
|
||||
import { initPdf } from './pdf';
|
||||
|
||||
document.querySelectorAll(".pdf").forEach((el) => {
|
||||
PDFObject.embed(el.dataset.src || "", el);
|
||||
})
|
||||
let ipynbModule: Promise<typeof import('./ipynb')> | undefined;
|
||||
|
||||
if (document.querySelector('.mermaid') && document.documentElement.classList.contains('dark')) {
|
||||
(window as any).mermaid?.initialize({ theme: 'dark', startOnLoad: true });
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.getElementById('user-btn')?.addEventListener("click" , () => {
|
||||
document.getElementById('user-menu')!.classList.toggle('hidden');
|
||||
})
|
||||
|
||||
document.querySelectorAll('form').forEach((form: HTMLFormElement) => {
|
||||
form.onsubmit = () => {
|
||||
form.querySelectorAll('input[type=datetime-local]').forEach((input: HTMLInputElement) => {
|
||||
const hiddenInput = document.createElement('input');
|
||||
hiddenInput.type = 'hidden';
|
||||
hiddenInput.name = 'expiredAtUnix'
|
||||
hiddenInput.value = Math.floor(new Date(input.value).getTime() / 1000).toString();
|
||||
form.appendChild(hiddenInput);
|
||||
});
|
||||
return true;
|
||||
};
|
||||
})
|
||||
|
||||
|
||||
|
||||
const rev = document.querySelector<HTMLElement>('.revision-text');
|
||||
if (rev) {
|
||||
const fullRev = rev.innerHTML;
|
||||
const smallRev = fullRev.substring(0, 7);
|
||||
rev.innerHTML = smallRev;
|
||||
|
||||
rev.onmouseover = () => {
|
||||
rev.innerHTML = fullRev;
|
||||
};
|
||||
rev.onmouseout = () => {
|
||||
rev.innerHTML = smallRev;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
const colorhash = () => {
|
||||
Array.from(document.querySelectorAll('.table-code .selected')).forEach((el) => el.classList.remove('selected'));
|
||||
const lineEl = document.querySelector<HTMLElement>(location.hash);
|
||||
if (lineEl) {
|
||||
const nextSibling = lineEl.nextSibling;
|
||||
if (nextSibling instanceof HTMLElement) {
|
||||
nextSibling.classList.add('selected');
|
||||
}
|
||||
}
|
||||
const initIpynb = () => {
|
||||
if (!document.querySelector('.jupyter.notebook pre')) return;
|
||||
ipynbModule ??= import('./ipynb');
|
||||
void ipynbModule.then(({ initIpynb: renderNotebooks }) => renderNotebooks());
|
||||
};
|
||||
|
||||
if (location.hash) {
|
||||
colorhash();
|
||||
}
|
||||
window.onhashchange = colorhash;
|
||||
|
||||
document.getElementById('main-menu-button')!.onclick = () => {
|
||||
document.getElementById('mobile-menu')!.classList.toggle('hidden');
|
||||
const init = () => {
|
||||
initGistFilters();
|
||||
initGistLines();
|
||||
initGistCheckboxes();
|
||||
initIpynb();
|
||||
initPdf();
|
||||
initJdenticon();
|
||||
};
|
||||
|
||||
const tabs = document.getElementById('gist-tabs');
|
||||
if (tabs) {
|
||||
tabs.onchange = (e: Event) => {
|
||||
const target = e.target as HTMLSelectElement;
|
||||
window.location.href = target.selectedOptions[0].dataset.url || '';
|
||||
};
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
// Re-init after hx-boost / htmx content swaps.
|
||||
document.body.addEventListener('htmx:afterSwap', init);
|
||||
|
||||
const gistmenutoggle = document.getElementById('gist-menu-toggle');
|
||||
if (gistmenutoggle) {
|
||||
const gistmenucopy = document.getElementById('gist-menu-copy')!;
|
||||
const gistmenubuttoncopy = document.getElementById('gist-menu-button-copy')!;
|
||||
const gistmenuinput = document.getElementById('gist-menu-input') as HTMLInputElement;
|
||||
const gistmenutitle = document.getElementById('gist-menu-title')!;
|
||||
|
||||
gistmenutitle.textContent = gistmenucopy.children[0].firstChild!.textContent;
|
||||
gistmenuinput.value = (gistmenucopy.children[0] as HTMLElement).dataset.link || '';
|
||||
|
||||
gistmenutoggle.onclick = () => {
|
||||
gistmenucopy.classList.toggle('hidden');
|
||||
};
|
||||
|
||||
for (const item of Array.from(gistmenucopy.children)) {
|
||||
(item as HTMLElement).onclick = () => {
|
||||
gistmenutitle.textContent = item.firstChild!.textContent;
|
||||
gistmenuinput.value = (item as HTMLElement).dataset.link || '';
|
||||
gistmenucopy.classList.toggle('hidden');
|
||||
};
|
||||
}
|
||||
|
||||
gistmenubuttoncopy.onclick = () => {
|
||||
const text = gistmenuinput.value;
|
||||
navigator.clipboard.writeText(text).catch((err) => {
|
||||
console.error('Could not copy text: ', err);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
const sortgist = document.getElementById('sort-gists-button');
|
||||
if (sortgist) {
|
||||
sortgist.onclick = () => {
|
||||
document.getElementById('sort-gists-dropdown')!.classList.toggle('hidden');
|
||||
};
|
||||
}
|
||||
|
||||
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');
|
||||
};
|
||||
|
||||
|
||||
document.querySelectorAll('.copy-gist-btn').forEach((e: HTMLElement) => {
|
||||
e.onclick = () => {
|
||||
navigator.clipboard.writeText(e.parentNode!.parentNode!.querySelector<HTMLElement>('.gist-content')!.textContent || '').catch((err) => {
|
||||
console.error('Could not copy text: ', err);
|
||||
});
|
||||
};
|
||||
// Desktop sidebar collapse toggle. The collapsed state is a class on <html>,
|
||||
// which survives hx-boost body swaps, so it persists across navigation (the head
|
||||
// script restores it from localStorage before paint). A delegated listener keeps
|
||||
// working after the #sidebar-toggle button is swapped in on each navigation.
|
||||
document.addEventListener('click', (e) => {
|
||||
const toggle = (e.target as Element | null)?.closest('#sidebar-toggle');
|
||||
if (!toggle) return;
|
||||
const collapsed = document.documentElement.classList.toggle('sidebar-collapsed');
|
||||
localStorage.setItem('sidebar-collapsed', collapsed ? '1' : '0');
|
||||
});
|
||||
|
||||
const gistmenuvisibility = document.getElementById('gist-menu-visibility');
|
||||
if (gistmenuvisibility) {
|
||||
let submitgistbutton = (document.getElementById('submit-gist') as HTMLInputElement);
|
||||
document.getElementById('gist-visibility-menu-button')!.onclick = () => {
|
||||
gistmenuvisibility!.classList.toggle('hidden');
|
||||
}
|
||||
const lastVisibility = localStorage.getItem('visibility');
|
||||
Array.from(document.querySelectorAll('.gist-visibility-option')).forEach((el) => {
|
||||
const visibility = (el as HTMLElement).dataset.visibility || '0';
|
||||
(el as HTMLElement).onclick = () => {
|
||||
submitgistbutton.textContent = (el as HTMLElement).dataset.btntext;
|
||||
submitgistbutton!.value = visibility;
|
||||
localStorage.setItem('visibility', visibility);
|
||||
gistmenuvisibility!.classList.add('hidden');
|
||||
}
|
||||
if (lastVisibility === visibility) {
|
||||
(el as HTMLElement).click();
|
||||
}
|
||||
});
|
||||
}
|
||||
// Top loading bar for boosted navigations. Large files can take a moment to
|
||||
// render server-side, and htmx gives no visual feedback in the meantime, so the
|
||||
// page looks frozen after a click. This animates a slim bar at the top of the
|
||||
// viewport while the request is in flight.
|
||||
//
|
||||
// hx-boost swaps the whole <body> innerHTML on navigation, which would wipe any
|
||||
// element we place in the template. We own the bar in JS instead and re-attach
|
||||
// it after every swap so the reference never goes stale.
|
||||
(() => {
|
||||
const bar = document.createElement('div');
|
||||
bar.id = 'page-progress';
|
||||
bar.setAttribute('aria-hidden', 'true');
|
||||
|
||||
const expireselect = document.getElementById('expire') as HTMLSelectElement | null;
|
||||
const expireat = document.getElementById('expire_at') as HTMLInputElement | null;
|
||||
if (expireselect && expireat) {
|
||||
const toggleExpireAt = () => {
|
||||
expireat.classList.toggle('hidden', expireselect.value !== 'custom');
|
||||
let trickle: number | undefined;
|
||||
let showTimer: number | undefined;
|
||||
let progress = 0;
|
||||
|
||||
const set = (value: number) => {
|
||||
progress = value;
|
||||
bar.style.setProperty('--progress', String(value));
|
||||
};
|
||||
expireselect.addEventListener('change', toggleExpireAt);
|
||||
toggleExpireAt();
|
||||
}
|
||||
|
||||
const searchinput = document.getElementById('search') as HTMLInputElement;
|
||||
searchinput.addEventListener('focusin', () => {
|
||||
document.getElementById('search-help').classList.remove('hidden');
|
||||
})
|
||||
|
||||
searchinput.addEventListener('focusout', (e) => {
|
||||
document.getElementById('search-help').classList.add('hidden');
|
||||
})
|
||||
const attach = () => {
|
||||
// A history snapshot may have restored a stale copy of the bar (see the
|
||||
// beforeHistorySave handler below). Drop any that isn't ours.
|
||||
document.querySelectorAll('#page-progress').forEach((el) => {
|
||||
if (el !== bar) el.remove();
|
||||
});
|
||||
if (bar.parentElement !== document.body) document.body.appendChild(bar);
|
||||
};
|
||||
attach();
|
||||
document.body.addEventListener('htmx:afterSwap', attach);
|
||||
|
||||
// The bar is chrome, not page content, so keep it out of the DOM snapshot
|
||||
// htmx saves for back/forward navigation — otherwise a bar frozen mid-load
|
||||
// gets restored and sticks at the top. Detaching + clearing state leaves the
|
||||
// snapshot clean; attach() re-adds a fresh bar on the way back.
|
||||
document.body.addEventListener('htmx:beforeHistorySave', () => {
|
||||
window.clearTimeout(showTimer);
|
||||
window.clearInterval(trickle);
|
||||
bar.classList.remove('is-loading');
|
||||
set(0);
|
||||
bar.remove();
|
||||
});
|
||||
|
||||
const start = () => {
|
||||
window.clearTimeout(showTimer);
|
||||
window.clearInterval(trickle);
|
||||
// Delay showing so quick navigations don't flash the bar.
|
||||
showTimer = window.setTimeout(() => {
|
||||
attach();
|
||||
bar.classList.add('is-loading');
|
||||
set(0.08);
|
||||
// Creep toward (but never reach) the end while we wait.
|
||||
trickle = window.setInterval(() => {
|
||||
if (progress < 0.9) set(progress + (0.9 - progress) * 0.1);
|
||||
}, 300);
|
||||
}, 150);
|
||||
};
|
||||
|
||||
const done = () => {
|
||||
window.clearTimeout(showTimer);
|
||||
window.clearInterval(trickle);
|
||||
if (!bar.classList.contains('is-loading')) return;
|
||||
// Fill to the end, then fade out. Reset the width only once it's fully
|
||||
// invisible so the bar never appears to slide backwards.
|
||||
attach();
|
||||
set(1);
|
||||
window.setTimeout(() => bar.classList.remove('is-loading'), 200);
|
||||
window.setTimeout(() => {
|
||||
bar.style.transition = 'none';
|
||||
set(0);
|
||||
void bar.offsetWidth; // flush before restoring transitions
|
||||
bar.style.transition = '';
|
||||
}, 500);
|
||||
};
|
||||
|
||||
document.body.addEventListener('htmx:beforeRequest', start);
|
||||
document.body.addEventListener('htmx:afterRequest', done);
|
||||
document.body.addEventListener('htmx:historyRestore', () => {
|
||||
attach();
|
||||
done();
|
||||
});
|
||||
})();
|
||||
|
||||
// htmx ignores error responses (4xx/5xx) by default, so a boosted navigation to
|
||||
// a page that errors (e.g. a 404) would leave the user on the old page. Tell htmx
|
||||
// to swap the error page in anyway so it renders like a normal navigation.
|
||||
document.body.addEventListener('htmx:beforeSwap', (e) => {
|
||||
const detail = (e as CustomEvent).detail as { xhr: XMLHttpRequest; shouldSwap: boolean; isError: boolean };
|
||||
if (detail.xhr && detail.xhr.status >= 400) {
|
||||
detail.shouldSwap = true;
|
||||
detail.isError = false;
|
||||
}
|
||||
});
|
||||
|
||||
// hx-boost stores a DOM snapshot for the back/forward cache. Components mark
|
||||
// themselves initialized (basecoat: data-*-initialized, our filter:
|
||||
// data-filter-ready), but the restored snapshot keeps those markers while the
|
||||
// JS listeners are gone — so dropdowns etc. look dead. Strip the markers before
|
||||
// the snapshot is saved, and re-initialize on restore.
|
||||
const INIT_MARKERS = '[data-dropdown-menu-initialized], [data-filter-ready]';
|
||||
const stripInitMarkers = () => {
|
||||
document.querySelectorAll(INIT_MARKERS).forEach((el) => {
|
||||
el.removeAttribute('data-dropdown-menu-initialized');
|
||||
el.removeAttribute('data-filter-ready');
|
||||
});
|
||||
};
|
||||
|
||||
document.body.addEventListener('htmx:beforeHistorySave', stripInitMarkers);
|
||||
document.body.addEventListener('htmx:historyRestore', () => {
|
||||
stripInitMarkers();
|
||||
(window as unknown as { basecoat?: { initAll?: () => void } }).basecoat?.initAll?.();
|
||||
init();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import PDFObject from 'pdfobject';
|
||||
|
||||
// Embed every PDF on the page. PDFObject drops an <iframe> pointing at the raw
|
||||
// file into the container and the browser's native viewer loads it; we keep the
|
||||
// server-rendered spinner up until that iframe fires `load`.
|
||||
//
|
||||
// PDFObject empties its target node before embedding, so we embed into a child
|
||||
// holder rather than the `.pdf` element itself — that preserves the spinner
|
||||
// sibling. The data-pdf-embedded marker keeps this idempotent, so it is safe to
|
||||
// call again after each hx-boost swap without embedding a second iframe.
|
||||
export function initPdf() {
|
||||
document.querySelectorAll<HTMLElement>('.pdf[data-src]').forEach((el) => {
|
||||
if (el.dataset.pdfEmbedded) return;
|
||||
el.dataset.pdfEmbedded = 'true';
|
||||
|
||||
const holder = document.createElement('div');
|
||||
holder.className = 'pdf-embed';
|
||||
el.appendChild(holder);
|
||||
|
||||
const removeSpinner = () => el.querySelector('.pdf-loading')?.remove();
|
||||
const embedded = PDFObject.embed(el.dataset.src || '', holder);
|
||||
|
||||
if (embedded && (embedded as HTMLElement).tagName === 'IFRAME') {
|
||||
(embedded as HTMLIFrameElement).addEventListener('load', removeSpinner);
|
||||
} else {
|
||||
// Unsupported browser / fallback markup: nothing will fire `load`.
|
||||
removeSpinner();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const noSoftWrapRadio = document.getElementById('no-soft-wrap');
|
||||
const softWrapRadio = document.getElementById('soft-wrap');
|
||||
|
||||
function updateRootClass() {
|
||||
const table = document.querySelector("table");
|
||||
|
||||
if (softWrapRadio.checked) {
|
||||
table.classList.remove('whitespace-pre');
|
||||
table.classList.add('whitespace-pre-wrap');
|
||||
} else {
|
||||
table.classList.remove('whitespace-pre-wrap');
|
||||
table.classList.add('whitespace-pre');
|
||||
}
|
||||
}
|
||||
|
||||
noSoftWrapRadio.addEventListener('change', updateRootClass);
|
||||
softWrapRadio.addEventListener('change', updateRootClass);
|
||||
|
||||
|
||||
document.getElementById('removedlinecolor').addEventListener('change', function(event) {
|
||||
const color = hexToRgba(event.target.value, 0.1);
|
||||
document.documentElement.style.setProperty('--red-diff', color);
|
||||
});
|
||||
|
||||
document.getElementById('addedlinecolor').addEventListener('change', function(event) {
|
||||
const color = hexToRgba(event.target.value, 0.1);
|
||||
document.documentElement.style.setProperty('--green-diff', color);
|
||||
});
|
||||
|
||||
document.getElementById('gitlinecolor').addEventListener('change', function(event) {
|
||||
const color = hexToRgba(event.target.value, 0.38);
|
||||
document.documentElement.style.setProperty('--git-diff', color);
|
||||
});
|
||||
});
|
||||
|
||||
function hexToRgba(hex, opacity) {
|
||||
hex = hex.replace('#', '');
|
||||
|
||||
const r = parseInt(hex.substring(0, 2), 16);
|
||||
const g = parseInt(hex.substring(2, 4), 16);
|
||||
const b = parseInt(hex.substring(4, 6), 16);
|
||||
|
||||
return `rgba(${r}, ${g}, ${b}, ${opacity})`;
|
||||
}
|
||||
+20
-9
@@ -165,18 +165,29 @@ async function loginWithPasskey() {
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const registerButton = document.getElementById('bind-passkey-button');
|
||||
if (registerButton) {
|
||||
function initWebauthn() {
|
||||
const registerButton = document.getElementById('bind-passkey-button') as HTMLElement | null;
|
||||
if (registerButton && !registerButton.dataset.passkeyBound) {
|
||||
registerButton.dataset.passkeyBound = 'true';
|
||||
registerButton.addEventListener('click', bindPasskey);
|
||||
}
|
||||
|
||||
if (document.documentURI.includes('/mfa')) {
|
||||
loginMethod = "assertion"
|
||||
}
|
||||
loginMethod = document.documentURI.includes('/mfa') ? 'assertion' : 'login';
|
||||
|
||||
const loginButton = document.getElementById('login-passkey-button');
|
||||
if (loginButton) {
|
||||
const loginButton = document.getElementById('login-passkey-button') as HTMLElement | null;
|
||||
if (loginButton && !loginButton.dataset.passkeyBound) {
|
||||
loginButton.dataset.passkeyBound = 'true';
|
||||
loginButton.addEventListener('click', loginWithPasskey);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// The page can be reached either by a full load or via hx-boost, which swaps
|
||||
// the body content without firing DOMContentLoaded. Initialize for all paths,
|
||||
// matching how main.ts / editor.ts re-initialize their components.
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initWebauthn);
|
||||
} else {
|
||||
initWebauthn();
|
||||
}
|
||||
document.body.addEventListener('htmx:afterSwap', initWebauthn);
|
||||
document.body.addEventListener('htmx:historyRestore', initWebauthn);
|
||||
|
||||
Vendored
+5
-8
@@ -18,16 +18,13 @@ export default defineConfig({
|
||||
manifest: true,
|
||||
rollupOptions: {
|
||||
input: [
|
||||
'./public/ts/admin.ts',
|
||||
'./public/ts/auto.ts',
|
||||
'./public/ts/dark.ts',
|
||||
'./public/ts/editor.ts',
|
||||
'./public/ts/embed.ts',
|
||||
'./public/ts/gist.ts',
|
||||
'./public/ts/light.ts',
|
||||
'./public/ts/main.ts',
|
||||
'./public/ts/style_preferences.ts',
|
||||
'./public/ts/editor.ts',
|
||||
'./public/ts/webauthn.ts',
|
||||
'./public/ts/auto.ts',
|
||||
'./public/ts/light.ts',
|
||||
'./public/ts/dark.ts',
|
||||
'./public/ts/embed.ts',
|
||||
]
|
||||
},
|
||||
assetsInlineLimit: 0,
|
||||
|
||||
Vendored
-14
@@ -1,14 +0,0 @@
|
||||
{{ if false }}{{/* prevent IDE errors */}}
|
||||
<div><main>
|
||||
{{ end }}
|
||||
|
||||
{{ define "admin_footer" }}
|
||||
{{ if .urlPage }}
|
||||
<div class="flex mt-4 justify-center space-x-2">
|
||||
{{ template "_pagination" . }}
|
||||
</div>
|
||||
{{ end }}
|
||||
<script src="{{ asset "ts/admin.ts" }}"></script>
|
||||
</main>
|
||||
</div>
|
||||
{{ end }}
|
||||
Vendored
-30
@@ -1,30 +0,0 @@
|
||||
{{ define "admin_header" }}
|
||||
<div class="py-10">
|
||||
<header class="pb-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold leading-tight">{{ .locale.Tr "admin.admin_panel" }}</h1>
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
<div class="mb-4">
|
||||
<div class="">
|
||||
<nav class="flex space-x-4" aria-label="Tabs">
|
||||
<a href="{{ $.c.ExternalUrl }}/admin-panel" class="{{ if eq .adminHeaderPage "index" }}bg-gray-100 dark:bg-gray-700 text-slate-700 dark:text-slate-300 px-3 py-2 font-medium text-sm rounded-md
|
||||
{{ else }} text-gray-600 dark:text-gray-400 hover:text-gray-400 dark:hover:text-slate-300 px-3 py-2 font-medium text-sm rounded-md {{ end }}">{{ .locale.Tr "admin.general" }} </a>
|
||||
<a href="{{ $.c.ExternalUrl }}/admin-panel/users" class="{{ if eq .adminHeaderPage "users" }}bg-gray-100 dark:bg-gray-700 text-slate-700 dark:text-slate-300 px-3 py-2 font-medium text-sm rounded-md
|
||||
{{ else }} text-gray-600 dark:text-gray-400 hover:text-gray-400 dark:hover:text-slate-300 px-3 py-2 font-medium text-sm rounded-md {{ end }}" aria-current="page">{{ .locale.Tr "admin.users" }}</a>
|
||||
<a href="{{ $.c.ExternalUrl }}/admin-panel/gists" class="{{ if eq .adminHeaderPage "gists" }}bg-gray-100 dark:bg-gray-700 text-slate-700 dark:text-slate-300 px-3 py-2 font-medium text-sm rounded-md
|
||||
{{ else }} text-gray-600 dark:text-gray-400 hover:text-gray-400 dark:hover:text-slate-300 px-3 py-2 font-medium text-sm rounded-md {{ end }}" aria-current="page">{{ .locale.Tr "admin.gists" }}</a>
|
||||
<a href="{{ $.c.ExternalUrl }}/admin-panel/invitations" class="{{ if eq .adminHeaderPage "invitations" }}bg-gray-100 dark:bg-gray-700 text-slate-700 dark:text-slate-300 px-3 py-2 font-medium text-sm rounded-md
|
||||
{{ else }} text-gray-600 dark:text-gray-400 hover:text-gray-400 dark:hover:text-slate-300 px-3 py-2 font-medium text-sm rounded-md {{ end }}" aria-current="page">{{ .locale.Tr "admin.invitations" }}</a>
|
||||
<a href="{{ $.c.ExternalUrl }}/admin-panel/configuration" class="{{ if eq .adminHeaderPage "config" }}bg-gray-100 dark:bg-gray-700 text-slate-700 dark:text-slate-300 px-3 py-2 font-medium text-sm rounded-md
|
||||
{{ else }} text-gray-600 dark:text-gray-400 hover:text-gray-400 dark:hover:text-slate-300 px-3 py-2 font-medium text-sm rounded-md {{ end }}" aria-current="page">{{ .locale.Tr "admin.configuration" }}</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
{{ if false }}
|
||||
{{/* prevent IDE errors */}}
|
||||
</main></div>
|
||||
{{ end }}
|
||||
Vendored
-44
@@ -1,44 +0,0 @@
|
||||
{{ if false }}
|
||||
{{/* prevent IDE errors */}}
|
||||
<html lang="en"><body><div><div>
|
||||
{{ end }}
|
||||
|
||||
{{ define "footer" }}
|
||||
<div class="inline-flex py-8">
|
||||
<p class="text-slate-600 dark:text-slate-400 *:mx-1.5 -ml-1.5 flex">
|
||||
<span>
|
||||
<a target="_blank" style="margin-left: 0 !important;" class="text-slate-600 dark:text-slate-400 hover:text-slate-800 dark:hover:text-slate-200 inline-flex" href="https://github.com/thomiceli/opengist">
|
||||
<span class="mr-1">{{ .locale.Tr "footer.powered-by" "<span class=\"font-bold dark:text-slate-300\">Opengist</span>" }} </span>
|
||||
</a>
|
||||
</span>⋅
|
||||
<span>Load: <span class="font-bold dark:text-slate-300">{{ loadedTime .loadStartTime }}</span></span>⋅
|
||||
</p>
|
||||
<div class="ml-1.5 cursor-pointer relative inline-block">
|
||||
<span id="language-btn" class="text-slate-600 font-bold dark:text-slate-300"><svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="mb-1 w-5 h-5 inline-flex">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418" />
|
||||
</svg>
|
||||
{{ .localeName }}
|
||||
</span>
|
||||
|
||||
<div id="language-list" class="hidden absolute bottom-0 z-10 mb-10 mt-2 origin-bottom-right rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:bg-gray-800 dark:ring-gray-700" role="menu" aria-orientation="vertical" aria-labelledby="menu-button" tabindex="-1">
|
||||
<div class="py-1" role="none">
|
||||
{{ range .allLocales }}
|
||||
<a href="?lang={{ .Code }}" class="dark:text-slate-300 text-slate-700 group flex items-center px-4 py-1.5 text-sm w-max hover:text-slate-500 dark:hover:text-slate-400" role="menuitem" tabindex="-1" id="menu-item-0">{{ .Name }}</a>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ if ne (len .c.StaticLinks) 0 }}
|
||||
<div class="ml-1.5">
|
||||
{{ range $index, $value := .c.StaticLinks }}
|
||||
⋅ <a href="{{ if isUrl .Path }}{{ .Path }}{{ else }}{{ $.c.ExternalUrl }}/assets/{{ .Path }}{{ end }}" class="text-slate-600 dark:text-slate-400 hover:text-slate-800 dark:hover:text-slate-200 inline-flex">{{ .Name }}</a>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
{{ end }}
|
||||
Vendored
-302
@@ -1,302 +0,0 @@
|
||||
{{ define "header" }}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="h-full {{ if eq (mainTheme .currentStyle) "dark"}} dark{{ end }}" data-theme="{{ mainTheme .currentStyle }}">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
{{ if .NoIndex }}
|
||||
<meta name="robots" content="noindex, follow">
|
||||
{{ end }}
|
||||
|
||||
<base href="{{ $.c.ExternalUrl }}" />
|
||||
|
||||
{{ if .canonicalUrl }}
|
||||
<link rel="canonical" href="{{ .canonicalUrl }}" />
|
||||
{{ end }}
|
||||
|
||||
{{ template "seo_meta" . }}
|
||||
|
||||
<script nonce="{{ .cspNonce }}">
|
||||
window.opengist_base_url = "{{ $.c.ExternalUrl }}";
|
||||
window.opengist_locale = "{{ .locale.Code }}".substring(0, 2);
|
||||
const checkTheme = () => {
|
||||
if (document.documentElement.dataset.theme === "auto") {
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches ?
|
||||
document.documentElement.classList.add('dark') :
|
||||
document.documentElement.classList.remove('dark')
|
||||
}
|
||||
}
|
||||
|
||||
checkTheme()
|
||||
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', checkTheme);
|
||||
</script>
|
||||
{{ if $.c.CustomFavicon }}
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="{{ custom $.c.CustomFavicon }}">
|
||||
{{ else }}
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="{{ asset "img/favicon-32.png" }}">
|
||||
{{ end }}
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
|
||||
{{ if dev }}
|
||||
<script type="module" src="{{ asset "@vite/client" }}"></script>
|
||||
<link rel="stylesheet" href="{{ assetCss (print "css/" (mainTheme .currentStyle ) ".css") }}" />
|
||||
{{ else }}
|
||||
<link rel="stylesheet" href="{{ assetCss "ts/main.ts" }}" />
|
||||
<link rel="stylesheet" href="{{ assetCss (print "ts/" (mainTheme .currentStyle ) ".ts") }}" />
|
||||
{{ end }}
|
||||
<script type="module" src="{{ asset "ts/main.ts" }}"></script>
|
||||
|
||||
{{ if .htmlTitle }}
|
||||
<title>{{ .htmlTitle }} - {{ if $.c.CustomName }}{{ $.c.CustomName }}{{ else }}Opengist{{ end }}</title>
|
||||
{{ else }}
|
||||
<title>{{ if $.c.CustomName }}{{ $.c.CustomName }}{{ else }}Opengist{{ end }}</title>
|
||||
{{ end }}
|
||||
|
||||
{{ if .currentStyle }}
|
||||
<style>
|
||||
:root {
|
||||
--red-diff: rgba({{ hexToRgb .currentStyle.RemovedLineColor }} 0.1);
|
||||
--green-diff: rgba({{ hexToRgb .currentStyle.AddedLineColor }} 0.1);
|
||||
--git-diff: rgba({{ hexToRgb .currentStyle.GitLineColor }} 0.38);
|
||||
}
|
||||
</style>
|
||||
{{ end }}
|
||||
</head>
|
||||
<body class="h-full">
|
||||
<div id="app" class="text-gray-700 dark:text-white min-h-full bg-white dark:bg-gray-900">
|
||||
<div class="min-h-full">
|
||||
<nav class="dark:bg-gray-800 bg-gray-50">
|
||||
<div class="max-w-5xl mx-auto px-2 sm:px-6 lg:px-8">
|
||||
<div class="relative flex items-center justify-between h-16">
|
||||
<div class="absolute inset-y-0 left-0 flex items-center sm:hidden">
|
||||
<!-- Mobile menu button-->
|
||||
<button id="main-menu-button" type="button" class="inline-flex items-center justify-center p-2 rounded-md text-slate-600 dark:text-slate-400 hover:text-black dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-white" aria-controls="mobile-menu" aria-expanded="false">
|
||||
<span class="sr-only">Open main menu</span>
|
||||
<svg id="main-menu-open" class="block h-6 w-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
<svg id="main-menu-close" class="hidden h-6 w-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="shrink-0 items-center hidden sm:flex">
|
||||
<a href="{{ $.c.ExternalUrl }}/">
|
||||
{{ if $.c.CustomLogo }}
|
||||
<img src="{{ custom $.c.CustomLogo }}" class="object-cover h-12">
|
||||
{{ else }}
|
||||
<img src="{{ asset "img/opengist.svg" }}" class="object-cover h-12 w-12">
|
||||
{{ end }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex-1 flex items-center justify-center sm:items-stretch sm:justify-start">
|
||||
<div class="shrink-0 items-center flex sm:hidden">
|
||||
<a href="{{ $.c.ExternalUrl }}/">
|
||||
{{ if $.c.CustomLogo }}
|
||||
<img src="{{ custom $.c.CustomLogo }}" class="object-cover h-12">
|
||||
{{ else }}
|
||||
<img src="{{ asset "img/opengist.svg" }}" class="object-cover h-12 w-12">
|
||||
{{ end }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="hidden sm:block sm:ml-6">
|
||||
<div class="flex space-x-4">
|
||||
<a href="{{ $.c.ExternalUrl }}/all" class="text-slate-700 dark:text-slate-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-black dark:hover:text-white px-3 py-2 rounded-md text-sm font-medium">{{ .locale.Tr "header.menu.all" }}</a>
|
||||
<a href="{{ $.c.ExternalUrl }}/{{ if not .userLogged }}login{{ end }}" class="text-slate-700 dark:text-slate-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-black dark:hover:text-white px-3 py-2 rounded-md text-sm font-medium">{{ .locale.Tr "header.menu.new" }}</a>
|
||||
<div class="flex flex-1 items-center justify-center px-2 lg:ml-6 lg:justify-end">
|
||||
<div class="w-full max-w-lg lg:max-w-xs">
|
||||
<label for="search" class="sr-only">{{ .locale.Tr "header.menu.search" }}</label>
|
||||
<div class="relative">
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<svg class="h-5 w-5 text-gray-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<form action="{{ $.c.ExternalUrl }}/search" method="GET">
|
||||
<input id="search" name="q" autocomplete="off" class="bg-white dark:bg-gray-900 shadow-sm focus:ring-primary-500 focus:border-primary-500 block w-full sm:text-sm border-gray-200 dark:border-gray-700 rounded-md pl-10" placeholder="{{if indexEnabled}}Code search{{else}}Search{{end}}" type="search" value="{{ .searchQuery }}">
|
||||
<input type="submit" hidden="hidden">
|
||||
</form>
|
||||
{{if indexEnabled}}
|
||||
<div id="search-help" class="hidden absolute left-1/2 z-10 mt-5 w-screen max-w-max -translate-x-1/2 px-4">
|
||||
<div class="flex-auto overflow-hidden rounded-md bg-white dark:bg-gray-800 text-sm leading-6 border-1 border-gray-100 dark:border-gray-700 ring-1 ring-gray-900/5">
|
||||
<div class="p-4 text-xs space-y-1">
|
||||
<p class="text-gray-400"><code class="text-slate-800 dark:text-slate-300 pr-1">user:thomas</code> {{ .locale.Tr "gist.search.help.user" }}</p>
|
||||
<p class="text-gray-400"><code class="text-slate-800 dark:text-slate-300 pr-1">title:mygist</code> {{ .locale.Tr "gist.search.help.title" }}</p>
|
||||
<p class="text-gray-400"><code class="text-slate-800 dark:text-slate-300 pr-1">description:sync</code> {{ .locale.Tr "gist.search.help.description" }}</p>
|
||||
<p class="text-gray-400"><code class="text-slate-800 dark:text-slate-300 pr-1">filename:myfile.txt</code> {{ .locale.Tr "gist.search.help.filename" }}</p>
|
||||
<p class="text-gray-400"><code class="text-slate-800 dark:text-slate-300 pr-1">extension:yml</code> {{ .locale.Tr "gist.search.help.extension" }}</p>
|
||||
<p class="text-gray-400"><code class="text-slate-800 dark:text-slate-300 pr-1">language:go</code> {{ .locale.Tr "gist.search.help.language" }}</p>
|
||||
<p class="text-gray-400"><code class="text-slate-800 dark:text-slate-300 pr-1">topic:homelab</code> {{ .locale.Tr "gist.search.help.topic" }}</p>
|
||||
<p class="text-gray-400"><code class="text-slate-800 dark:text-slate-300 pr-1">all:systemctl</code> {{ .locale.Tr "gist.search.help.all" }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute inset-y-0 right-0 flex items-center pr-2 sm:static sm:inset-auto sm:ml-6 sm:pr-0">
|
||||
{{ if .userLogged }}
|
||||
<div id="user-btn" class="hidden sm:flex items-center ml-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md px-3 py-2">
|
||||
<div class="inline-flex">
|
||||
<p class="hidden sm:block text-slate-700 dark:text-slate-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-black dark:hover:text-white rounded-md text-sm font-medium mr-2">{{ .userLogged.Username }}</p>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="h-5 w-5 inline-block">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="hidden relative sm:inline-block text-left">
|
||||
<div id="user-menu" class="hidden w-max font-medium absolute right-0 z-10 mt-12 origin-top-right divide-y dark:divide-gray-600 divide-gray-100 rounded-md dark:bg-gray-800 bg-white shadow-lg ring-1 ring-gray-50 dark:ring-gray-700 focus:outline-none">
|
||||
<div class="py-1" role="none">
|
||||
<a href="{{ $.c.ExternalUrl }}/{{ .userLogged.Username }}" class="dark:text-slate-300 text-slate-700 group flex items-center px-3 py-1.5 pr-6 text-sm w-full hover:text-slate-500 dark:hover:text-slate-400" role="menuitem" tabindex="-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="mr-3 h-5 w-5 text-slate-600 dark:text-slate-400 group-hover:text-slate-500">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
|
||||
</svg>
|
||||
{{ .locale.Tr "header.menu.my-gists" }}
|
||||
</a>
|
||||
<a href="{{ $.c.ExternalUrl }}/{{ .userLogged.Username }}/liked" class="dark:text-slate-300 text-slate-700 group flex items-center px-3 py-1.5 pr-6 text-sm w-full hover:text-slate-500 dark:hover:text-slate-400" role="menuitem" tabindex="-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="mr-3 h-5 w-5 text-slate-600 dark:text-slate-400 group-hover:text-slate-500">
|
||||
<path d="M11.645 20.91l-.007-.003-.022-.012a15.247 15.247 0 01-.383-.218 25.18 25.18 0 01-4.244-3.17C4.688 15.36 2.25 12.174 2.25 8.25 2.25 5.322 4.714 3 7.688 3A5.5 5.5 0 0112 5.052 5.5 5.5 0 0116.313 3c2.973 0 5.437 2.322 5.437 5.25 0 3.925-2.438 7.111-4.739 9.256a25.175 25.175 0 01-4.244 3.17 15.247 15.247 0 01-.383.219l-.022.012-.007.004-.003.001a.752.752 0 01-.704 0l-.003-.001z" />
|
||||
</svg>
|
||||
{{ .locale.Tr "header.menu.liked" }}
|
||||
</a>
|
||||
</div>
|
||||
{{ if .userLogged.IsAdmin }}
|
||||
<div class="py-1" role="none">
|
||||
<a href="{{ $.c.ExternalUrl }}/admin-panel" class="dark:text-slate-300 text-slate-700 group flex items-center px-3 py-1.5 pr-6 text-sm w-full hover:text-slate-500 dark:hover:text-slate-400" role="menuitem" tabindex="-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="mr-3 h-5 w-5 text-slate-600 dark:text-slate-400 group-hover:text-slate-500">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 6h9.75M10.5 6a1.5 1.5 0 11-3 0m3 0a1.5 1.5 0 10-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m-9.75 0h9.75" />
|
||||
</svg>
|
||||
{{ .locale.Tr "header.menu.admin" }}
|
||||
</a>
|
||||
</div>
|
||||
{{ end }}
|
||||
<div class="py-1" role="none">
|
||||
<a href="{{ $.c.ExternalUrl }}/settings" class="dark:text-slate-300 text-slate-700 group flex items-center px-3 py-1.5 pr-6 text-sm w-full hover:text-slate-500 dark:hover:text-slate-400" role="menuitem" tabindex="-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="mr-3 h-5 w-5 text-slate-600 dark:text-slate-400 group-hover:text-slate-500">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
{{ .locale.Tr "header.menu.settings" }}
|
||||
</a>
|
||||
<a href="{{ $.c.ExternalUrl }}/logout" class="dark:text-rose-400 text-rose-500 group flex items-center px-3 py-1.5 pr-6 text-sm w-full hover:text-rose-600 dark:hover:text-rose-500" role="menuitem" tabindex="-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="mr-3 h-5 w-5 dark:text-rose-400 text-rose-500 group-hover:text-rose-600 dark:group-hover:text-rose-500">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75" />
|
||||
</svg>
|
||||
{{ .locale.Tr "header.menu.logout" }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{{ else }}
|
||||
{{ if not .DisableSignup }}
|
||||
<a href="{{ $.c.ExternalUrl }}/register" class="hidden sm:inline-flex text-slate-700 dark:text-slate-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-black dark:hover:text-white px-3 py-2 rounded-md text-sm font-medium">
|
||||
<p class="text-slate-700 dark:text-slate-300 mr-1">{{ .locale.Tr "header.menu.register" }}</p>
|
||||
</a>
|
||||
{{ end }}
|
||||
<a href="{{ $.c.ExternalUrl }}/login" class="hidden sm:inline-flex hidden-xs text-slate-700 dark:text-slate-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-black dark:hover:text-white px-3 py-2 rounded-md text-sm font-medium">
|
||||
<p class="text-slate-700 dark:text-slate-300 mr-1">{{ .locale.Tr "header.menu.login" }}</p>
|
||||
</a>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile menu -->
|
||||
<div class="sm:hidden hidden" id="mobile-menu">
|
||||
<div class="mx-2">
|
||||
<label for="searchmobile" class="sr-only">{{ .locale.Tr "header.menu.search" }}</label>
|
||||
<div class="relative">
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<svg class="h-5 w-5 text-gray-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<form action="{{ $.c.ExternalUrl }}/search" method="GET">
|
||||
<input id="searchmobile" name="q" class="bg-white dark:bg-gray-900 shadow-sm focus:ring-primary-500 focus:border-primary-500 block w-full sm:text-sm border-gray-200 dark:border-gray-700 rounded-md pl-10" placeholder="Search" type="search" value="{{.searchQuery}}">
|
||||
<input type="submit" hidden="hidden">
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-2 pt-2 pb-3 space-y-1">
|
||||
<a href="{{ $.c.ExternalUrl }}/all" class="text-slate-700 dark:text-slate-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-black dark:hover:text-white block px-3 py-2 rounded-md text-base font-medium">{{ .locale.Tr "header.menu.all" }}</a>
|
||||
<a href="{{ $.c.ExternalUrl }}/{{ if not .userLogged }}login{{ end }}" class="text-slate-700 dark:text-slate-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-black dark:hover:text-white block px-3 py-2 rounded-md text-base font-medium">{{ .locale.Tr "header.menu.new" }}</a>
|
||||
{{ if .userLogged }}
|
||||
<a href="{{ $.c.ExternalUrl }}/{{ .userLogged.Username }}" class="text-slate-700 dark:text-slate-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-black dark:hover:text-white block px-3 py-2 rounded-md text-base font-medium">{{ .locale.Tr "header.menu.my-gists" }}</a>
|
||||
<a href="{{ $.c.ExternalUrl }}/settings" class="text-slate-700 dark:text-slate-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-black dark:hover:text-white block px-3 py-2 rounded-md text-base font-medium">{{ .locale.Tr "header.menu.settings" }}</a>
|
||||
|
||||
{{ if .userLogged.IsAdmin }}
|
||||
<a href="{{ $.c.ExternalUrl }}/admin-panel" class="text-slate-700 dark:text-slate-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-black dark:hover:text-white block px-3 py-2 rounded-md text-base font-medium">{{ .locale.Tr "header.menu.admin" }}</a>
|
||||
{{ end }}
|
||||
<a href="{{ $.c.ExternalUrl }}/logout" class="dark:text-rose-400 text-rose-500 hover:text-rose-600 dark:hover:text-rose-500 hover:bg-gray-100 dark:hover:bg-gray-700 block px-3 py-2 rounded-md text-base font-medium">{{ .locale.Tr "header.menu.logout" }}</a>
|
||||
{{ else }}
|
||||
{{ if not .DisableSignup }}
|
||||
<a href="{{ $.c.ExternalUrl }}/register" class="text-slate-700 dark:text-slate-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-black dark:hover:text-white block px-3 py-2 rounded-md text-base font-medium">{{ .locale.Tr "header.menu.register" }}</a>
|
||||
{{ end }}
|
||||
<a href="{{ $.c.ExternalUrl }}/login" class="text-slate-700 dark:text-slate-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-black dark:hover:text-white block px-3 py-2 rounded-md text-base font-medium">{{ .locale.Tr "header.menu.login" }}</a>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 text-slate-700 dark:text-slate-300">
|
||||
<div>
|
||||
{{range .flashErrors}}
|
||||
<div class="mt-4 rounded-md bg-gray-50 dark:bg-gray-800 border-l-4 border-rose-400 p-4">
|
||||
<div class="flex">
|
||||
<div class="shrink-0">
|
||||
<svg class="h-5 w-5 text-rose-600 dark:text-rose-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-rose-600 dark:text-rose-400">{{.}}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{range .flashSuccess}}
|
||||
<div class="mt-4 rounded-md bg-gray-50 dark:bg-gray-800 border-l-4 border-primary-500 dark:border-primary-400 p-4">
|
||||
<div class="flex">
|
||||
<div class="shrink-0">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-primary-500 dark:text-primary-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-primary-500 dark:text-primary-400">{{.}}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{range .flashWarnings}}
|
||||
<div class="mt-4 rounded-md bg-gray-50 dark:bg-gray-800 border-l-4 border-yellow-500 dark:border-yellow-400 p-4">
|
||||
<div class="flex">
|
||||
<div class="shrink-0">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-yellow-600 dark:text-yellow-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-yellow-600 dark:text-yellow-400">{{.}}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{ end }}
|
||||
|
||||
{{ if false }}
|
||||
{{/* prevent IDE errors */}}
|
||||
</div></div></body></html>
|
||||
{{ end }}
|
||||
Vendored
-10
@@ -1,10 +0,0 @@
|
||||
{{ if false }}
|
||||
{{/* prevent IDE errors */}}
|
||||
<div><main>
|
||||
{{ end }}
|
||||
|
||||
{{ define "gist_footer" }}
|
||||
|
||||
</main>
|
||||
</div>
|
||||
{{ end }}
|
||||
Vendored
-211
@@ -1,211 +0,0 @@
|
||||
{{ define "gist_header" }}
|
||||
<div class="py-10" id="gist" data-own="{{ if not .gist.Archived }}{{ if .userLogged }}{{ if eq .gist.User.Username .userLogged.Username }}true{{ end }}{{ end }}{{ end }}">
|
||||
<header>
|
||||
<div class="flex flex-col lg:flex-row">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold leading-tight break-all">
|
||||
<a href="{{ $.c.ExternalUrl }}/{{ .gist.User.Username }}">{{ .gist.User.Username }}</a> <span class="text-slate-700 dark:text-slate-300">/</span> <a href="{{ $.c.ExternalUrl }}/{{ .gist.User.Username }}/{{ .gist.Identifier }}">{{ .gist.Title }}</a>
|
||||
</h1>
|
||||
</div>
|
||||
<div class="lg:flex-row flex py-2 lg:py-0 lg:ml-auto">
|
||||
{{ if .userLogged }}
|
||||
<form id="like" class="flex items-center" method="post" action="{{ $.c.ExternalUrl }}/{{ .gist.User.Username }}/{{ .gist.Identifier }}/like?redirecturl={{ .currentUrl }}">
|
||||
{{ .csrfHtml }}
|
||||
<button type="submit" class="focus-within:z-10 text-slate-700 dark:text-slate-300 relative inline-flex items-center space-x-2 rounded-l-md border border-gray-200 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 px-2 py-1.5 text-xs font-medium text-slate-700 dark:text-slate-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:border-gray-500 hover:text-slate-700 dark:hover:text-slate-300 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 leading-3">
|
||||
{{ if not .hasLiked }}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4 mr-2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12z" />
|
||||
</svg>
|
||||
{{ .locale.Tr "gist.header.like" }}
|
||||
{{ else }}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-4 h-4 mr-2">
|
||||
<path d="M11.645 20.91l-.007-.003-.022-.012a15.247 15.247 0 01-.383-.218 25.18 25.18 0 01-4.244-3.17C4.688 15.36 2.25 12.174 2.25 8.25 2.25 5.322 4.714 3 7.688 3A5.5 5.5 0 0112 5.052 5.5 5.5 0 0116.313 3c2.973 0 5.437 2.322 5.437 5.25 0 3.925-2.438 7.111-4.739 9.256a25.175 25.175 0 01-4.244 3.17 15.247 15.247 0 01-.383.219l-.022.012-.007.004-.003.001a.752.752 0 01-.704 0l-.003-.001z" />
|
||||
</svg>
|
||||
{{ .locale.Tr "gist.header.unlike" }}
|
||||
{{ end }}
|
||||
</button>
|
||||
<a href="{{ $.c.ExternalUrl }}/{{ .gist.User.Username }}/{{ .gist.Identifier }}/likes" class="text-slate-700 dark:text-slate-300 relative inline-flex align-middle items-center space-x-2 rounded-r-md border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 px-2 py-1.5 -ml-px text-xs font-medium text-slate-700 dark:text-slate-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:border-gray-500 hover:text-slate-700 dark:hover:text-slate-300 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500">
|
||||
{{ .gist.NbLikes }}
|
||||
</a>
|
||||
</form>
|
||||
{{ if ne .userLogged.ID .gist.User.ID }}
|
||||
<form id="fork" class="ml-2 flex items-center " method="post" action="{{ $.c.ExternalUrl }}/{{ .gist.User.Username }}/{{ .gist.Identifier }}/fork">
|
||||
{{ .csrfHtml }}
|
||||
<button type="submit" class="ml-auto focus-within:z-10 text-slate-700 dark:text-slate-300 relative inline-flex items-center space-x-2 rounded-l-md border border-gray-200 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 px-2 py-1.5 text-xs font-medium text-slate-700 dark:text-slate-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:border-gray-500 hover:text-slate-700 dark:hover:text-slate-300 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 leading-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4 mr-2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M7.217 10.907a2.25 2.25 0 100 2.186m0-2.186c.18.324.283.696.283 1.093s-.103.77-.283 1.093m0-2.186l9.566-5.314m-9.566 7.5l9.566 5.314m0 0a2.25 2.25 0 103.935 2.186 2.25 2.25 0 00-3.935-2.186zm0-12.814a2.25 2.25 0 103.933-2.185 2.25 2.25 0 00-3.933 2.185z" />
|
||||
</svg>
|
||||
{{ .locale.Tr "gist.header.fork" }}
|
||||
</button>
|
||||
<a href="{{ $.c.ExternalUrl }}/{{ .gist.User.Username }}/{{ .gist.Identifier }}/forks" class="text-slate-700 dark:text-slate-300 relative inline-flex align-middle items-center space-x-2 rounded-r-md border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 px-2 py-1.5 -ml-px text-xs font-medium text-slate-700 dark:text-slate-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:border-gray-500 hover:text-slate-700 dark:hover:text-slate-300 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500">
|
||||
{{ .gist.NbForks }}
|
||||
</a>
|
||||
</form>
|
||||
{{ end }}
|
||||
{{ else }}
|
||||
<div class="lg:flex-row flex lg:py-0 lg:ml-auto flex items-center">
|
||||
<a href="{{ $.c.ExternalUrl }}/login" type="submit" class="ml-auto focus-within:z-10 text-slate-700 dark:text-slate-300 relative inline-flex items-center space-x-2 rounded-l-md border border-gray-200 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 px-2 py-1.5 text-xs font-medium text-slate-700 dark:text-slate-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:border-gray-500 hover:text-slate-700 dark:hover:text-slate-300 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 leading-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4 mr-2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12z" />
|
||||
</svg>
|
||||
{{ .locale.Tr "gist.header.like" }}
|
||||
</a>
|
||||
<a href="{{ $.c.ExternalUrl }}/{{ .gist.User.Username }}/{{ .gist.Identifier }}/likes" class="text-slate-700 dark:text-slate-300 relative inline-flex align-middle items-center space-x-2 rounded-r-md border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 px-2 py-1.5 -ml-px text-xs font-medium text-slate-700 dark:text-slate-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:border-gray-500 hover:text-slate-700 dark:hover:text-slate-300 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500">
|
||||
{{ .gist.NbLikes }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="ml-2 flex items-center">
|
||||
<a href="{{ $.c.ExternalUrl }}/login" type="submit" class="ml-auto focus-within:z-10 text-slate-700 dark:text-slate-300 relative inline-flex items-center space-x-2 rounded-l-md border border-gray-200 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 px-2 py-1.5 text-xs font-medium text-slate-700 dark:text-slate-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:border-gray-500 hover:text-slate-700 dark:hover:text-slate-300 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 leading-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4 mr-2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M7.217 10.907a2.25 2.25 0 100 2.186m0-2.186c.18.324.283.696.283 1.093s-.103.77-.283 1.093m0-2.186l9.566-5.314m-9.566 7.5l9.566 5.314m0 0a2.25 2.25 0 103.935 2.186 2.25 2.25 0 00-3.935-2.186zm0-12.814a2.25 2.25 0 103.933-2.185 2.25 2.25 0 00-3.933 2.185z" />
|
||||
</svg>
|
||||
{{ .locale.Tr "gist.header.fork" }}
|
||||
</a>
|
||||
<a href="{{ $.c.ExternalUrl }}/{{ .gist.User.Username }}/{{ .gist.Identifier }}/forks" class="text-slate-700 dark:text-slate-300 relative inline-flex align-middle items-center space-x-2 rounded-r-md border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 px-2 py-1.5 -ml-px text-xs font-medium text-slate-700 dark:text-slate-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:border-gray-500 hover:text-slate-700 dark:hover:text-slate-300 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500">
|
||||
{{ .gist.NbForks }}
|
||||
</a>
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ if .userLogged }}{{ if eq .gist.User.Username .userLogged.Username }}
|
||||
{{ if not .gist.Archived }}
|
||||
<div class="ml-2 flex items-center">
|
||||
<a href="{{ $.c.ExternalUrl }}/{{ .gist.User.Username }}/{{ .gist.Identifier }}/edit" class="relative inline-flex items-center space-x-2 rounded-md border border-gray-200 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 px-2 py-1.5 text-xs font-medium text-slate-700 dark:text-slate-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:border-gray-500 hover:text-slate-700 dark:hover:text-slate-300 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 leading-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
|
||||
</svg>
|
||||
{{ .locale.Tr "gist.header.edit" }}
|
||||
</a>
|
||||
</div>
|
||||
{{ end }}
|
||||
<form id="archive" class="ml-2 flex items-center" method="post" action="{{ $.c.ExternalUrl }}/{{ .gist.User.Username }}/{{ .gist.Identifier }}/archive">
|
||||
{{ .csrfHtml }}
|
||||
<button type="submit" class="relative inline-flex items-center space-x-2 rounded-md border border-gray-200 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 px-2 py-1.5 text-xs font-medium text-slate-700 dark:text-slate-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:border-gray-500 hover:text-slate-700 dark:hover:text-slate-300 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 leading-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
|
||||
</svg>
|
||||
{{ if .gist.Archived }}{{ .locale.Tr "gist.header.unarchive" }}{{ else }}{{ .locale.Tr "gist.header.archive" }}{{ end }}
|
||||
</button>
|
||||
</form>
|
||||
<form id="delete" class="ml-2 flex items-center" method="post" action="{{ $.c.ExternalUrl }}/{{ .gist.User.Username }}/{{ .gist.Identifier }}/delete">
|
||||
{{ .csrfHtml }}
|
||||
<button type="submit" onclick="return confirm('{{ .locale.Tr "gist.delete.confirm" }}')" class="relative inline-flex items-center space-x-2 rounded-md border border-gray-200 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 px-2 py-1.5 text-xs font-medium text-rose-600 dark:text-rose-400 hover:bg-rose-500 hover:text-white dark:hover:bg-rose-600 hover:border-rose-600 dark:hover:border-rose-700 dark:hover:text-white focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
{{ .locale.Tr "gist.header.delete" }}
|
||||
</button>
|
||||
</form>
|
||||
{{ end }}{{ end }}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{{ if .gist.Forked }}
|
||||
<p class="mt-1 max-w-2xl text-sm text-slate-500">{{ .locale.Tr "gist.header.forked-from" }} <a href="{{ $.c.ExternalUrl }}/{{ .gist.Forked.User.Username }}/{{ .gist.Forked.Identifier }}">{{ .gist.Forked.User.Username }}/{{ .gist.Forked.Title }}</a></p>
|
||||
{{ end }}
|
||||
<p class="mt-1 max-w-2xl text-sm text-slate-500">{{ .locale.Tr "gist.header.last-active" }} <span> {{ .gist.UpdatedAt | humanTimeDiff }} </span>
|
||||
{{ if .gist.Private }} • <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 dark:bg-gray-700 text-slate-700 dark:text-slate-300"> {{ visibilityStr .gist.Private false }} </span>{{ end }}
|
||||
{{ if .gist.Archived }} • <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-amber-100 dark:bg-amber-900/40 text-amber-800 dark:text-amber-200"> {{ .locale.Tr "gist.header.archived" }} </span>{{ end }}
|
||||
{{ if .gist.ExpiresAt }} • <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-yellow-100 dark:bg-yellow-900/40 text-yellow-800 dark:text-yellow-200" title="{{ humanDate .gist.ExpiresAt }}"> {{ .locale.Tr "gist.header.expires" }} {{ .gist.ExpiresAt | humanTimeDiff }} </span>{{ end }}
|
||||
</p>
|
||||
<p class="mt-1 text-sm max-w-2xl text-slate-600 dark:text-slate-400">{{ .gist.Description }}</p>
|
||||
{{ if .gist.Topics }}
|
||||
<div class="mt-2">
|
||||
{{ range .gist.Topics }}
|
||||
<a href="{{ $.c.ExternalUrl }}/topics/{{ .Topic }}" class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-primary-200 text-primary-900 hover:bg-primary-300 dark:bg-primary-950 dark:text-primary-200 dark:hover:bg-primary-900">{{ .Topic }}</a>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
</header>
|
||||
<main class="mt-4">
|
||||
|
||||
{{ if .gist.Archived }}
|
||||
<div class="rounded-md border border-1 border-amber-300 dark:border-amber-700 bg-amber-50 dark:bg-amber-900/30 px-4 py-2 text-sm text-amber-900 dark:text-amber-200 flex items-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2 flex-none" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
|
||||
</svg>
|
||||
{{ .locale.Tr "gist.header.archived-help" }}
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
<div class="my-4">
|
||||
<div class="sm:hidden">
|
||||
<label for="gist-tabs" class="sr-only">{{ .locale.Tr "gist.header.select-tab" }}</label>
|
||||
<select id="gist-tabs" name="tabs" class="block bg-gray-50 dark:bg-gray-800 w-full pl-3 pr-10 py-2 text-base border-gray-200 dark:border-gray-700 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm rounded-md">
|
||||
<option {{ if eq .page "code"}}selected{{end}} data-url="/{{ .gist.User.Username }}/{{ .gist.Identifier }}">{{ .locale.Tr "gist.header.code" }}</option>
|
||||
<option {{ if eq .page "revisions"}}selected{{end}} data-url="/{{ .gist.User.Username }}/{{ .gist.Identifier }}/revisions">{{ .locale.Tr "gist.header.revisions" }} ({{ if .nbCommits }}{{ .nbCommits }}{{else}}0{{ end }})</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="hidden sm:block">
|
||||
<div class="border-b flex border-gray-200 dark:border-gray-700">
|
||||
<nav class="-mb-px flex-auto space-x-4" aria-label="Tabs">
|
||||
<a href="{{ $.c.ExternalUrl }}/{{ .gist.User.Username }}/{{ .gist.Identifier }}" class="inline-flex items-center text-slate-700 dark:text-slate-300 {{ if eq .page "code"}}border-slate-500 dark:border-slate-300 {{else}}border-transparent hover:border-gray-700 dark:hover:border-gray-200{{end}} hover:text-slate-700 dark:hover:text-slate-300 whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm" aria-current="page">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6 mr-1">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M14.25 9.75L16.5 12l-2.25 2.25m-4.5 0L7.5 12l2.25-2.25M6 20.25h12A2.25 2.25 0 0020.25 18V6A2.25 2.25 0 0018 3.75H6A2.25 2.25 0 003.75 6v12A2.25 2.25 0 006 20.25z" />
|
||||
</svg>
|
||||
{{ .locale.Tr "gist.header.code" }}
|
||||
</a>
|
||||
<a href="{{ $.c.ExternalUrl }}/{{ .gist.User.Username }}/{{ .gist.Identifier }}/revisions" class="inline-flex items-center text-slate-700 dark:text-slate-300 {{ if eq .page "revisions"}}border-slate-500 dark:border-slate-300 {{else}}border-transparent hover:border-gray-700 dark:hover:border-gray-200{{end}} hover:text-slate-700 dark:hover:text-slate-300 whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6 mr-1">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 6.75h12M8.25 12h12m-12 5.25h12M3.75 6.75h.007v.008H3.75V6.75zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zM3.75 12h.007v.008H3.75V12zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm-.375 5.25h.007v.008H3.75v-.008zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z" />
|
||||
</svg>
|
||||
{{ .locale.Tr "gist.header.revisions" }}
|
||||
<span class="inline-flex items-center ml-2 px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 dark:bg-gray-700 text-slate-700 dark:text-slate-300"> {{ if .nbCommits }}{{ .nbCommits }}{{else}}0{{ end }} </span>
|
||||
</a>
|
||||
</nav>
|
||||
<div class="float-right inline-flex items-center space-x-2">
|
||||
<div>
|
||||
<div class="flex rounded-md shadow-sm">
|
||||
<div class="relative">
|
||||
<button type="button" id="gist-menu-toggle" class="relative text-xs inline-flex items-center space-x-2 rounded-l-md border border-gray-200 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 px-2 py-1.5 text-sm font-medium text-slate-700 dark:text-slate-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:border-gray-500 hover:text-slate-700 dark:hover:text-slate-300 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 leading-3 focus-within:z-10 -mr-px">
|
||||
<span id="gist-menu-title" class="whitespace-nowrap">{{ .locale.Tr "gist.header.embed" }}</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="absolute left-0 z-10 mt-2 w-56 origin-top-left 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="py-1 cursor-pointer border-1 rounded-md border-gray-200 dark:border-gray-700 hidden" id="gist-menu-copy" role="none">
|
||||
<div class="text-slate-700 dark:text-slate-300 block px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 gist-menu-item" role="menuitem" id="gist-menu-share" data-link="{{ .embedScript }}"><p>{{ .locale.Tr "gist.header.embed" }}</p>
|
||||
<p class="text-xs font-normal text-gray-600 dark:text-gray-400">{{ .locale.Tr "gist.header.embed-help" }}</p>
|
||||
</div>
|
||||
{{ if .httpCloneUrl }}
|
||||
<div class="text-slate-700 dark:text-slate-300 block px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 gist-menu-item" role="menuitem" id="gist-menu-http" data-link="{{ .httpCloneUrl }}"><p>{{ .locale.Tr "gist.header.clone-http" .httpProtocol }}</p>
|
||||
<p class="text-xs font-normal text-gray-600 dark:text-gray-400">{{ .locale.Tr "gist.header.clone-http-help" }}</p>
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ if .sshCloneUrl }}
|
||||
<div class="text-slate-700 dark:text-slate-300 block px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 gist-menu-item" role="menuitem" id="gist-menu-ssh" data-link="{{ .sshCloneUrl }}"><p>{{ .locale.Tr "gist.header.clone-ssh" }}</p>
|
||||
<p class="text-xs font-normal text-gray-600 dark:text-gray-400">{{ .locale.Tr "gist.header.clone-ssh-help" }}</p>
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative flex grow items-stretch focus-within:z-10">
|
||||
<input readonly id="gist-menu-input" value="{{.embedScript}}" class="block code bg-white dark:bg-gray-900 w-full rounded-none border border-gray-200 dark:border-gray-600 focus:border-primary-500 focus:ring-primary-500 focus:outline-none focus:ring-1 text-xs px-2 py-1">
|
||||
</div>
|
||||
<button id="gist-menu-button-copy" type="button" class="relative text-xs -ml-px inline-flex items-center space-x-2 rounded-r-md border border-gray-200 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 px-2 py-1 text-sm font-medium text-slate-700 dark:text-slate-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:border-gray-500 hover:text-slate-700 dark:hover:text-slate-300 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 leading-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 7.5V6.108c0-1.135.845-2.098 1.976-2.192.373-.03.748-.057 1.123-.08M15.75 18H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08M15.75 18.75v-1.875a3.375 3.375 0 00-3.375-3.375h-1.5a1.125 1.125 0 01-1.125-1.125v-1.5A3.375 3.375 0 006.375 7.5H5.25m11.9-3.664A2.251 2.251 0 0015 2.25h-1.5a2.251 2.251 0 00-2.15 1.586m5.8 0c.065.21.1.433.1.664v.75h-6V4.5c0-.231.035-.454.1-.664M6.75 7.5H4.875c-.621 0-1.125.504-1.125 1.125v12c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V16.5a9 9 0 00-9-9z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a href="{{ $.c.ExternalUrl }}/{{ .gist.User.Username }}/{{ .gist.Identifier }}/archive/{{ .revision }}" class="whitespace-nowrap text-slate-700 dark:text-slate-300 rounded border border-gray-200 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 px-2.5 py-2 text-xs font-medium 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 "gist.header.download-zip" }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ if .revision }} {{ if ne .revision "HEAD" }}
|
||||
<p class="italic text-xs mt-3">{{ .locale.Tr "gist.header.revision" }} <span class="revision-text">{{ .revision }}</span></p>
|
||||
{{ end }} {{ end }}
|
||||
</div>
|
||||
|
||||
{{ end }}
|
||||
|
||||
{{ if false }}
|
||||
{{/* prevent IDE errors */}}
|
||||
</main></div>
|
||||
{{ end }}
|
||||
|
||||
Vendored
-8
@@ -1,8 +0,0 @@
|
||||
{{ if false }}{{/* prevent IDE errors */}}
|
||||
<div><main>
|
||||
{{ end }}
|
||||
|
||||
{{ define "settings_footer" }}
|
||||
</main>
|
||||
</div>
|
||||
{{ end }}
|
||||
Vendored
-30
@@ -1,30 +0,0 @@
|
||||
{{ define "settings_header" }}
|
||||
<div class="py-10">
|
||||
<header class="pb-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold leading-tight">{{ .locale.Tr "settings" }}</h1>
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
<div class="mb-4">
|
||||
<div class="">
|
||||
<nav class="flex space-x-4" aria-label="Tabs">
|
||||
<a href="{{ $.c.ExternalUrl }}/settings" class="{{ if eq .settingsHeaderPage "account" }}bg-gray-100 dark:bg-gray-700 text-slate-700 dark:text-slate-300 px-3 py-2 font-medium text-sm rounded-md
|
||||
{{ else }} text-gray-600 dark:text-gray-400 hover:text-gray-400 dark:hover:text-slate-300 px-3 py-2 font-medium text-sm rounded-md {{ end }}">{{ .locale.Tr "settings.header.account" }} </a>
|
||||
<a href="{{ $.c.ExternalUrl }}/settings/mfa" class="{{ if eq .settingsHeaderPage "mfa" }}bg-gray-100 dark:bg-gray-700 text-slate-700 dark:text-slate-300 px-3 py-2 font-medium text-sm rounded-md
|
||||
{{ else }} text-gray-600 dark:text-gray-400 hover:text-gray-400 dark:hover:text-slate-300 px-3 py-2 font-medium text-sm rounded-md {{ end }}" aria-current="page">{{ .locale.Tr "settings.header.mfa" }}</a>
|
||||
<a href="{{ $.c.ExternalUrl }}/settings/ssh" class="{{ if eq .settingsHeaderPage "ssh" }}bg-gray-100 dark:bg-gray-700 text-slate-700 dark:text-slate-300 px-3 py-2 font-medium text-sm rounded-md
|
||||
{{ else }} text-gray-600 dark:text-gray-400 hover:text-gray-400 dark:hover:text-slate-300 px-3 py-2 font-medium text-sm rounded-md {{ end }}" aria-current="page">{{ .locale.Tr "settings.header.ssh" }}</a>
|
||||
<a href="{{ $.c.ExternalUrl }}/settings/access-tokens" class="{{ if eq .settingsHeaderPage "tokens" }}bg-gray-100 dark:bg-gray-700 text-slate-700 dark:text-slate-300 px-3 py-2 font-medium text-sm rounded-md
|
||||
{{ else }} text-gray-600 dark:text-gray-400 hover:text-gray-400 dark:hover:text-slate-300 px-3 py-2 font-medium text-sm rounded-md {{ end }}" aria-current="page">{{ .locale.Tr "settings.header.tokens" }}</a>
|
||||
<a href="{{ $.c.ExternalUrl }}/settings/style" class="{{ if eq .settingsHeaderPage "style" }}bg-gray-100 dark:bg-gray-700 text-slate-700 dark:text-slate-300 px-3 py-2 font-medium text-sm rounded-md
|
||||
{{ else }} text-gray-600 dark:text-gray-400 hover:text-gray-400 dark:hover:text-slate-300 px-3 py-2 font-medium text-sm rounded-md {{ end }}" aria-current="page">{{ .locale.Tr "settings.header.style" }}</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
{{ if false }}
|
||||
{{/* prevent IDE errors */}}
|
||||
</main></div>
|
||||
{{ end }}
|
||||
Vendored
+1
-1
@@ -2,5 +2,5 @@ package templates
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed */*.html
|
||||
//go:embed layouts/*.html partials/*.html pages/*.html
|
||||
var Files embed.FS
|
||||
|
||||
Vendored
+166
@@ -0,0 +1,166 @@
|
||||
{{ define "base" }}{{ template "header" . }}{{ block "content" . }}{{ end }}{{ template "footer" . }}{{ end }}
|
||||
|
||||
{{ define "header" }}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="h-full {{ if eq (mainTheme .currentStyle) "dark" }}dark{{ end }}" data-theme="{{ mainTheme .currentStyle }}">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
{{ if .NoIndex }}<meta name="robots" content="noindex, follow" />{{ end }}
|
||||
|
||||
<base href="{{ .c.ExternalUrl }}" />
|
||||
|
||||
{{ if .canonicalUrl }}
|
||||
<link rel="canonical" href="{{ .canonicalUrl }}" />
|
||||
{{ end }}
|
||||
|
||||
{{ template "seo_meta" . }}
|
||||
|
||||
<script nonce="{{ .cspNonce }}">
|
||||
window.opengist_base_url = "{{ .c.ExternalUrl }}";
|
||||
const checkTheme = () => {
|
||||
if (document.documentElement.dataset.theme === "auto") {
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
? document.documentElement.classList.add('dark')
|
||||
: document.documentElement.classList.remove('dark');
|
||||
}
|
||||
};
|
||||
checkTheme();
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', checkTheme);
|
||||
|
||||
// Restore the desktop sidebar collapsed state before paint (no flash).
|
||||
// The class lives on <html>, which survives hx-boost body swaps, so it
|
||||
// persists across navigation without re-applying on every swap.
|
||||
if (localStorage.getItem('sidebar-collapsed') === '1') {
|
||||
document.documentElement.classList.add('sidebar-collapsed');
|
||||
}
|
||||
</script>
|
||||
|
||||
{{ if .c.CustomFavicon }}
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="{{ custom .c.CustomFavicon }}" />
|
||||
{{ else }}
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="{{ asset "img/favicon-32.png" }}" />
|
||||
{{ end }}
|
||||
|
||||
{{ if dev }}
|
||||
<script type="module" src="{{ asset "@vite/client" }}"></script>
|
||||
<link rel="stylesheet" href="{{ asset "css/main.css" }}" />
|
||||
<link rel="stylesheet" href="{{ assetCss (print "css/" (mainTheme .currentStyle ) ".css") }}" />
|
||||
{{ else }}
|
||||
<link rel="stylesheet" href="{{ assetCss "ts/main.ts" }}" />
|
||||
<link rel="stylesheet" href="{{ assetCss (print "ts/" (mainTheme .currentStyle ) ".ts") }}" />
|
||||
{{ end }}
|
||||
<script type="module" src="{{ asset "ts/main.ts" }}"></script>
|
||||
|
||||
{{ if .currentStyle }}
|
||||
<style>
|
||||
:root {
|
||||
{{ if .currentStyle.ThemeColor }}
|
||||
{{/* Loaded after globals.css, so this overrides the default
|
||||
--theme-color in both :root and .dark. --primary and the
|
||||
sidebar/content tints all derive from it, recoloring the UI.
|
||||
ThemeColor is validated against validator.ThemeColors, so it
|
||||
only ever names a Tailwind color with a safelisted -500 var. */}}
|
||||
--theme-color: var(--color-{{ .currentStyle.ThemeColor }}-500);
|
||||
{{ end }}
|
||||
--red-diff: rgba({{ hexToRgb .currentStyle.RemovedLineColor }} 0.1);
|
||||
--green-diff: rgba({{ hexToRgb .currentStyle.AddedLineColor }} 0.1);
|
||||
--git-diff: rgba({{ hexToRgb .currentStyle.GitLineColor }} 0.38);
|
||||
}
|
||||
</style>
|
||||
{{ end }}
|
||||
|
||||
<title>{{ if .htmlTitle }}{{ .htmlTitle }} - {{ end }}{{ if .c.CustomName }}{{ .c.CustomName }}{{ else }}Opengist{{ end }}</title>
|
||||
</head>
|
||||
<body class="h-full bg-background text-foreground antialiased" hx-boost="true">
|
||||
|
||||
<!-- CSS-only mobile sidebar drawer (no JS) -->
|
||||
<input type="checkbox" id="nav-toggle" class="peer sr-only" />
|
||||
|
||||
{{ template "sidebar" . }}
|
||||
|
||||
<!-- Backdrop closes the mobile sidebar (no JS) -->
|
||||
<label for="nav-toggle" class="fixed inset-0 z-40 hidden bg-black/50 peer-checked:block md:hidden" aria-hidden="true"></label>
|
||||
|
||||
<!-- Desktop collapse is driven by the `sidebar-collapsed` class on <html> (see head
|
||||
script + main.ts). <html> survives hx-boost body swaps, so it persists across
|
||||
navigation. See globals.css for the collapsed layout rules. -->
|
||||
<div id="content-wrapper" class="flex min-h-full flex-col md:ml-60">
|
||||
<header class="bg-background/80 sticky top-0 z-30 flex h-14 items-center gap-3 border-b px-4 backdrop-blur">
|
||||
<label for="nav-toggle" class="btn-icon-ghost cursor-pointer md:hidden" aria-label="Toggle sidebar">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M3 12h18M3 18h18"/></svg>
|
||||
</label>
|
||||
|
||||
<form action="{{ .c.ExternalUrl }}/-/search" method="GET" class="group relative w-full max-w-md">
|
||||
<svg class="text-muted-foreground pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
|
||||
<input type="search" name="q" value="{{ .searchQuery }}" autocomplete="off"
|
||||
placeholder="{{ if indexEnabled }}Code search{{ else }}Search{{ end }}"
|
||||
class="input pl-9" />
|
||||
{{ if indexEnabled }}
|
||||
<div class="bg-popover text-popover-foreground absolute left-0 top-full z-40 mt-2 hidden w-max max-w-[calc(100vw-2rem)] rounded-md border p-3 text-xs shadow-md group-focus-within:block">
|
||||
<div class="grid gap-1">
|
||||
<p class="text-muted-foreground"><code class="text-foreground pr-1 font-mono">user:thomas</code> {{ .locale.Tr "gist.search.help.user" }}</p>
|
||||
<p class="text-muted-foreground"><code class="text-foreground pr-1 font-mono">title:mygist</code> {{ .locale.Tr "gist.search.help.title" }}</p>
|
||||
<p class="text-muted-foreground"><code class="text-foreground pr-1 font-mono">description:sync</code> {{ .locale.Tr "gist.search.help.description" }}</p>
|
||||
<p class="text-muted-foreground"><code class="text-foreground pr-1 font-mono">filename:myfile.txt</code> {{ .locale.Tr "gist.search.help.filename" }}</p>
|
||||
<p class="text-muted-foreground"><code class="text-foreground pr-1 font-mono">extension:yml</code> {{ .locale.Tr "gist.search.help.extension" }}</p>
|
||||
<p class="text-muted-foreground"><code class="text-foreground pr-1 font-mono">language:go</code> {{ .locale.Tr "gist.search.help.language" }}</p>
|
||||
<p class="text-muted-foreground"><code class="text-foreground pr-1 font-mono">topic:homelab</code> {{ .locale.Tr "gist.search.help.topic" }}</p>
|
||||
<p class="text-muted-foreground"><code class="text-foreground pr-1 font-mono">all:systemctl</code> {{ .locale.Tr "gist.search.help.all" }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
</form>
|
||||
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
{{ if .userLogged }}
|
||||
<a hx-boost="false" href="{{ .c.ExternalUrl }}/" class="btn-sm">
|
||||
<svg class="size-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14"/></svg>
|
||||
{{ .locale.Tr "header.menu.new" }}
|
||||
</a>
|
||||
<!-- Basecoat dropdown-menu (JS) -->
|
||||
<div class="dropdown-menu">
|
||||
<button type="button" id="user-menu-trigger" aria-haspopup="menu" aria-expanded="false" aria-controls="user-menu"
|
||||
class="hover:bg-accent hover:text-accent-foreground flex cursor-pointer items-center gap-2 rounded-md p-1.5 text-sm">
|
||||
{{ if not (shouldGenerateAvatar .userLogged .DisableGravatar) }}
|
||||
<img class="size-7 shrink-0 rounded-md object-cover" src="{{ avatarUrl .userLogged .DisableGravatar }}" alt="{{ .userLogged.Username }}" />
|
||||
{{ else }}
|
||||
<svg class="size-7 shrink-0 rounded-md" data-jdenticon-value="{{ .userLogged.Username }}" width="28" height="28"></svg>
|
||||
{{ end }}
|
||||
<span class="hidden max-w-[8rem] truncate text-left sm:block">{{ .userLogged.Username }}</span>
|
||||
<svg class="size-4 shrink-0" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>
|
||||
</button>
|
||||
<div id="user-menu" data-popover aria-hidden="true" data-side="bottom" data-align="end">
|
||||
<div role="menu" aria-labelledby="user-menu-trigger">
|
||||
<a href="{{ .c.ExternalUrl }}/{{ .userLogged.Username }}" role="menuitem">{{ .locale.Tr "header.menu.my-gists" }}</a>
|
||||
<a href="{{ .c.ExternalUrl }}/-/settings" role="menuitem">{{ .locale.Tr "header.menu.settings" }}</a>
|
||||
<hr role="separator" />
|
||||
<a href="{{ .c.ExternalUrl }}/-/logout" role="menuitem" class="text-destructive">{{ .locale.Tr "header.menu.logout" }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ else }}
|
||||
<a href="{{ .c.ExternalUrl }}/-/login" class="btn-sm">{{ .locale.Tr "header.menu.login" }}</a>
|
||||
{{ if not .DisableSignup }}
|
||||
<a href="{{ .c.ExternalUrl }}/-/register" class="btn-sm-outline">{{ .locale.Tr "header.menu.register" }}</a>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="content" class="bg-content flex-1">
|
||||
<div class="mx-auto w-full max-w-6xl px-4 py-6 sm:px-6 lg:px-8">
|
||||
{{ block "flashes" . }}
|
||||
{{ range .flashErrors }}<div class="alert og-red mb-4" role="alert"><section>{{ . }}</section></div>{{ end }}
|
||||
{{ range .flashSuccess }}<div class="alert og-blue mb-4" role="status"><section>{{ . }}</section></div>{{ end }}
|
||||
{{ range .flashWarnings }}<div class="alert og-orange mb-4" role="alert"><section>{{ . }}</section></div>{{ end }}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ define "footer" }}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
{{ end }}
|
||||
Vendored
+50
@@ -0,0 +1,50 @@
|
||||
{{ define "content" }}
|
||||
<div class="space-y-6">
|
||||
<header class="space-y-1">
|
||||
<h1 class="text-2xl font-bold leading-tight">{{ .locale.Tr "admin.admin_panel" }}</h1>
|
||||
<p class="text-muted-foreground text-sm">{{ .locale.Tr "admin.actions.subtitle" }}</p>
|
||||
</header>
|
||||
|
||||
<section class="bg-card overflow-hidden rounded-lg border">
|
||||
{{/* The list re-fetches itself while any action is running (and briefly
|
||||
after a manual trigger via pollNow), then stops polling once idle. */}}
|
||||
<div id="actions-list"
|
||||
{{ if or .anyRunning .pollNow }}hx-get="{{ .c.ExternalUrl }}/-/admin-panel/actions" hx-trigger="every 3s" hx-select="#actions-list" hx-target="this" hx-swap="outerHTML"{{ end }}>
|
||||
<ul class="divide-y">
|
||||
{{ range .actions }}
|
||||
<li class="flex items-center justify-between gap-4 p-4">
|
||||
<div class="min-w-0 space-y-1">
|
||||
<p class="text-sm font-medium">{{ $.locale.Tr .LabelKey }}</p>
|
||||
{{ if .Periodic }}
|
||||
<p class="text-muted-foreground flex items-center gap-1.5 text-xs">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-3.5"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>
|
||||
<code class="font-mono">{{ .Spec }}</code>
|
||||
</p>
|
||||
{{ end }}
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-3">
|
||||
{{ if .Running }}
|
||||
<span class="badge og-blue border-transparent">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" class="size-3.5 animate-spin"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>
|
||||
{{ $.locale.Tr "admin.actions.running" }}
|
||||
</span>
|
||||
{{ end }}
|
||||
{{/* Plain POST → the handler adds a flash and redirects back here
|
||||
with ?run=1. hx-boost (on <body>) turns this into a boosted
|
||||
navigation that re-renders the whole page, so the flash shows
|
||||
and polling picks up any still-running action. */}}
|
||||
<form method="POST" action="{{ $.c.ExternalUrl }}/-/admin-panel/{{ .Path }}">
|
||||
{{ $.csrfHtml }}
|
||||
<button type="submit" {{ if .Running }}disabled{{ end }}
|
||||
class="btn-sm-outline disabled:pointer-events-none disabled:opacity-50">
|
||||
{{ $.locale.Tr "admin.actions.run" }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{{ end }}
|
||||
Vendored
+126
-145
@@ -1,151 +1,132 @@
|
||||
{{ template "header" .}}
|
||||
{{ template "admin_header" .}}
|
||||
{{ define "content" }}
|
||||
<div class="space-y-6">
|
||||
<header class="space-y-1">
|
||||
<h1 class="text-2xl font-bold leading-tight">{{ .locale.Tr "admin.admin_panel" }}</h1>
|
||||
<p class="text-muted-foreground text-sm">{{ .locale.Tr "admin.configuration" }}</p>
|
||||
</header>
|
||||
|
||||
<div class="grid gap-4 grid-cols-1 md:grid-cols-2">
|
||||
<div class="p-6 bg-gray-50 dark:bg-gray-800 rounded-md border border-gray-200 dark:border-gray-700">
|
||||
<p class="italic text-xs text-gray-400 dark:text-gray-400 mb-4">{{ .locale.Tr "admin.config-link" (join "<a target=\"_blank\" href=\"https://github.com/thomiceli/opengist/blob/master/docs/configuration/configure.md#configuration\">" (toStr (.locale.Tr "admin.config-link-overriden")) "</a>") }}</p>
|
||||
<dl class="dl-config">
|
||||
<div class="relative col-span-3">
|
||||
<div class="absolute inset-0 flex items-center" aria-hidden="true">
|
||||
<div class="w-full border-t border-gray-300"></div>
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<!-- Read-only configuration -->
|
||||
<section class="bg-card h-fit rounded-lg border">
|
||||
<div class="border-b px-4 py-3">
|
||||
<p class="text-muted-foreground text-xs italic">{{ .locale.Tr "admin.config-link" (join "<a target=\"_blank\" class=\"text-primary hover:underline\" href=\"https://github.com/thomiceli/opengist/blob/master/docs/configuration/configure.md#configuration\">" (toStr (.locale.Tr "admin.config-link-overriden")) "</a>") }}</p>
|
||||
</div>
|
||||
<div class="relative flex justify-center">
|
||||
<span class="bg-gray-50 dark:bg-gray-800 px-2 text-sm text-slate-700 dark:text-slate-300 font-bold">General</span>
|
||||
<div class="space-y-4 p-4">
|
||||
{{ template "admin_config_section" (dict "title" "General") }}
|
||||
<dl class="divide-y text-sm">
|
||||
{{ template "admin_config_row" (dict "k" "Log level" "v" .c.LogLevel) }}
|
||||
{{ template "admin_config_row" (dict "k" "Log output" "v" .c.LogOutput) }}
|
||||
{{ template "admin_config_row" (dict "k" "External URL" "v" .c.ExternalUrl) }}
|
||||
{{ template "admin_config_row" (dict "k" "Opengist home" "v" .c.OpengistHome) }}
|
||||
<div class="flex items-center justify-between gap-4 py-1.5">
|
||||
<dt class="text-muted-foreground">Database type</dt>
|
||||
<dd class="text-right font-medium break-all">{{ .dbtype }}{{ if eq .dbtype "SQLite" }} ({{ .c.SqliteJournalMode }}){{ end }}</dd>
|
||||
</div>
|
||||
</div>
|
||||
<dt>Log level</dt><dd>{{ .c.LogLevel }}</dd>
|
||||
<dt>Log output</dt><dd>{{ .c.LogOutput }}</dd>
|
||||
<dt>External URL</dt><dd>{{ .c.ExternalUrl }}</dd>
|
||||
<dt>Opengist home</dt><dd>{{ .c.OpengistHome }}</dd>
|
||||
<dt>Database type</dt><dd>{{ .dbtype }}{{ if eq .dbtype "SQLite" }} ({{ .c.SqliteJournalMode }}){{ end }}</dd>
|
||||
<dt>Database name</dt><dd>{{ .dbname }}</dd>
|
||||
<dt>Index</dt><dd>{{ .c.Index }}</dd>
|
||||
<dt>MeiliSearch Host</dt><dd>{{ .c.MeiliHost }}</dd>
|
||||
<dt>MeiliSearch API Key</dt><dd>{{ if .c.MeiliAPIKey }}<defined>{{ end }}</dd>
|
||||
<dt>Git default branch</dt><dd>{{ .c.GitDefaultBranch }}</dd>
|
||||
<div class="relative col-span-3 mt-4">
|
||||
<div class="absolute inset-0 flex items-center" aria-hidden="true">
|
||||
<div class="w-full border-t border-gray-300"></div>
|
||||
</div>
|
||||
<div class="relative flex justify-center">
|
||||
<span class="bg-gray-50 dark:bg-gray-800 px-2 text-sm text-slate-700 dark:text-slate-300 font-bold">HTTP</span>
|
||||
</div>
|
||||
</div>
|
||||
<dt>HTTP host</dt><dd>{{ .c.HttpHost }}</dd>
|
||||
<dt>HTTP port</dt><dd>{{ .c.HttpPort }}</dd>
|
||||
<dt>HTTP Git enabled</dt><dd>{{ .c.HttpGit }}</dd>
|
||||
<div class="relative col-span-3 mt-4">
|
||||
<div class="absolute inset-0 flex items-center" aria-hidden="true">
|
||||
<div class="w-full border-t border-gray-300"></div>
|
||||
</div>
|
||||
<div class="relative flex justify-center">
|
||||
<span class="bg-gray-50 dark:bg-gray-800 px-2 text-sm text-slate-700 dark:text-slate-300 font-bold">SSH</span>
|
||||
</div>
|
||||
</div>
|
||||
<dt>SSH Git enabled</dt><dd>{{ .c.SshGit }}</dd>
|
||||
<dt>SSH host</dt><dd>{{ .c.SshHost }}</dd>
|
||||
<dt>SSH port</dt><dd>{{ .c.SshPort }}</dd>
|
||||
<dt>SSH external domain</dt><dd>{{ .c.SshExternalDomain }}</dd>
|
||||
<div class="relative col-span-3 mt-4">
|
||||
<div class="absolute inset-0 flex items-center" aria-hidden="true">
|
||||
<div class="w-full border-t border-gray-300"></div>
|
||||
</div>
|
||||
<div class="relative flex justify-center">
|
||||
<span class="bg-gray-50 dark:bg-gray-800 px-2 text-sm text-slate-700 dark:text-slate-300 font-bold">OAuth</span>
|
||||
</div>
|
||||
</div>
|
||||
<dt>Github Client key</dt><dd>{{ if .c.GithubClientKey }}<defined>{{ end }}</dd>
|
||||
<dt>Github Secret</dt><dd>{{ if .c.GithubSecret }}<defined>{{ end }}</dd>
|
||||
<dt>GitLab client Key</dt><dd>{{ if .c.GitlabClientKey }}<defined>{{ end }}</dd>
|
||||
<dt>GitLab Secret</dt><dd>{{ if .c.GitlabSecret }}<defined>{{ end }}</dd>
|
||||
<dt>GitLab URL</dt><dd>{{ .c.GitlabUrl }}</dd>
|
||||
<dt>GitLab Name</dt><dd>{{ .c.GitlabName }}</dd>
|
||||
<dt>Gitea client Key</dt><dd>{{ if .c.GiteaClientKey }}<defined>{{ end }}</dd>
|
||||
<dt>Gitea Secret</dt><dd>{{ if .c.GiteaSecret }}<defined>{{ end }}</dd>
|
||||
<dt>Gitea URL</dt><dd>{{ .c.GiteaUrl }}</dd>
|
||||
<dt>Gitea Name</dt><dd>{{ .c.GiteaName }}</dd>
|
||||
<dt>OIDC Provider name</dt><dd>{{ .c.OIDCProviderName }}</dd>
|
||||
<dt>OIDC client Key</dt><dd>{{ if .c.OIDCClientKey }}<defined>{{ end }}</dd>
|
||||
<dt>OIDC Secret</dt><dd>{{ if .c.OIDCSecret }}<defined>{{ end }}</dd>
|
||||
<dt>OIDC Discovery URL</dt><dd>{{ if .c.OIDCDiscoveryUrl }}<defined>{{ end }}</dd>
|
||||
<dt>OIDC Group Claim Name</dt><dd>{{ .c.OIDCGroupClaimName }}</dd>
|
||||
<dt>OIDC Admin Group</dt><dd>{{ .c.OIDCAdminGroup }}</dd>
|
||||
<div class="relative col-span-3 mt-4">
|
||||
<div class="absolute inset-0 flex items-center" aria-hidden="true">
|
||||
<div class="w-full border-t border-gray-300"></div>
|
||||
</div>
|
||||
<div class="relative flex justify-center">
|
||||
<span class="bg-gray-50 dark:bg-gray-800 px-2 text-sm text-slate-700 dark:text-slate-300 font-bold">LDAP</span>
|
||||
</div>
|
||||
</div>
|
||||
<dt>LDAP URL</dt><dd>{{ .c.LDAPUrl }}</dd>
|
||||
<dt>LDAP Bind DN</dt><dd>{{ .c.LDAPBindDn }}</dd>
|
||||
<dt>LDAP Bind Credentials</dt><dd>{{ if .c.LDAPBindCredentials }}<defined>{{ end }}</dd>
|
||||
<dt>LDAP Search Base</dt><dd>{{ .c.LDAPSearchBase }}</dd>
|
||||
<dt>LDAP Search Filter</dt><dd>{{ .c.LDAPSearchFilter }}</dd>
|
||||
{{ template "admin_config_row" (dict "k" "Database name" "v" .dbname) }}
|
||||
{{ template "admin_config_row" (dict "k" "Index" "v" .c.Index) }}
|
||||
{{ template "admin_config_row" (dict "k" "MeiliSearch Host" "v" .c.MeiliHost) }}
|
||||
{{ template "admin_config_defined" (dict "k" "MeiliSearch API Key" "v" .c.MeiliAPIKey) }}
|
||||
{{ template "admin_config_row" (dict "k" "Git default branch" "v" .c.GitDefaultBranch) }}
|
||||
</dl>
|
||||
|
||||
{{ template "admin_config_section" (dict "title" "HTTP") }}
|
||||
<dl class="divide-y text-sm">
|
||||
{{ template "admin_config_row" (dict "k" "HTTP host" "v" .c.HttpHost) }}
|
||||
{{ template "admin_config_row" (dict "k" "HTTP port" "v" .c.HttpPort) }}
|
||||
{{ template "admin_config_row" (dict "k" "HTTP Git enabled" "v" .c.HttpGit) }}
|
||||
</dl>
|
||||
|
||||
{{ template "admin_config_section" (dict "title" "SSH") }}
|
||||
<dl class="divide-y text-sm">
|
||||
{{ template "admin_config_row" (dict "k" "SSH Git enabled" "v" .c.SshGit) }}
|
||||
{{ template "admin_config_row" (dict "k" "SSH host" "v" .c.SshHost) }}
|
||||
{{ template "admin_config_row" (dict "k" "SSH port" "v" .c.SshPort) }}
|
||||
{{ template "admin_config_row" (dict "k" "SSH external domain" "v" .c.SshExternalDomain) }}
|
||||
</dl>
|
||||
|
||||
{{ template "admin_config_section" (dict "title" "OAuth") }}
|
||||
<dl class="divide-y text-sm">
|
||||
{{ template "admin_config_defined" (dict "k" "Github Client key" "v" .c.GithubClientKey) }}
|
||||
{{ template "admin_config_defined" (dict "k" "Github Secret" "v" .c.GithubSecret) }}
|
||||
{{ template "admin_config_defined" (dict "k" "GitLab client Key" "v" .c.GitlabClientKey) }}
|
||||
{{ template "admin_config_defined" (dict "k" "GitLab Secret" "v" .c.GitlabSecret) }}
|
||||
{{ template "admin_config_row" (dict "k" "GitLab URL" "v" .c.GitlabUrl) }}
|
||||
{{ template "admin_config_row" (dict "k" "GitLab Name" "v" .c.GitlabName) }}
|
||||
{{ template "admin_config_defined" (dict "k" "Gitea client Key" "v" .c.GiteaClientKey) }}
|
||||
{{ template "admin_config_defined" (dict "k" "Gitea Secret" "v" .c.GiteaSecret) }}
|
||||
{{ template "admin_config_row" (dict "k" "Gitea URL" "v" .c.GiteaUrl) }}
|
||||
{{ template "admin_config_row" (dict "k" "Gitea Name" "v" .c.GiteaName) }}
|
||||
{{ template "admin_config_row" (dict "k" "OIDC Provider name" "v" .c.OIDCProviderName) }}
|
||||
{{ template "admin_config_defined" (dict "k" "OIDC client Key" "v" .c.OIDCClientKey) }}
|
||||
{{ template "admin_config_defined" (dict "k" "OIDC Secret" "v" .c.OIDCSecret) }}
|
||||
{{ template "admin_config_defined" (dict "k" "OIDC Discovery URL" "v" .c.OIDCDiscoveryUrl) }}
|
||||
{{ template "admin_config_row" (dict "k" "OIDC Group Claim Name" "v" .c.OIDCGroupClaimName) }}
|
||||
{{ template "admin_config_row" (dict "k" "OIDC Admin Group" "v" .c.OIDCAdminGroup) }}
|
||||
</dl>
|
||||
|
||||
{{ template "admin_config_section" (dict "title" "LDAP") }}
|
||||
<dl class="divide-y text-sm">
|
||||
{{ template "admin_config_row" (dict "k" "LDAP URL" "v" .c.LDAPUrl) }}
|
||||
{{ template "admin_config_row" (dict "k" "LDAP Bind DN" "v" .c.LDAPBindDn) }}
|
||||
{{ template "admin_config_defined" (dict "k" "LDAP Bind Credentials" "v" .c.LDAPBindCredentials) }}
|
||||
{{ template "admin_config_row" (dict "k" "LDAP Search Base" "v" .c.LDAPSearchBase) }}
|
||||
{{ template "admin_config_row" (dict "k" "LDAP Search Filter" "v" .c.LDAPSearchFilter) }}
|
||||
</dl>
|
||||
</div>
|
||||
<div>
|
||||
<ul role="list" class="divide-y divide-slate-300 dark:divide-gray-200 px-4 py-2 sm:px-6 bg-gray-50 dark:bg-gray-800 rounded-md border border-gray-200 dark:border-gray-700">
|
||||
<li class="list-none gap-x-4 py-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="flex grow flex-col">
|
||||
<span class="text-sm font-medium leading-6 text-slate-700 dark:text-slate-300">{{ .locale.Tr "admin.disable-signup" }}</span>
|
||||
<span class="text-sm text-gray-400 dark:text-gray-400">{{ .locale.Tr "admin.disable-signup_help" }}</span>
|
||||
</span>
|
||||
<button type="button" id="disable-signup" data-bool="{{ .DisableSignup }}" class="toggle-button {{ if .DisableSignup }}bg-primary-600{{else}}bg-gray-300 dark:bg-gray-400{{end}} relative inline-flex h-6 w-11 ml-4 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-600 focus:ring-offset-2" role="switch" aria-checked="false" aria-labelledby="availability-label" aria-describedby="availability-description">
|
||||
<span aria-hidden="true" class="{{ if .DisableSignup }}translate-x-5{{else}}translate-x-0{{end}} pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"></span>
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
<li class="list-none gap-x-4 py-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="flex grow flex-col">
|
||||
<span class="text-sm font-medium leading-6 text-slate-700 dark:text-slate-300">{{ .locale.Tr "admin.require-login" }}</span>
|
||||
<span class="text-sm text-gray-400 dark:text-gray-400">{{ .locale.Tr "admin.require-login_help" }}</span>
|
||||
</span>
|
||||
<button type="button" id="require-login" data-bool="{{ .RequireLogin }}" class="toggle-button {{ if .RequireLogin }}bg-primary-600{{else}}bg-gray-300 dark:bg-gray-400{{end}} relative inline-flex h-6 w-11 ml-4 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-600 focus:ring-offset-2" role="switch" aria-checked="false" aria-labelledby="availability-label" aria-describedby="availability-description">
|
||||
<span aria-hidden="true" class="{{ if .RequireLogin }}translate-x-5{{else}}translate-x-0{{end}} pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"></span>
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
<li class="list-none gap-x-4 py-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="flex grow flex-col">
|
||||
<span class="text-sm font-medium leading-6 text-slate-700 dark:text-slate-300">{{ .locale.Tr "admin.allow-gists-without-login" }}</span>
|
||||
<span class="text-sm text-gray-400 dark:text-gray-400">{{ .locale.Tr "admin.allow-gists-without-login_help" }}</span>
|
||||
</span>
|
||||
<button type="button" id="allow-gists-without-login" data-bool="{{ .AllowGistsWithoutLogin }}" class="toggle-button {{ if .AllowGistsWithoutLogin }}bg-primary-600{{else}}bg-gray-300 dark:bg-gray-400{{end}} relative inline-flex h-6 w-11 ml-4 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-600 focus:ring-offset-2" role="switch" aria-checked="false" aria-labelledby="availability-label" aria-describedby="availability-description">
|
||||
<span aria-hidden="true" class="{{ if .AllowGistsWithoutLogin }}translate-x-5{{else}}translate-x-0{{end}} pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"></span>
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
<li class="list-none gap-x-4 py-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="flex grow flex-col">
|
||||
<span class="text-sm font-medium leading-6 text-slate-700 dark:text-slate-300">{{ .locale.Tr "admin.disable-login" }}</span>
|
||||
<span class="text-sm text-gray-400 dark:text-gray-400">{{ .locale.Tr "admin.disable-login_help" }}</span>
|
||||
</span>
|
||||
<button type="button" id="disable-login-form" data-bool="{{ .DisableLoginForm }}" class="toggle-button {{ if .DisableLoginForm }}bg-primary-600{{else}}bg-gray-300 dark:bg-gray-400{{end}} relative inline-flex h-6 w-11 ml-4 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-600 focus:ring-offset-2" role="switch" aria-checked="false" aria-labelledby="availability-label" aria-describedby="availability-description">
|
||||
<span aria-hidden="true" class="{{ if .DisableLoginForm }}translate-x-5{{else}}translate-x-0{{end}} pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"></span>
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
<li class="list-none gap-x-4 py-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="flex grow flex-col">
|
||||
<span class="text-sm font-medium leading-6 text-slate-700 dark:text-slate-300">{{ .locale.Tr "admin.disable-gravatar" }}</span>
|
||||
<span class="text-sm text-gray-400 dark:text-gray-400">{{ .locale.Tr "admin.disable-gravatar_help" }}</span>
|
||||
</span>
|
||||
<button type="button" id="disable-gravatar" data-bool="{{ .DisableGravatar }}" class="toggle-button {{ if .DisableGravatar }}bg-primary-600{{else}}bg-gray-300 dark:bg-gray-400{{end}} relative inline-flex h-6 w-11 ml-4 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-600 focus:ring-offset-2" role="switch" aria-checked="false" aria-labelledby="availability-label" aria-describedby="availability-description">
|
||||
<span aria-hidden="true" class="{{ if .DisableGravatar }}translate-x-5{{else}}translate-x-0{{end}} pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"></span>
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
{{ .csrfHtml }}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{ template "admin_footer" .}}
|
||||
{{ template "footer" .}}
|
||||
<!-- Toggleable settings -->
|
||||
<section class="bg-card h-fit rounded-lg border">
|
||||
{{ .csrfHtml }}
|
||||
<ul class="divide-y">
|
||||
{{ template "admin_config_toggle" (dict "url" .c.ExternalUrl "id" "disable-signup" "title" (.locale.Tr "admin.disable-signup") "help" (.locale.Tr "admin.disable-signup_help") "on" .DisableSignup) }}
|
||||
{{ template "admin_config_toggle" (dict "url" .c.ExternalUrl "id" "require-login" "title" (.locale.Tr "admin.require-login") "help" (.locale.Tr "admin.require-login_help") "on" .RequireLogin) }}
|
||||
{{ template "admin_config_toggle" (dict "url" .c.ExternalUrl "id" "allow-gists-without-login" "title" (.locale.Tr "admin.allow-gists-without-login") "help" (.locale.Tr "admin.allow-gists-without-login_help") "on" .AllowGistsWithoutLogin) }}
|
||||
{{ template "admin_config_toggle" (dict "url" .c.ExternalUrl "id" "disable-login-form" "title" (.locale.Tr "admin.disable-login") "help" (.locale.Tr "admin.disable-login_help") "on" .DisableLoginForm) }}
|
||||
{{ template "admin_config_toggle" (dict "url" .c.ExternalUrl "id" "disable-gravatar" "title" (.locale.Tr "admin.disable-gravatar") "help" (.locale.Tr "admin.disable-gravatar_help") "on" .DisableGravatar) }}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
{{ define "admin_config_section" }}
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm font-bold">{{ .title }}</span>
|
||||
<span class="border-border flex-1 border-t" aria-hidden="true"></span>
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
{{ define "admin_config_row" }}
|
||||
<div class="flex items-center justify-between gap-4 py-1.5">
|
||||
<dt class="text-muted-foreground">{{ .k }}</dt>
|
||||
<dd class="text-right font-medium break-all">{{ .v }}</dd>
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
{{ define "admin_config_defined" }}
|
||||
<div class="flex items-center justify-between gap-4 py-1.5">
|
||||
<dt class="text-muted-foreground">{{ .k }}</dt>
|
||||
<dd class="text-right font-medium">{{ if .v }}<defined>{{ end }}</dd>
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
{{ define "admin_config_toggle" }}
|
||||
<li class="flex items-center justify-between gap-4 p-4">
|
||||
<div class="min-w-0 space-y-1">
|
||||
<p class="text-sm font-medium">{{ .title }}</p>
|
||||
<p class="text-muted-foreground text-sm">{{ .help }}</p>
|
||||
</div>
|
||||
<button type="button" id="{{ .id }}" role="switch" aria-checked="{{ .on }}"
|
||||
hx-put="{{ .url }}/-/admin-panel/set-config"
|
||||
hx-swap="none"
|
||||
hx-include="[name='_csrf']"
|
||||
hx-vals='js:{key: this.id, value: this.getAttribute("aria-checked") === "true" ? "0" : "1"}'
|
||||
_="on htmx:afterRequest if event.detail.successful
|
||||
if @aria-checked is 'true' set @aria-checked to 'false' else set @aria-checked to 'true' end
|
||||
end"
|
||||
class="group bg-input aria-checked:bg-primary relative inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">
|
||||
<span class="pointer-events-none inline-block size-5 translate-x-0.5 rounded-full bg-white shadow transition-transform group-aria-checked:translate-x-[1.375rem]"></span>
|
||||
</button>
|
||||
</li>
|
||||
{{ end }}
|
||||
|
||||
Vendored
+35
-29
@@ -1,36 +1,40 @@
|
||||
{{ template "header" .}}
|
||||
{{ template "admin_header" .}}
|
||||
{{ define "content" }}
|
||||
<div class="space-y-6">
|
||||
<header class="space-y-1">
|
||||
<h1 class="text-2xl font-bold leading-tight">{{ .locale.Tr "admin.admin_panel" }}</h1>
|
||||
<p class="text-muted-foreground text-sm">{{ .locale.Tr "admin.gists" }}</p>
|
||||
</header>
|
||||
|
||||
<div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8 bg-gray-50 dark:bg-gray-800 rounded-md border border-gray-200 dark:border-gray-700">
|
||||
<table class="min-w-full divide-y divide-slate-300 dark:divide-gray-500">
|
||||
<thead>
|
||||
<section class="bg-card overflow-hidden rounded-lg border">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="text-muted-foreground border-b text-left">
|
||||
<tr>
|
||||
<th scope="col" class="whitespace-nowrap py-3.5 pl-4 pr-3 text-left text-sm font-bold text-slate-700 dark:text-slate-300 sm:pl-0">{{ .locale.Tr "admin.id" }}</th>
|
||||
<th scope="col" class="whitespace-nowrap px-2 py-3.5 text-left text-sm font-semibold text-slate-700 dark:text-slate-300">{{ .locale.Tr "admin.gists.title" }}</th>
|
||||
<th scope="col" class="whitespace-nowrap px-2 py-3.5 text-left text-sm font-semibold text-slate-700 dark:text-slate-300">{{ .locale.Tr "admin.user" }}</th>
|
||||
<th scope="col" class="whitespace-nowrap px-2 py-3.5 text-left text-sm font-semibold text-slate-700 dark:text-slate-300">{{ .locale.Tr "admin.gists.private" }}</th>
|
||||
<th scope="col" class="whitespace-nowrap px-2 py-3.5 text-left text-sm font-semibold text-slate-700 dark:text-slate-300">{{ .locale.Tr "admin.gists.nb-files" }}</th>
|
||||
<th scope="col" class="whitespace-nowrap px-2 py-3.5 text-left text-sm font-semibold text-slate-700 dark:text-slate-300">{{ .locale.Tr "admin.gists.nb-likes" }}</th>
|
||||
<th scope="col" class="whitespace-nowrap px-2 py-3.5 text-left text-sm font-semibold text-slate-700 dark:text-slate-300">{{ .locale.Tr "admin.created_at" }}</th>
|
||||
<th scope="col" class="relative whitespace-nowrap py-3.5 pl-3 pr-4 sm:pr-0">
|
||||
<span class="sr-only">{{ .locale.Tr "admin.delete" }}</span>
|
||||
</th>
|
||||
<th scope="col" class="px-4 py-3 font-medium">{{ .locale.Tr "admin.id" }}</th>
|
||||
<th scope="col" class="px-4 py-3 font-medium">{{ .locale.Tr "admin.gists.title" }}</th>
|
||||
<th scope="col" class="px-4 py-3 font-medium">{{ .locale.Tr "admin.user" }}</th>
|
||||
<th scope="col" class="px-4 py-3 font-medium">{{ .locale.Tr "admin.gists.private" }}</th>
|
||||
<th scope="col" class="px-4 py-3 font-medium">{{ .locale.Tr "admin.gists.nb-files" }}</th>
|
||||
<th scope="col" class="px-4 py-3 font-medium">{{ .locale.Tr "admin.gists.nb-likes" }}</th>
|
||||
<th scope="col" class="px-4 py-3 font-medium">{{ .locale.Tr "admin.created_at" }}</th>
|
||||
<th scope="col" class="px-4 py-3"><span class="sr-only">{{ .locale.Tr "admin.delete" }}</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-300 dark:divide-gray-500">
|
||||
<tbody class="divide-y">
|
||||
{{ range $gist := .data }}
|
||||
<tr>
|
||||
<td class="whitespace-nowrap py-2 pl-4 pr-3 text-sm text-slate-700 dark:text-slate-300 sm:pl-0">{{ $gist.ID }}</td>
|
||||
<td class="whitespace-nowrap px-2 py-2 text-sm text-slate-700 dark:text-slate-300"><a href="{{ $.c.ExternalUrl }}/{{ $gist.User.Username }}/{{ $gist.Identifier }}">{{ $gist.Title }}</a></td>
|
||||
<td class="whitespace-nowrap px-2 py-2 text-sm text-slate-700 dark:text-slate-300"><a href="{{ $.c.ExternalUrl }}/{{ $gist.User.Username }}">{{ $gist.User.Username }}</a></td>
|
||||
<td class="whitespace-nowrap px-2 py-2 text-sm text-slate-700 dark:text-slate-300">{{ $gist.Private }}</td>
|
||||
<td class="whitespace-nowrap px-2 py-2 text-sm text-slate-700 dark:text-slate-300">{{ $gist.NbFiles }}</td>
|
||||
<td class="whitespace-nowrap px-2 py-2 text-sm text-slate-700 dark:text-slate-300">{{ $gist.NbLikes }}</td>
|
||||
<td class="whitespace-nowrap px-2 py-2 text-sm text-slate-700 dark:text-slate-300"><span>{{ $gist.CreatedAt | humanDate }}</span></td>
|
||||
<td class="relative whitespace-nowrap py-2 pl-3 pr-4 text-right text-sm font-medium sm:pr-0">
|
||||
<form action="{{ $.c.ExternalUrl }}/admin-panel/gists/{{ $gist.ID }}/delete" method="POST">
|
||||
<tr class="hover:bg-muted/40">
|
||||
<td class="px-4 py-2">{{ $gist.ID }}</td>
|
||||
<td class="px-4 py-2"><a href="{{ $.c.ExternalUrl }}/{{ $gist.User.Username }}/{{ $gist.Identifier }}" class="text-primary hover:underline">{{ $gist.Title }}</a></td>
|
||||
<td class="px-4 py-2"><a href="{{ $.c.ExternalUrl }}/{{ $gist.User.Username }}" class="text-primary hover:underline">{{ $gist.User.Username }}</a></td>
|
||||
<td class="text-muted-foreground px-4 py-2">{{ $gist.Private }}</td>
|
||||
<td class="text-muted-foreground px-4 py-2">{{ $gist.NbFiles }}</td>
|
||||
<td class="text-muted-foreground px-4 py-2">{{ $gist.NbLikes }}</td>
|
||||
<td class="text-muted-foreground px-4 py-2">{{ $gist.CreatedAt | humanDate }}</td>
|
||||
<td class="px-4 py-2 text-right">
|
||||
<form action="{{ $.c.ExternalUrl }}/-/admin-panel/gists/{{ $gist.ID }}/delete" method="POST">
|
||||
{{ $.csrfHtml }}
|
||||
<button type="submit" onclick="return confirm('{{ $.locale.Tr "admin.gists.delete_confirm" }}')" class="text-rose-500 hover:text-rose-600">{{ $.locale.Tr "admin.delete" }}</button>
|
||||
<button type="submit" class="text-destructive text-sm font-medium hover:underline"
|
||||
_="on click if not window.confirm('{{ $.locale.Tr "admin.gists.delete_confirm" }}') halt the event">{{ $.locale.Tr "admin.delete" }}</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -38,6 +42,8 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{ template "admin_footer" .}}
|
||||
{{ template "footer" .}}
|
||||
{{ template "pagination" . }}
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
Vendored
+43
-112
@@ -1,121 +1,52 @@
|
||||
{{ template "header" .}}
|
||||
{{ template "admin_header" .}}
|
||||
{{ define "content" }}
|
||||
<div class="space-y-6">
|
||||
<header class="space-y-1">
|
||||
<h1 class="text-2xl font-bold leading-tight">{{ .locale.Tr "admin.admin_panel" }}</h1>
|
||||
<p class="text-muted-foreground text-sm">{{ .locale.Tr "admin.general" }}</p>
|
||||
</header>
|
||||
|
||||
<div class="sm:flex sm:space-x-4 space-y-4 sm:space-y-0">
|
||||
<div class="sm:overflow-hidden ">
|
||||
<div class="space-y-2 bg-gray-50 dark:bg-gray-800 py-6 px-6 rounded-md border border-gray-200 dark:border-gray-700">
|
||||
<div>
|
||||
<span class="text-base font-bold leading-6 text-slate-700 dark:text-slate-300">{{ .locale.Tr "admin.versions" }}</span>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<!-- Versions -->
|
||||
<section class="bg-card rounded-lg border">
|
||||
<div class="border-b px-4 py-3">
|
||||
<h2 class="font-semibold">{{ .locale.Tr "admin.versions" }}</h2>
|
||||
</div>
|
||||
<table class="table-fixed">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="whitespace-nowrap py-2 pr-3 text-sm text-slate-700 dark:text-slate-300 ">Opengist</td>
|
||||
<td class="whitespace-nowrap px-2 py-2 text-sm font-medium text-slate-700 dark:text-slate-300">{{ .opengistVersion }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="whitespace-nowrap py-2 pr-3 text-sm text-slate-700 dark:text-slate-300 ">Go</td>
|
||||
<td class="whitespace-nowrap px-2 py-2 text-sm font-medium text-slate-700 dark:text-slate-300">{{ .goVersion }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="whitespace-nowrap py-2 pr-3 text-sm text-slate-700 dark:text-slate-300 ">Git</td>
|
||||
<td class="whitespace-nowrap px-2 py-2 text-sm font-medium text-slate-700 dark:text-slate-300">{{ .gitVersion }} </td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<dl class="divide-y text-sm">
|
||||
<div class="flex items-center justify-between px-4 py-2">
|
||||
<dt class="text-muted-foreground">Opengist</dt>
|
||||
<dd class="font-medium">{{ .opengistVersion }}</dd>
|
||||
</div>
|
||||
<div class="flex items-center justify-between px-4 py-2">
|
||||
<dt class="text-muted-foreground">Go</dt>
|
||||
<dd class="font-medium">{{ .goVersion }}</dd>
|
||||
</div>
|
||||
<div class="flex items-center justify-between px-4 py-2">
|
||||
<dt class="text-muted-foreground">Git</dt>
|
||||
<dd class="font-medium">{{ .gitVersion }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<div class="sm:overflow-hidden ">
|
||||
<div class="space-y-2 bg-gray-50 dark:bg-gray-800 py-6 px-6 rounded-md border border-gray-200 dark:border-gray-700">
|
||||
<div>
|
||||
<span class="text-base font-bold leading-6 text-slate-700 dark:text-slate-300">{{ .locale.Tr "admin.stats" }}</span>
|
||||
<!-- Stats -->
|
||||
<section class="bg-card rounded-lg border">
|
||||
<div class="border-b px-4 py-3">
|
||||
<h2 class="font-semibold">{{ .locale.Tr "admin.stats" }}</h2>
|
||||
</div>
|
||||
<table class="table-fixed">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="whitespace-nowrap py-2 pr-3 text-sm text-slate-700 dark:text-slate-300 ">{{ .locale.Tr "admin.users" }}</td>
|
||||
<td class="whitespace-nowrap px-2 py-2 text-sm font-medium text-slate-700 dark:text-slate-300">{{ .countUsers }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="whitespace-nowrap py-2 pr-3 text-sm text-slate-700 dark:text-slate-300 ">{{ .locale.Tr "admin.gists" }}</td>
|
||||
<td class="whitespace-nowrap px-2 py-2 text-sm font-medium text-slate-700 dark:text-slate-300">{{ .countGists }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="whitespace-nowrap py-2 pr-3 text-sm text-slate-700 dark:text-slate-300 ">{{ .locale.Tr "admin.ssh_keys" }}</td>
|
||||
<td class="whitespace-nowrap px-2 py-2 text-sm font-medium text-slate-700 dark:text-slate-300">{{ .countKeys }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<dl class="divide-y text-sm">
|
||||
<div class="flex items-center justify-between px-4 py-2">
|
||||
<dt class="text-muted-foreground">{{ .locale.Tr "admin.users" }}</dt>
|
||||
<dd class="font-medium">{{ .countUsers }}</dd>
|
||||
</div>
|
||||
<div class="flex items-center justify-between px-4 py-2">
|
||||
<dt class="text-muted-foreground">{{ .locale.Tr "admin.gists" }}</dt>
|
||||
<dd class="font-medium">{{ .countGists }}</dd>
|
||||
</div>
|
||||
<div class="flex items-center justify-between px-4 py-2">
|
||||
<dt class="text-muted-foreground">{{ .locale.Tr "admin.ssh_keys" }}</dt>
|
||||
<dd class="font-medium">{{ .countKeys }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sm:overflow-hidden ">
|
||||
<div class="space-y-2 bg-gray-50 dark:bg-gray-800 py-6 px-6 rounded-md border border-gray-200 dark:border-gray-700">
|
||||
<div>
|
||||
<span class="text-base font-bold leading-6 text-slate-700 dark:text-slate-300">{{ .locale.Tr "admin.actions" }}</span>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<form action="{{ $.c.ExternalUrl }}/admin-panel/sync-fs" method="POST">
|
||||
{{ .csrfHtml }}
|
||||
<button type="submit" {{ if .syncReposFromFS }}disabled="disabled"{{ end }} class="whitespace-nowrap text-slate-700 dark:text-slate-300{{ if .syncReposFromFS }} 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-fs" }}
|
||||
</button>
|
||||
</form>
|
||||
<form action="{{ $.c.ExternalUrl }}/admin-panel/sync-db" method="POST">
|
||||
{{ .csrfHtml }}
|
||||
<button type="submit" {{ if .syncReposFromDB }}disabled="disabled"{{ end }} class="whitespace-nowrap text-slate-700 dark:text-slate-300{{ if .syncReposFromDB }} 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-db" }}
|
||||
</button>
|
||||
</form>
|
||||
<form action="{{ $.c.ExternalUrl }}/admin-panel/gc-repos" method="POST">
|
||||
{{ .csrfHtml }}
|
||||
<button type="submit" {{ if .gitGcRepos }}disabled="disabled"{{ end }} class="whitespace-nowrap text-slate-700 dark:text-slate-300{{ if .gitGcRepos }} 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.git-gc" }}
|
||||
</button>
|
||||
</form>
|
||||
<form action="{{ $.c.ExternalUrl }}/admin-panel/sync-previews" method="POST">
|
||||
{{ .csrfHtml }}
|
||||
<button type="submit" {{ if .syncGistPreviews }}disabled="disabled"{{ end }} class="whitespace-nowrap text-slate-700 dark:text-slate-300{{ if .syncGistPreviews }} 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-previews" }}
|
||||
</button>
|
||||
</form>
|
||||
<form action="{{ $.c.ExternalUrl }}/admin-panel/reset-hooks" method="POST">
|
||||
{{ .csrfHtml }}
|
||||
<button type="submit" {{ if .resetHooks }}disabled="disabled"{{ end }} class="whitespace-nowrap text-slate-700 dark:text-slate-300{{ if .resetHooks }} 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.reset-hooks" }}
|
||||
</button>
|
||||
</form>
|
||||
<form action="{{ $.c.ExternalUrl }}/admin-panel/index-gists" method="POST">
|
||||
{{ .csrfHtml }}
|
||||
<button type="submit" {{ if .indexGists }}disabled="disabled"{{ end }} class="whitespace-nowrap text-slate-700 dark:text-slate-300{{ if .indexGists }} 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.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>
|
||||
<form action="{{ $.c.ExternalUrl }}/admin-panel/delete-expired-gists" method="POST">
|
||||
{{ .csrfHtml }}
|
||||
<button type="submit" {{ if .deleteExpiredGists }}disabled="disabled"{{ end }} class="whitespace-nowrap text-slate-700 dark:text-slate-300{{ if .deleteExpiredGists }} 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.delete-expired-gists" }}
|
||||
</button>
|
||||
</form>
|
||||
{{ if .sshManagesAuthorizedKeys }}
|
||||
<form action="{{ $.c.ExternalUrl }}/admin-panel/sync-ssh-keys" method="POST">
|
||||
{{ .csrfHtml }}
|
||||
<button type="submit" {{ if .syncSSHKeys }}disabled="disabled"{{ end }} class="whitespace-nowrap text-slate-700 dark:text-slate-300{{ if .syncSSHKeys }} 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-ssh-keys" }}
|
||||
</button>
|
||||
</form>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{ template "admin_footer" .}}
|
||||
{{ template "footer" .}}
|
||||
|
||||
+54
-47
@@ -1,61 +1,68 @@
|
||||
{{ template "header" .}}
|
||||
{{ template "admin_header" .}}
|
||||
{{ define "content" }}
|
||||
<div class="space-y-6">
|
||||
<header class="space-y-1">
|
||||
<h1 class="text-2xl font-bold leading-tight">{{ .locale.Tr "admin.admin_panel" }}</h1>
|
||||
<p class="text-muted-foreground text-sm">{{ .locale.Tr "admin.invitations" }}</p>
|
||||
</header>
|
||||
|
||||
<h3 class="text-sm text-gray-600 dark:text-gray-400 italic mb-4">
|
||||
{{ .locale.Tr "admin.invitations.help" }}
|
||||
</h3>
|
||||
|
||||
<form method="POST">
|
||||
<div class="flex space-x-4">
|
||||
<div class="flex-1">
|
||||
<label for="nbMax" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">{{ .locale.Tr "admin.invitations.max_uses" }}</label>
|
||||
<input type="number" id="nbMax" name="nbMax" value="10" min="1" max="100" class="dark:bg-gray-800 appearance-none block w-full px-3 py-2 border border-gray-200 dark:border-gray-700 rounded-md shadow-sm placeholder-gray-600 dark:placeholder-gray-400 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm">
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<label for="expiresAt" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">{{ .locale.Tr "admin.invitations.expires_at" }}</label>
|
||||
<input type="datetime-local" id="expiresAt" name="expiresAt" class="dark:bg-gray-800 appearance-none block w-full px-3 py-2 border border-gray-200 dark:border-gray-700 rounded-md shadow-sm placeholder-gray-600 dark:placeholder-gray-400 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<button type="submit" class="inline-flex items-center px-4 py-2 border border-transparent border-gray-200 dark:border-gray-700 text-sm 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 "admin.invitations.create" }}</button>
|
||||
<!-- Create invitation -->
|
||||
<section class="bg-card rounded-lg border">
|
||||
<div class="border-b px-4 py-3">
|
||||
<p class="text-muted-foreground text-sm italic">{{ .locale.Tr "admin.invitations.help" }}</p>
|
||||
</div>
|
||||
<form method="POST" class="space-y-4 p-4">
|
||||
{{ .csrfHtml }}
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<div class="flex-1 space-y-1.5">
|
||||
<label for="nbMax" class="text-sm font-medium">{{ .locale.Tr "admin.invitations.max_uses" }}</label>
|
||||
<input type="number" id="nbMax" name="nbMax" value="10" min="1" max="100" class="input h-9 w-full" />
|
||||
</div>
|
||||
<div class="flex-1 space-y-1.5">
|
||||
<label for="expiresAt" class="text-sm font-medium">{{ .locale.Tr "admin.invitations.expires_at" }}</label>
|
||||
<input type="datetime-local" id="expiresAt" name="expiresAt" class="input h-9 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<button type="submit" class="btn-sm">{{ .locale.Tr "admin.invitations.create" }}</button>
|
||||
</div>
|
||||
</form>
|
||||
<hr class="my-4" />
|
||||
<div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8 bg-gray-50 dark:bg-gray-800 rounded-md border border-gray-200 dark:border-gray-700">
|
||||
<table class="min-w-full divide-y divide-slate-300 dark:divide-gray-500">
|
||||
<thead>
|
||||
</section>
|
||||
|
||||
<!-- Invitation list -->
|
||||
<section class="bg-card overflow-hidden rounded-lg border">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="text-muted-foreground border-b text-left">
|
||||
<tr>
|
||||
<th scope="col" class="whitespace-nowrap px-2 py-3.5 text-left text-sm font-semibold text-slate-700 dark:text-slate-300">{{ .locale.Tr "admin.invitations.code" }}</th>
|
||||
<th scope="col" class="whitespace-nowrap px-2 py-3.5 text-left text-sm font-semibold text-slate-700 dark:text-slate-300">{{ .locale.Tr "admin.invitations.copy_link" }}</th>
|
||||
<th scope="col" class="whitespace-nowrap px-2 py-3.5 text-left text-sm font-semibold text-slate-700 dark:text-slate-300">{{ .locale.Tr "admin.invitations.uses" }}</th>
|
||||
<th scope="col" class="whitespace-nowrap px-2 py-3.5 text-left text-sm font-semibold text-slate-700 dark:text-slate-300">{{ .locale.Tr "admin.invitations.expires_at" }}</th>
|
||||
<th scope="col" class="relative whitespace-nowrap py-3.5 pl-3 pr-4 sm:pr-0">
|
||||
<span class="sr-only">{{ .locale.Tr "admin.delete" }}</span>
|
||||
</th>
|
||||
<th scope="col" class="px-4 py-3 font-medium">{{ .locale.Tr "admin.invitations.code" }}</th>
|
||||
<th scope="col" class="px-4 py-3 font-medium">{{ .locale.Tr "admin.invitations.copy_link" }}</th>
|
||||
<th scope="col" class="px-4 py-3 font-medium">{{ .locale.Tr "admin.invitations.uses" }}</th>
|
||||
<th scope="col" class="px-4 py-3 font-medium">{{ .locale.Tr "admin.invitations.expires_at" }}</th>
|
||||
<th scope="col" class="px-4 py-3"><span class="sr-only">{{ .locale.Tr "admin.delete" }}</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-300 dark:divide-gray-500">
|
||||
<tbody class="divide-y">
|
||||
{{ range $invitation := .invitations }}
|
||||
<tr class="{{ if $invitation.IsUsable }}text-slate-700 dark:text-slate-100{{ else }}text-gray-300 italic{{ end }}">
|
||||
<td class="whitespace-nowrap py-2 px-2 text-sm">{{ $invitation.Code }}</td>
|
||||
<td class="whitespace-nowrap py-2 px-2 text-sm items-center">
|
||||
<tr class="hover:bg-muted/40 {{ if not $invitation.IsUsable }}text-muted-foreground italic{{ end }}">
|
||||
<td class="px-4 py-2 font-mono">{{ $invitation.Code }}</td>
|
||||
<td class="px-4 py-2">
|
||||
{{ if $invitation.IsUsable }}
|
||||
<span class="copy-invitation-link" data-link="{{ $.baseHttpUrl }}/register?code={{ $invitation.Code }}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6 cursor-pointer">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 7.5V6.108c0-1.135.845-2.098 1.976-2.192.373-.03.748-.057 1.123-.08M15.75 18H18a2.25 2.25 0 0 0 2.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 0 0-1.123-.08M15.75 18.75v-1.875a3.375 3.375 0 0 0-3.375-3.375h-1.5a1.125 1.125 0 0 1-1.125-1.125v-1.5A3.375 3.375 0 0 0 6.375 7.5H5.25m11.9-3.664A2.251 2.251 0 0 0 15 2.25h-1.5a2.251 2.251 0 0 0-2.15 1.586m5.8 0c.065.21.1.433.1.664v.75h-6V4.5c0-.231.035-.454.1-.664M6.75 7.5H4.875c-.621 0-1.125.504-1.125 1.125v12c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V16.5a9 9 0 0 0-9-9Z" />
|
||||
</svg>
|
||||
</span>
|
||||
<button type="button" class="text-muted-foreground hover:text-foreground cursor-pointer" title="{{ $.locale.Tr "admin.invitations.copy_link" }}"
|
||||
_="on click call navigator.clipboard.writeText('{{ $.baseHttpUrl }}/-/register?code={{ $invitation.Code }}')
|
||||
then add .text-primary to me then wait 1s then remove .text-primary from me">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5"><path stroke-linecap="round" stroke-linejoin="round" d="M8.25 7.5V6.108c0-1.135.845-2.098 1.976-2.192.373-.03.748-.057 1.123-.08M15.75 18H18a2.25 2.25 0 0 0 2.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 0 0-1.123-.08M15.75 18.75v-1.875a3.375 3.375 0 0 0-3.375-3.375h-1.5a1.125 1.125 0 0 1-1.125-1.125v-1.5A3.375 3.375 0 0 0 6.375 7.5H5.25m11.9-3.664A2.251 2.251 0 0 0 15 2.25h-1.5a2.251 2.251 0 0 0-2.15 1.586m5.8 0c.065.21.1.433.1.664v.75h-6V4.5c0-.231.035-.454.1-.664M6.75 7.5H4.875c-.621 0-1.125.504-1.125 1.125v12c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V16.5a9 9 0 0 0-9-9Z" /></svg>
|
||||
</button>
|
||||
{{ else }}
|
||||
<span class="italic">{{ $.locale.Tr "admin.invitations.expired" }}</span>
|
||||
{{ end }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap py-2 px-2 text-sm">{{ $invitation.NbUsed }}/{{ $invitation.NbMax }}</td>
|
||||
<td class="whitespace-nowrap px-2 py-2 text-sm"><span>{{ $invitation.ExpiresAt | humanDate }}</span></td>
|
||||
<td class="relative whitespace-nowrap py-2 pl-3 pr-4 text-right text-sm font-medium sm:pr-0">
|
||||
<form action="{{ $.c.ExternalUrl }}/admin-panel/invitations/{{ $invitation.ID }}/delete" method="POST">
|
||||
<td class="px-4 py-2">{{ $invitation.NbUsed }}/{{ $invitation.NbMax }}</td>
|
||||
<td class="px-4 py-2">{{ $invitation.ExpiresAt | humanDate }}</td>
|
||||
<td class="px-4 py-2 text-right">
|
||||
<form action="{{ $.c.ExternalUrl }}/-/admin-panel/invitations/{{ $invitation.ID }}/delete" method="POST">
|
||||
{{ $.csrfHtml }}
|
||||
<button type="submit" onclick="return confirm('{{ $.locale.Tr "admin.invitations.delete_confirm" }}')" class="text-rose-500 hover:text-rose-600">{{ $.locale.Tr "admin.delete" }}</button>
|
||||
<button type="submit" class="text-destructive text-sm font-medium not-italic hover:underline"
|
||||
_="on click if not window.confirm('{{ $.locale.Tr "admin.invitations.delete_confirm" }}') halt the event">{{ $.locale.Tr "admin.delete" }}</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -63,6 +70,6 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{{ template "admin_footer" .}}
|
||||
{{ template "footer" .}}
|
||||
</section>
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
Vendored
+27
-21
@@ -1,28 +1,32 @@
|
||||
{{ template "header" .}}
|
||||
{{ template "admin_header" .}}
|
||||
{{ define "content" }}
|
||||
<div class="space-y-6">
|
||||
<header class="space-y-1">
|
||||
<h1 class="text-2xl font-bold leading-tight">{{ .locale.Tr "admin.admin_panel" }}</h1>
|
||||
<p class="text-muted-foreground text-sm">{{ .locale.Tr "admin.users" }}</p>
|
||||
</header>
|
||||
|
||||
<div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8 bg-gray-50 dark:bg-gray-800 rounded-md border border-gray-200 dark:border-gray-700">
|
||||
<table class="min-w-full divide-y divide-slate-300 dark:divide-gray-500">
|
||||
<thead>
|
||||
<section class="bg-card overflow-hidden rounded-lg border">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="text-muted-foreground border-b text-left">
|
||||
<tr>
|
||||
<th scope="col" class="whitespace-nowrap py-3.5 pl-4 pr-3 text-left text-sm font-bold text-slate-700 dark:text-slate-300 sm:pl-0">{{ .locale.Tr "admin.id" }}</th>
|
||||
<th scope="col" class="whitespace-nowrap px-2 py-3.5 text-left text-sm font-semibold text-slate-700 dark:text-slate-300">{{ .locale.Tr "admin.user" }}</th>
|
||||
<th scope="col" class="whitespace-nowrap px-2 py-3.5 text-left text-sm font-semibold text-slate-700 dark:text-slate-300">{{ .locale.Tr "admin.created_at" }}</th>
|
||||
<th scope="col" class="relative whitespace-nowrap py-3.5 pl-3 pr-4 sm:pr-0">
|
||||
<span class="sr-only">{{ .locale.Tr "admin.delete" }}</span>
|
||||
</th>
|
||||
<th scope="col" class="px-4 py-3 font-medium">{{ .locale.Tr "admin.id" }}</th>
|
||||
<th scope="col" class="px-4 py-3 font-medium">{{ .locale.Tr "admin.user" }}</th>
|
||||
<th scope="col" class="px-4 py-3 font-medium">{{ .locale.Tr "admin.created_at" }}</th>
|
||||
<th scope="col" class="px-4 py-3"><span class="sr-only">{{ .locale.Tr "admin.delete" }}</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-300 dark:divide-gray-500">
|
||||
<tbody class="divide-y">
|
||||
{{ range $user := .data }}
|
||||
<tr>
|
||||
<td class="whitespace-nowrap py-2 pl-4 pr-3 text-sm text-slate-700 dark:text-slate-300 sm:pl-0">{{ $user.ID }}</td>
|
||||
<td class="whitespace-nowrap px-2 py-2 text-sm text-slate-700 dark:text-slate-300"><a href="{{ $.c.ExternalUrl }}/{{ $user.Username }}">{{ $user.Username }}</a></td>
|
||||
<td class="whitespace-nowrap px-2 py-2 text-sm text-slate-700 dark:text-slate-300"><span>{{ $user.CreatedAt | humanDate }}</span></td>
|
||||
<td class="relative whitespace-nowrap py-2 pl-3 pr-4 text-right text-sm font-medium sm:pr-0">
|
||||
<form action="{{ $.c.ExternalUrl }}/admin-panel/users/{{ $user.ID }}/delete" method="POST">
|
||||
<tr class="hover:bg-muted/40">
|
||||
<td class="px-4 py-2">{{ $user.ID }}</td>
|
||||
<td class="px-4 py-2"><a href="{{ $.c.ExternalUrl }}/{{ $user.Username }}" class="text-primary hover:underline">{{ $user.Username }}</a></td>
|
||||
<td class="text-muted-foreground px-4 py-2">{{ $user.CreatedAt | humanDate }}</td>
|
||||
<td class="px-4 py-2 text-right">
|
||||
<form action="{{ $.c.ExternalUrl }}/-/admin-panel/users/{{ $user.ID }}/delete" method="POST">
|
||||
{{ $.csrfHtml }}
|
||||
<button type="submit" class="text-rose-500 hover:text-rose-600" onclick="return confirm('{{ $.locale.Tr "admin.users.delete_confirm" }}')">{{ $.locale.Tr "admin.delete" }}</button>
|
||||
<button type="submit" class="text-destructive text-sm font-medium hover:underline"
|
||||
_="on click if not window.confirm('{{ $.locale.Tr "admin.users.delete_confirm" }}') halt the event">{{ $.locale.Tr "admin.delete" }}</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -30,6 +34,8 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{ template "admin_footer" .}}
|
||||
{{ template "footer" .}}
|
||||
{{ template "pagination" . }}
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
Vendored
+96
-208
@@ -1,226 +1,114 @@
|
||||
{{ template "header" .}}
|
||||
<div class="py-10">
|
||||
<header class="pb-4 ">
|
||||
<div class="flex">
|
||||
<div class="flex-auto">
|
||||
{{ define "content" }}
|
||||
<div class="space-y-6">
|
||||
<header class="flex min-h-9 flex-wrap items-center justify-between gap-3">
|
||||
{{ if .fromUser }}
|
||||
<div class="flex items-center">
|
||||
<div class="shrink-0">
|
||||
<div class="flex items-center gap-3">
|
||||
{{ if not (shouldGenerateAvatar .fromUser .DisableGravatar) }}
|
||||
<img class="h-12 w-12 rounded-md mr-2 border border-gray-200 dark:border-gray-700" src="{{ avatarUrl .fromUser .DisableGravatar }}" alt="{{ .fromuser.Username }}'s Avatar">
|
||||
<img class="size-12 rounded-md border object-cover" src="{{ avatarUrl .fromUser .DisableGravatar }}" alt="{{ .fromUser.Username }}" />
|
||||
{{ else }}
|
||||
<svg class="h-12 w-12 rounded-md mr-2 border border-gray-200 dark:border-gray-700"
|
||||
data-jdenticon-value="{{ .fromUser.Username }}"
|
||||
width="48"
|
||||
height="48">
|
||||
</svg>
|
||||
<svg class="size-12 rounded-md border" data-jdenticon-value="{{ .fromUser.Username }}" width="48" height="48"></svg>
|
||||
{{ end }}
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold leading-tight">{{ .fromUser.Username }}</h1>
|
||||
<p class="text-sm text-slate-500">{{ .locale.Tr "gist.list.joined" }} <span>{{.fromUser.CreatedAt | humanTimeDiff}}</span></p>
|
||||
<p class="text-muted-foreground text-sm">{{ .locale.Tr "gist.list.joined" }} {{ .fromUser.CreatedAt | humanTimeDiff }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{{ else }}
|
||||
{{ if eq .mode "all" }}
|
||||
<h1 class="text-2xl font-bold leading-tight">{{ .locale.Tr "gist.list.all" }}</h1>
|
||||
{{ else if eq .mode "topics" }}
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="bg-muted text-muted-foreground flex size-12 items-center justify-center rounded-md border text-lg font-medium uppercase">{{ slice .topic 0 1 }}</span>
|
||||
<h1 class="text-2xl font-bold leading-tight">{{ .topic }}</h1>
|
||||
</div>
|
||||
{{ else if eq .mode "search" }}
|
||||
<h1 class="text-2xl font-bold leading-tight">{{ .locale.Tr "gist.list.search-results" }}</h1>
|
||||
{{ else if eq .mode "topics" }}
|
||||
<h1 class="text-2xl font-bold leading-tight">{{ .locale.Tr "gist.list.topic-results" }} <span class="items-center px-2 py-0.5 rounded bg-primary-200 text-primary-900 hover:bg-primary-300 dark:bg-primary-950 dark:text-primary-200 dark:hover:bg-primary-900">{{ .topic }}</span></h1>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</div>
|
||||
<div class="align-middle inline-flex items-center">
|
||||
<div class="relative text-left">
|
||||
<div>
|
||||
<button type="button" class="whitespace-nowrap inline-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="sort-gists-button">
|
||||
<span class="text-gray-700 dark:text-gray-300">{{ .locale.Tr "gist.list.sort" }} : <span class="text-slate-700 dark:text-slate-300">{{.order}} {{.sort}}</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="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 }}{{ .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 }}{{ .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 }}{{ .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 }}{{ .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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{{ if and (ne .mode "all") (ne .mode "search") (ne .mode "topics") }}
|
||||
<div class="mt-4">
|
||||
<div class="sm:hidden">
|
||||
<label for="tabs" class="sr-only">{{ .locale.Tr "gist.list.select-tab" }}</label>
|
||||
<select id="gist-tabs" name="tabs" class="block w-full rounded-md border-gray-300 py-2 pl-3 pr-10 text-base focus:border-primary-500 focus:outline-none focus:ring-primary-500 sm:text-sm dark:bg-gray-800 dark:border-gray-700">
|
||||
<option {{if eq .mode "fromUser"}}selected {{end}}data-url="/{{ .fromUser.Username }}">{{ .locale.Tr "gist.list.all" }} ({{ .countFromUser }})</option>
|
||||
{{ if ne .countLiked 0 }}<option {{if eq .mode "liked"}}selected {{end}}data-url="/{{ .fromUser.Username }}/liked">{{ .locale.Tr "gist.list.liked" }} ({{ .countLiked }})</option>{{end}}
|
||||
{{ if ne .countForked 0 }}<option {{if eq .mode "forked"}}selected {{end}}data-url="/{{ .fromUser.Username }}/forked">{{ .locale.Tr "gist.list.forked" }} ({{ .countForked }})</option>{{end}}
|
||||
</select>
|
||||
</div>
|
||||
<div class="hidden sm:block">
|
||||
<div class="border-b border-gray-200 dark:border-gray-700 flex">
|
||||
<div class="flex-auto">
|
||||
<nav class="-mb-px flex space-x-6" aria-label="Tabs">
|
||||
<a href="{{ $.c.ExternalUrl }}/{{ .fromUser.Username }}" class="{{if eq .mode "fromUser"}}border-primary-500 font-bold {{else}}border-transparent hover:border-gray-200 hover:text-gray-700{{end}} text-slate-700 dark:text-slate-300 inline-flex items-center whitespace-nowrap border-b-2 py-2 px-1 text-sm">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6 mr-1">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M14.25 9.75L16.5 12l-2.25 2.25m-4.5 0L7.5 12l2.25-2.25M6 20.25h12A2.25 2.25 0 0020.25 18V6A2.25 2.25 0 0018 3.75H6A2.25 2.25 0 003.75 6v12A2.25 2.25 0 006 20.25z" />
|
||||
</svg>
|
||||
{{ .locale.Tr "gist.list.all" }}
|
||||
<span class="bg-gray-100 text-gray-900 dark:bg-gray-700 dark:text-slate-300 ml-2 hidden rounded-full py-0.5 px-2.5 text-xs font-medium md:inline-block">{{ .countFromUser }}</span>
|
||||
</a>
|
||||
{{ if ne .countLiked 0 }}
|
||||
<a href="{{ $.c.ExternalUrl }}/{{ .fromUser.Username }}/liked" class="{{if eq .mode "liked"}}border-primary-500 font-bold {{else}}border-transparent hover:border-gray-200 hover:text-gray-700{{end}} text-slate-700 dark:text-slate-300 inline-flex items-center whitespace-nowrap border-b-2 py-2 px-1 text-sm">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-6 h-6 mr-1">
|
||||
<path d="M11.645 20.91l-.007-.003-.022-.012a15.247 15.247 0 01-.383-.218 25.18 25.18 0 01-4.244-3.17C4.688 15.36 2.25 12.174 2.25 8.25 2.25 5.322 4.714 3 7.688 3A5.5 5.5 0 0112 5.052 5.5 5.5 0 0116.313 3c2.973 0 5.437 2.322 5.437 5.25 0 3.925-2.438 7.111-4.739 9.256a25.175 25.175 0 01-4.244 3.17 15.247 15.247 0 01-.383.219l-.022.012-.007.004-.003.001a.752.752 0 01-.704 0l-.003-.001z" />
|
||||
</svg>
|
||||
{{ .locale.Tr "gist.list.liked" }}
|
||||
<span class="bg-gray-100 text-gray-900 dark:bg-gray-700 dark:text-slate-300 ml-2 hidden rounded-full py-0.5 px-2.5 text-xs font-medium md:inline-block">{{ .countLiked }}</span>
|
||||
</a>
|
||||
{{ end }}
|
||||
{{ if ne .countForked 0 }}
|
||||
<a href="{{ $.c.ExternalUrl }}/{{ .fromUser.Username }}/forked" class="{{if eq .mode "forked"}}border-primary-500 font-bold {{else}}border-transparent hover:border-gray-200 hover:text-gray-700{{end}} text-slate-700 dark:text-slate-300 inline-flex items-center whitespace-nowrap border-b-2 py-2 px-1 text-sm" aria-current="page">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6 mr-1">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M7.217 10.907a2.25 2.25 0 100 2.186m0-2.186c.18.324.283.696.283 1.093s-.103.77-.283 1.093m0-2.186l9.566-5.314m-9.566 7.5l9.566 5.314m0 0a2.25 2.25 0 103.935 2.186 2.25 2.25 0 00-3.935-2.186zm0-12.814a2.25 2.25 0 103.933-2.185 2.25 2.25 0 00-3.933 2.185z" />
|
||||
</svg>
|
||||
{{ .locale.Tr "gist.list.forked" }}
|
||||
<span class="bg-gray-100 text-gray-900 dark:bg-gray-700 dark:text-slate-300 ml-2 hidden rounded-full py-0.5 px-2.5 text-xs font-medium md:inline-block">{{ .countForked }}</span>
|
||||
</a>
|
||||
{{ end }}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ else }}
|
||||
<h1 class="text-2xl font-bold leading-tight">{{ .locale.Tr "gist.list.all" }}</h1>
|
||||
{{ 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 }}
|
||||
{{ $nest := dict "gist" $gist "c" $.c "locale" $.locale "DisableGravatar" $.DisableGravatar "searchQuery" $.searchQuery }}
|
||||
{{ template "_gist_preview" $nest }}
|
||||
|
||||
{{ if .fromUser }}
|
||||
<!-- Tabs for a user's gists -->
|
||||
<nav class="flex gap-1 border-b" aria-label="Tabs">
|
||||
<a href="{{ .c.ExternalUrl }}/{{ .fromUser.Username }}" class="-mb-px border-b-2 px-3 py-2 text-sm font-medium {{ if eq .mode "fromUser" }}border-primary{{ else }}text-muted-foreground hover:text-foreground border-transparent{{ end }}">{{ .locale.Tr "gist.list.all" }} <span class="text-muted-foreground">{{ .countFromUser }}</span></a>
|
||||
{{ if or (ne .countLiked 0) (eq .mode "liked") }}<a href="{{ .c.ExternalUrl }}/{{ .fromUser.Username }}/-/liked" class="-mb-px border-b-2 px-3 py-2 text-sm font-medium {{ if eq .mode "liked" }}border-primary{{ else }}text-muted-foreground hover:text-foreground border-transparent{{ end }}">{{ .locale.Tr "gist.list.liked" }} <span>{{ .countLiked }}</span></a>{{ end }}
|
||||
{{ if or (ne .countForked 0) (eq .mode "forked") }}<a href="{{ .c.ExternalUrl }}/{{ .fromUser.Username }}/-/forked" class="-mb-px border-b-2 px-3 py-2 text-sm font-medium {{ if eq .mode "forked" }}border-primary{{ else }}text-muted-foreground hover:text-foreground border-transparent{{ end }}">{{ .locale.Tr "gist.list.forked" }} <span>{{ .countForked }}</span></a>{{ end }}
|
||||
</nav>
|
||||
{{ else if or (eq .mode "all") (eq .mode "all-liked") (eq .mode "all-forked") }}
|
||||
<!-- Tabs for the explore "All gists" views -->
|
||||
<nav class="flex gap-1 border-b" aria-label="Tabs">
|
||||
<a href="{{ .c.ExternalUrl }}/-/all" class="-mb-px border-b-2 px-3 py-2 text-sm font-medium {{ if eq .mode "all" }}border-primary{{ else }}text-muted-foreground hover:text-foreground border-transparent{{ end }}">{{ .locale.Tr "header.menu.all" }}</a>
|
||||
<a href="{{ .c.ExternalUrl }}/-/liked" class="-mb-px border-b-2 px-3 py-2 text-sm font-medium {{ if eq .mode "all-liked" }}border-primary{{ else }}text-muted-foreground hover:text-foreground border-transparent{{ end }}">{{ .locale.Tr "gist.list.recently-liked" }}</a>
|
||||
<a href="{{ .c.ExternalUrl }}/-/forked" class="-mb-px border-b-2 px-3 py-2 text-sm font-medium {{ if eq .mode "all-forked" }}border-primary{{ else }}text-muted-foreground hover:text-foreground border-transparent{{ end }}">{{ .locale.Tr "gist.list.recently-forked" }}</a>
|
||||
</nav>
|
||||
{{ end }}
|
||||
|
||||
{{ template "_pagination" . }}
|
||||
{{ if .sortable }}
|
||||
<!-- Filter / sort toolbar -->
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
{{ if or (eq .mode "fromUser") (eq .mode "all") (eq .mode "topics") }}
|
||||
<form action="{{ .c.ExternalUrl }}/{{ if eq .mode "fromUser" }}{{ .fromUser.Username }}{{ else if eq .mode "topics" }}-/topics/{{ .topic }}{{ else }}-/all{{ end }}" method="GET"
|
||||
class="flex flex-1 flex-wrap items-center gap-2"
|
||||
data-gist-filter
|
||||
data-init-title="{{ .title }}" data-init-visibility="{{ .visibility }}" data-init-language="{{ .language }}" data-init-topics="{{ .topics }}">
|
||||
<div class="relative min-w-60 flex-1">
|
||||
<div data-filter-box class="border-input bg-background focus-within:ring-ring flex h-auto min-h-9 flex-wrap items-center gap-1 rounded-md border px-3 py-1 focus-within:ring-1">
|
||||
<input type="text" data-filter-input autocomplete="off" spellcheck="false"
|
||||
placeholder="{{ .locale.Tr "gist.search.placeholder.search" }}"
|
||||
class="min-w-32 flex-1 border-0 bg-transparent p-0 text-sm outline-none focus:ring-0" />
|
||||
</div>
|
||||
<div data-filter-menu class="bg-popover text-popover-foreground absolute inset-x-0 top-full z-30 mt-1 hidden max-h-64 overflow-auto rounded-md border p-1 shadow-md"></div>
|
||||
</div>
|
||||
|
||||
<!-- Option sources read by the filter script (never rendered). -->
|
||||
<template data-filter-options="visibility">
|
||||
<span data-value="public">{{ .locale.Tr "gist.search.placeholder.public" }}</span>
|
||||
<span data-value="unlisted">{{ .locale.Tr "gist.search.placeholder.unlisted" }}</span>
|
||||
<span data-value="private">{{ .locale.Tr "gist.search.placeholder.private" }}</span>
|
||||
</template>
|
||||
<template data-filter-options="language">
|
||||
{{ range .languages }}<span data-value="{{ .Language }}">{{ .Language }} ({{ .Count }})</span>{{ end }}
|
||||
</template>
|
||||
|
||||
<input type="hidden" name="title" data-filter-hidden="title" value="{{ .title }}" />
|
||||
<input type="hidden" name="visibility" data-filter-hidden="visibility" value="{{ .visibility }}" />
|
||||
<input type="hidden" name="language" data-filter-hidden="language" value="{{ .language }}" />
|
||||
<input type="hidden" name="topics" data-filter-hidden="topics" value="{{ .topics }}" />
|
||||
<input type="hidden" name="sort" value="{{ .pagination.Sort }}" />
|
||||
<input type="hidden" name="order" value="{{ .pagination.Order }}" />
|
||||
|
||||
<button type="submit" class="btn h-9 shrink-0">{{ .locale.Tr "gist.search.placeholder.search" }}</button>
|
||||
</form>
|
||||
{{ end }}
|
||||
|
||||
<!-- Sort dropdown (Basecoat dropdown-menu, JS) -->
|
||||
<div class="dropdown-menu ml-auto shrink-0">
|
||||
<button type="button" id="sort-trigger" aria-haspopup="menu" aria-expanded="false" aria-controls="sort-menu" class="btn-outline">
|
||||
{{ .locale.Tr "gist.list.sort" }} : {{ .order }} {{ .sort }}
|
||||
<svg class="size-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>
|
||||
</button>
|
||||
<div id="sort-menu" data-popover aria-hidden="true" data-side="bottom" data-align="end">
|
||||
<div role="menu" aria-labelledby="sort-trigger">
|
||||
<a href="{{ .c.ExternalUrl }}/{{ .urlPage }}{{ .pagination.WithParams "sort" "created" "order" "desc" }}" role="menuitem">{{ .locale.Tr "gist.list.order-by-desc" }} {{ .locale.Tr "gist.list.sort-by-created" }}</a>
|
||||
<a href="{{ .c.ExternalUrl }}/{{ .urlPage }}{{ .pagination.WithParams "sort" "created" "order" "asc" }}" role="menuitem">{{ .locale.Tr "gist.list.order-by-asc" }} {{ .locale.Tr "gist.list.sort-by-created" }}</a>
|
||||
<a href="{{ .c.ExternalUrl }}/{{ .urlPage }}{{ .pagination.WithParams "sort" "updated" "order" "desc" }}" role="menuitem">{{ .locale.Tr "gist.list.order-by-desc" }} {{ .locale.Tr "gist.list.sort-by-updated" }}</a>
|
||||
<a href="{{ .c.ExternalUrl }}/{{ .urlPage }}{{ .pagination.WithParams "sort" "updated" "order" "asc" }}" role="menuitem">{{ .locale.Tr "gist.list.order-by-asc" }} {{ .locale.Tr "gist.list.sort-by-updated" }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
{{ if ne (len .gists) 0 }}
|
||||
<div class="space-y-4">
|
||||
{{ range $gist := .gists }}
|
||||
{{ template "gist" (dict "gist" $gist "c" $.c "locale" $.locale "DisableGravatar" $.DisableGravatar "currentStyle" $.currentStyle) }}
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ template "pagination" . }}
|
||||
{{ else }}
|
||||
<div class="text-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="mx-auto h-12 w-12 text-slate-600 dark:text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M14 10l-2 1m0 0l-2-1m2 1v2.5M20 7l-2 1m2-1l-2-1m2 1v2.5M14 4l-2-1-2 1M4 7l2-1M4 7l2 1M4 7v2.5M12 21l-2-1m2 1l2-1m-2 1v-2.5M6 18l-2-1v-2.5M18 18l2-1v-2.5" />
|
||||
</svg>
|
||||
<h3 class="mt-2 text-sm font-medium text-slate-700 dark:text-slate-300">{{ .locale.Tr "gist.list.no-gists" }}</h3>
|
||||
<div class="text-muted-foreground flex flex-col items-center justify-center py-16 text-center">
|
||||
<svg class="size-12" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/></svg>
|
||||
<h3 class="text-foreground mt-3 text-sm font-medium">{{ .locale.Tr "gist.list.no-gists" }}</h3>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
{{ template "footer" .}}
|
||||
{{ end }}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user