UI rework (#757)

This commit is contained in:
Thomas
2026-07-27 03:41:17 +02:00
committed by GitHub
parent ec429f51c6
commit 5ae2942bfa
133 changed files with 4834 additions and 3848 deletions
+1 -1
View File
@@ -100,4 +100,4 @@ update_js_deps:
update_go_deps: update_go_deps:
@echo "Updating Go dependencies..." @echo "Updating Go dependencies..."
@go get -u ./... && go mod tidy @go get -u ./... && go mod tidy
+10
View File
@@ -59,6 +59,15 @@ func IsRunning(actionType int) bool {
return actionType >= 0 && actionType < numActions && running[actionType].Load() 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) { func RunOnce(actionType int) {
a, ok := registry[actionType] a, ok := registry[actionType]
if !ok { if !ok {
@@ -89,6 +98,7 @@ func RunOnce(actionType int) {
}() }()
log.Info().Msgf("Starting running action %d", actionType) log.Info().Msgf("Starting running action %d", actionType)
time.Sleep(100 * time.Millisecond)
a.run() a.run()
log.Info().Msgf("Finished running action %d", actionType) log.Info().Msgf("Finished running action %d", actionType)
} }
+2 -2
View File
@@ -129,10 +129,10 @@ func TestCmdAdminCreateUser_MissingPassword(t *testing.T) {
func TestCmdAdminCreateUser_InvalidUsername(t *testing.T) { func TestCmdAdminCreateUser_InvalidUsername(t *testing.T) {
// Reserved names must be rejected with the same rules as the web form. // 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) require.Error(t, err)
_, fetchErr := db.GetUserByUsername("login") _, fetchErr := db.GetUserByUsername("oauth")
require.Error(t, fetchErr, "reserved user must not be persisted") require.Error(t, fetchErr, "reserved user must not be persisted")
} }
+5
View File
@@ -11,6 +11,11 @@ import (
// keyed by Action (the action's identifier); LockedUntil holds the Unix // keyed by Action (the action's identifier); LockedUntil holds the Unix
// timestamp the current lease expires at (0 = free). // timestamp the current lease expires at (0 = free).
type ActionLock struct { 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"` Action int `gorm:"primaryKey;autoIncrement:false"`
LockedUntil int64 LockedUntil int64
} }
+154
View File
@@ -167,6 +167,94 @@ func GetAllGistsForCurrentUser(currentUserId uint, since *time.Time, offset int,
return gists, err 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 // GetAllGistsFromUserVisibleTo returns gists owned by fromUserId, filtered
// to what currentUserId is allowed to see (public always; private/unlisted // to what currentUserId is allowed to see (public always; private/unlisted
// only when currentUserId == fromUserId). Same pagination/since shape as // only when currentUserId == fromUserId). Same pagination/since shape as
@@ -344,6 +432,26 @@ func CountAllGistsLikedByUser(fromUserId uint, currentUserId uint) (int64, error
return count, err 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 { func forkedStatement(fromUserId uint, currentUserId uint) *gorm.DB {
return db.Preload("User").Preload("Forked.User").Preload("Topics"). 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). 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 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 // 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 // is non-nil, matching the filter the API list queries apply. Kept separate so
// the count helpers stay in sync with their Find counterparts. // 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"` 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 { type FileDTO struct {
Filename string `validate:"excludes=\x2f,excludes=\x5c,max=255"` Filename string `validate:"excludes=\x2f,excludes=\x5c,max=255"`
Content string Content string
+50
View File
@@ -27,3 +27,53 @@ func GetGistLanguagesForUser(fromUserId, currentUserId uint) ([]struct {
return results, err 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
}
+25
View File
@@ -4,3 +4,28 @@ type GistTopic struct {
GistID uint `gorm:"primaryKey"` GistID uint `gorm:"primaryKey"`
Topic string `gorm:"primaryKey;size:50"` 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
}
+65
View File
@@ -124,6 +124,70 @@ func GetAllUsers(offset int) ([]*User, error) {
return users, err 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) { func GetUserByUsername(username string) (*User, error) {
user := new(User) user := new(User)
err := db. err := db.
@@ -301,6 +365,7 @@ type UserUsernameDTO struct {
type UserStyleDTO struct { type UserStyleDTO struct {
SoftWrap bool `form:"softwrap" json:"soft_wrap"` 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"` 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"` 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"` GitLineColor string `form:"gitlinecolor" json:"git_line_color" validate:"min=0,max=7"`
+1 -2
View File
@@ -21,8 +21,7 @@ func (mt MimeType) IsText() bool {
} }
func (mt MimeType) IsCSV() bool { func (mt MimeType) IsCSV() bool {
return strings.HasPrefix(mt.ContentType, "text/csv") && return strings.HasSuffix(mt.extension, ".csv") && mt.IsText()
(strings.HasSuffix(mt.extension, ".csv"))
} }
func (mt MimeType) IsImage() bool { func (mt MimeType) IsImage() bool {
-9
View File
@@ -289,8 +289,6 @@ admin.versions: الإصدارات
admin.ssh_keys: مفاتيح SSH admin.ssh_keys: مفاتيح SSH
admin.stats: الإحصائيات admin.stats: الإحصائيات
admin.actions: الإجراءات admin.actions: الإجراءات
admin.actions.sync-fs: مزامنة المقاطع من نظام الملفات
admin.actions.sync-db: مزامنة المقاطع من قاعدة البيانات
admin.actions.git-gc: تنفيذ جمع القمامة لجميع مستودعات Git admin.actions.git-gc: تنفيذ جمع القمامة لجميع مستودعات Git
admin.actions.sync-previews: مزامنة معاينات جميع المقاطع admin.actions.sync-previews: مزامنة معاينات جميع المقاطع
admin.actions.reset-hooks: إعادة تعيين خطافات خادم Git لجميع المستودعات admin.actions.reset-hooks: إعادة تعيين خطافات خادم Git لجميع المستودعات
@@ -335,13 +333,6 @@ flash.admin.user-deleted: تم حذف المستخدم
flash.admin.gist-deleted: تم حذف المقطع flash.admin.gist-deleted: تم حذف المقطع
flash.admin.invitation-created: تم إنشاء الدعوة flash.admin.invitation-created: تم إنشاء الدعوة
flash.admin.invitation-deleted: تم حذف الدعوة 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.username-exists: اسم المستخدم موجود بالفعل
flash.auth.invalid-credentials: بيانات الاعتماد غير صالحة flash.auth.invalid-credentials: بيانات الاعتماد غير صالحة
-8
View File
@@ -155,8 +155,6 @@ admin.versions: Verze
admin.ssh_keys: SSH klíče admin.ssh_keys: SSH klíče
admin.stats: Statistiky admin.stats: Statistiky
admin.actions: Akce 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.actions.git-gc: Garbage collect git repozitářů
admin.id: ID admin.id: ID
admin.user: Uživatel admin.user: Uživatel
@@ -201,14 +199,8 @@ flash.admin.user-deleted: ''
flash.admin.gist-deleted: '' flash.admin.gist-deleted: ''
flash.admin.invitation-created: '' flash.admin.invitation-created: ''
flash.admin.invitation-deleted: '' 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.new.create-a-new-gist: ''
gist.edit.edit-gist: '' gist.edit.edit-gist: ''
flash.admin.reset-hooks: ''
flash.admin.index-gists: ''
flash.auth.username-exists: '' flash.auth.username-exists: ''
flash.auth.invalid-credentials: '' flash.auth.invalid-credentials: ''
flash.auth.account-linked-oauth: '' flash.auth.account-linked-oauth: ''
-9
View File
@@ -187,8 +187,6 @@ admin.versions: 'Versionen'
admin.ssh_keys: 'SSH Schlüssel' admin.ssh_keys: 'SSH Schlüssel'
admin.stats: 'Statistiken' admin.stats: 'Statistiken'
admin.actions: 'Aktionen' 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.git-gc: '„garbage collection“ bei allen git Repositories ausführen'
admin.actions.sync-previews: 'Alle Gist Vorschauen synchronisieren' admin.actions.sync-previews: 'Alle Gist Vorschauen synchronisieren'
admin.actions.reset-hooks: 'Alle Git server Hooks für alle Repositories 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.gist-deleted: 'Gist wurde gelöscht'
flash.admin.invitation-created: 'Einladung wurde erstellt' flash.admin.invitation-created: 'Einladung wurde erstellt'
flash.admin.invitation-deleted: 'Einladung wurde gelöscht' 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.username-exists: 'Benutzername existiert bereits'
flash.auth.invalid-credentials: 'Ungültige Anmeldeinformationen' 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 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 gist.revision.binary-file-changes: Änderungen an Binärdateien werden nicht angezeigt
error.no-file-uploaded: Keine Datei hochgeladen 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 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.new.topics: Themen (durch Leerzeichen getrennt)
gist.preview-non-available: Vorschau nicht verfügbar gist.preview-non-available: Vorschau nicht verfügbar
+40 -13
View File
@@ -6,6 +6,7 @@ gist.header.like: Like
gist.header.unlike: Unlike gist.header.unlike: Unlike
gist.header.fork: Fork gist.header.fork: Fork
gist.header.edit: Edit gist.header.edit: Edit
gist.header.edit-files: Edit files
gist.header.delete: Delete gist.header.delete: Delete
gist.header.archive: Archive gist.header.archive: Archive
gist.header.unarchive: Unarchive gist.header.unarchive: Unarchive
@@ -16,6 +17,7 @@ gist.header.last-active: Last active
gist.header.expires: Expires gist.header.expires: Expires
gist.header.select-tab: Select a tab gist.header.select-tab: Select a tab
gist.header.code: Code gist.header.code: Code
gist.header.more-actions: More actions
gist.header.revisions: Revisions gist.header.revisions: Revisions
gist.header.revision: Revision gist.header.revision: Revision
gist.header.clone-http: Clone via %s 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.file-not-valid: This file is not a valid CSV file.
gist.no-content: No files found gist.no-content: No files found
gist.preview-non-available: Preview not available gist.preview-non-available: Preview not available
gist.embed.view-all-files: View all files
gist.new.new_gist: New gist gist.new.new_gist: New gist
gist.new.title: Title gist.new.title: Title
@@ -74,9 +77,18 @@ gist.edit.cancel: Cancel
gist.edit.save: Save gist.edit.save: Save
gist.delete.confirm: Are you sure you want to delete this gist ? 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.joined: Joined
gist.list.all: All gists 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-results: Search results
gist.list.search-for: Results for
gist.list.sort: Sort gist.list.sort: Sort
gist.list.sort-by-created: created gist.list.sort-by-created: created
gist.list.sort-by-updated: updated 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.order-by-desc: Recently
gist.list.select-tab: Select a tab gist.list.select-tab: Select a tab
gist.list.liked: Liked gist.list.liked: Liked
gist.list.likes: likes gist.list.likes: Likes
gist.list.forked: Forked gist.list.forked: Forked
gist.list.forked-from: Forked from gist.list.forked-from: Forked from
gist.list.forks: forks gist.list.forks: Forks
gist.list.files: files gist.list.files: files
gist.list.last-active: Last active gist.list.last-active: Last active
gist.list.no-gists: No gists 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-liked-by: All gists liked by %s
gist.list.all-forked-by: All gists forked by %s gist.list.all-forked-by: All gists forked by %s
gist.list.all-from: All gists from %s gist.list.all-from: All gists from %s
gist.list.topic-results-topic: All gists matching topic %s gist.list.topic-results-topic: All gists matching topic %s
gist.list.topic-results: All gists matching topic 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.found: gists found
gist.search.no-results: No 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.password-label-title: Password
settings.header.account: Account settings.header.account: Account
settings.header.mfa: MFA settings.header.mfa: MFA
settings.header.authentication: Authentication
settings.header.ssh: SSH settings.header.ssh: SSH
settings.header.tokens: Access tokens settings.header.tokens: Access tokens
settings.header.style: Style 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: Error
error.go-home: Back to home
error.page-not-found: Page not found error.page-not-found: Page not found
error.bad-request: Bad request error.bad-request: Bad request
error.signup-disabled: Signing up is disabled 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.all: All
header.menu.new: New header.menu.new: New
header.menu.topics: Topics
header.menu.users: Users
header.menu.search: Search header.menu.search: Search
header.menu.recently-liked: Recently liked
header.menu.recently-forked: Recently forked
header.menu.my-gists: My gists header.menu.my-gists: My gists
header.menu.liked: Liked header.menu.liked: Liked
header.menu.admin: Admin header.menu.admin: Admin
@@ -299,6 +329,8 @@ header.menu.light: Light
header.menu.dark: Dark header.menu.dark: Dark
header.menu.system: System header.menu.system: System
footer.powered-by: Powered by %s footer.powered-by: Powered by %s
ui.feedback-new: Give feedback on the new UI
sidebar.collapse: Collapse
pagination.older: Older pagination.older: Older
pagination.newer: Newer pagination.newer: Newer
@@ -316,8 +348,11 @@ admin.versions: Versions
admin.ssh_keys: SSH keys admin.ssh_keys: SSH keys
admin.stats: Stats admin.stats: Stats
admin.actions: Actions admin.actions: Actions
admin.actions.sync-fs: Synchronize gists from filesystem admin.actions.subtitle: Run maintenance tasks manually. Some tasks also run automatically on a schedule.
admin.actions.sync-db: Synchronize gists from database 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.git-gc: Garbage collect all git repositories
admin.actions.sync-previews: Synchronize all gists previews admin.actions.sync-previews: Synchronize all gists previews
admin.actions.reset-hooks: Reset Git server hooks for all repositories 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.gist-deleted: Gist has been deleted
flash.admin.invitation-created: Invitation has been created flash.admin.invitation-created: Invitation has been created
flash.admin.invitation-deleted: Invitation has been deleted 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.username-exists: Username already exists
flash.auth.invalid-credentials: Invalid credentials 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.archived: Gist has been archived
flash.gist.unarchived: Gist has been unarchived flash.gist.unarchived: Gist has been unarchived
flash.gist.deleted: Gist has been deleted 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.fork-own-gist: Unable to fork own gists
flash.gist.forked: Gist has been forked flash.gist.forked: Gist has been forked
-8
View File
@@ -148,8 +148,6 @@ admin.versions: Versiones
admin.ssh_keys: Claves SSH admin.ssh_keys: Claves SSH
admin.stats: Estadísticas admin.stats: Estadísticas
admin.actions: Acciones 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.actions.git-gc: Recolectar basura en los repositorios Git
admin.id: ID admin.id: ID
admin.user: Usuario 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.gist-deleted: 'El gist ha sido eliminado'
flash.admin.invitation-created: 'La invitación ha sido creada' flash.admin.invitation-created: 'La invitación ha sido creada'
flash.admin.invitation-deleted: 'La invitación ha sido eliminada' 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.username-exists: 'El nombre de usuario ya existe'
flash.auth.invalid-credentials: 'Credenciales incorrectas' flash.auth.invalid-credentials: 'Credenciales incorrectas'
flash.auth.account-linked-oauth: 'Cuenta vinculada a %s' flash.auth.account-linked-oauth: 'Cuenta vinculada a %s'
-8
View File
@@ -148,8 +148,6 @@ admin.versions: Versions
admin.ssh_keys: Clés SSH admin.ssh_keys: Clés SSH
admin.stats: Statistiques admin.stats: Statistiques
admin.actions: Actions 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.actions.git-gc: Nettoyage des dépôts git
admin.id: ID admin.id: ID
admin.user: Utilisateur 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.gist-deleted: 'Le gist a été supprimée'
flash.admin.invitation-created: 'L''invitation a été créée' flash.admin.invitation-created: 'L''invitation a été créée'
flash.admin.invitation-deleted: 'L''invitation a été supprimé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.username-exists: 'Nom d''utilisateur déjà utilisé'
flash.auth.invalid-credentials: 'Identifiants non valides' flash.auth.invalid-credentials: 'Identifiants non valides'
flash.auth.account-linked-oauth: 'Compte lié à %s' flash.auth.account-linked-oauth: 'Compte lié à %s'
-8
View File
@@ -165,8 +165,6 @@ admin.versions: Verziók
admin.ssh_keys: SSH kulcsok admin.ssh_keys: SSH kulcsok
admin.stats: Statisztikák admin.stats: Statisztikák
admin.actions: Műveletek 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.git-gc: Használatlan git repository-k eltávolítása
admin.actions.sync-previews: Gist előnézetek szinkronizálá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 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.gist-deleted: ''
flash.admin.invitation-created: '' flash.admin.invitation-created: ''
flash.admin.invitation-deleted: '' 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.username-exists: ''
flash.auth.invalid-credentials: '' flash.auth.invalid-credentials: ''
flash.auth.account-linked-oauth: '' flash.auth.account-linked-oauth: ''
-9
View File
@@ -186,8 +186,6 @@ admin.versions: 'Versioni'
admin.ssh_keys: 'Chiavi SSH' admin.ssh_keys: 'Chiavi SSH'
admin.stats: 'Statistiche' admin.stats: 'Statistiche'
admin.actions: 'Azioni' 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.git-gc: 'Esegui la garbage collection da tutti i repositories'
admin.actions.sync-previews: 'Sincronizza tutte le anteprime dei gists' 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' 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.gist-deleted: 'Il gist è stato eliminato'
flash.admin.invitation-created: 'L''invito è stato creato' flash.admin.invitation-created: 'L''invito è stato creato'
flash.admin.invitation-deleted: 'L''invito è stato eliminato' 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.username-exists: 'Il nome utente esiste già'
flash.auth.invalid-credentials: 'Credenziali errate' 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). 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 error.not-in-mfa-session: Non stai usando l'autenticazione a due fattori
admin.actions.sync-gist-languages: Sincronizza tutte le lingue dei gist 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-registred: Passkey %s registrata
flash.auth.passkey-deleted: Passkey eliminata 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' 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'
-9
View File
@@ -236,8 +236,6 @@ admin.versions: ''
admin.ssh_keys: '' admin.ssh_keys: ''
admin.stats: '' admin.stats: ''
admin.actions: '' admin.actions: ''
admin.actions.sync-fs: ''
admin.actions.sync-db: ''
admin.actions.git-gc: '' admin.actions.git-gc: ''
admin.actions.sync-previews: '' admin.actions.sync-previews: ''
admin.actions.reset-hooks: '' admin.actions.reset-hooks: ''
@@ -282,13 +280,6 @@ flash.admin.user-deleted: ''
flash.admin.gist-deleted: '' flash.admin.gist-deleted: ''
flash.admin.invitation-created: '' flash.admin.invitation-created: ''
flash.admin.invitation-deleted: '' 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.username-exists: ''
flash.auth.invalid-credentials: '' flash.auth.invalid-credentials: ''
-9
View File
@@ -366,15 +366,6 @@ flash.admin.user-deleted: 사용자가 삭제되었습니다
flash.admin.gist-deleted: Gist가 삭제되었습니다 flash.admin.gist-deleted: Gist가 삭제되었습니다
flash.admin.invitation-created: 초대가 생성되었습니다 flash.admin.invitation-created: 초대가 생성되었습니다
flash.admin.invitation-deleted: 초대가 삭제되었습니다 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.username-exists: 이미 존재하는 사용자 이름입니다
flash.auth.invalid-credentials: 잘못된 인증 정보입니다 flash.auth.invalid-credentials: 잘못된 인증 정보입니다
-8
View File
@@ -222,8 +222,6 @@ admin.versions: 'Wersje'
admin.ssh_keys: 'Klucze SSH' admin.ssh_keys: 'Klucze SSH'
admin.stats: 'Statystyki' admin.stats: 'Statystyki'
admin.actions: 'Akcje' 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.git-gc: 'Zbierz śmieci we wszystkich repozytoriach Git'
admin.actions.sync-previews: 'Synchronizuj podglądy wszystkich Gistów' admin.actions.sync-previews: 'Synchronizuj podglądy wszystkich Gistów'
admin.actions.reset-hooks: 'Zresetuj hooki serwera Git dla wszystkich repozytorió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.gist-deleted: 'Gist został usunięty'
flash.admin.invitation-created: 'Zaproszenie zostało stworzone' flash.admin.invitation-created: 'Zaproszenie zostało stworzone'
flash.admin.invitation-deleted: 'Zaproszenie zostało usunięte' 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.username-exists: 'Nazwa użytkownika już istnieje'
flash.auth.invalid-credentials: 'Niepoprawne dane logowania' flash.auth.invalid-credentials: 'Niepoprawne dane logowania'
-8
View File
@@ -146,8 +146,6 @@ admin.versions: Versões
admin.ssh_keys: Chaves SSH admin.ssh_keys: Chaves SSH
admin.stats: Estatísticas admin.stats: Estatísticas
admin.actions: Ações 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.actions.git-gc: Coletar lixo nos repositórios Git
admin.id: ID admin.id: ID
admin.user: Usuário admin.user: Usuário
@@ -172,7 +170,6 @@ admin.gists.private: Privado
admin.gists.nb-files: Núm. de arquivos admin.gists.nb-files: Núm. de arquivos
admin.gists.nb-likes: Núm. de curtidas admin.gists.nb-likes: Núm. de curtidas
admin.gists.delete_confirm: Quer excluir este gist? admin.gists.delete_confirm: Quer excluir este gist?
flash.admin.index-gists: ''
gist.header.embed: '' gist.header.embed: ''
gist.header.embed-help: '' gist.header.embed-help: ''
gist.new.url: '' gist.new.url: ''
@@ -228,11 +225,6 @@ flash.admin.user-deleted: ''
flash.admin.gist-deleted: '' flash.admin.gist-deleted: ''
flash.admin.invitation-created: '' flash.admin.invitation-created: ''
flash.admin.invitation-deleted: '' 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.username-exists: ''
flash.auth.invalid-credentials: '' flash.auth.invalid-credentials: ''
flash.auth.account-linked-oauth: '' flash.auth.account-linked-oauth: ''
-9
View File
@@ -148,8 +148,6 @@ admin.versions: Версии
admin.ssh_keys: Ключи SSH admin.ssh_keys: Ключи SSH
admin.stats: Статистика admin.stats: Статистика
admin.actions: Действия admin.actions: Действия
admin.actions.sync-fs: Синхронизировать фрагменты из файловой системы
admin.actions.sync-db: Синхронизировать фрагменты с базой данных
admin.actions.git-gc: Сборка мусора в репозиториях Git admin.actions.git-gc: Сборка мусора в репозиториях Git
admin.id: ID admin.id: ID
admin.user: Пользователь admin.user: Пользователь
@@ -227,12 +225,6 @@ flash.admin.user-deleted: 'Пользователь удалён'
flash.admin.gist-deleted: 'Фрагмент удалён' flash.admin.gist-deleted: 'Фрагмент удалён'
flash.admin.invitation-created: 'Приглашение создано' flash.admin.invitation-created: 'Приглашение создано'
flash.admin.invitation-deleted: 'Приглашение удалено' 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.username-exists: 'Такое имя пользователя уже занято'
flash.auth.invalid-credentials: 'Некорректные данные для входа' flash.auth.invalid-credentials: 'Некорректные данные для входа'
flash.auth.account-linked-oauth: 'Учётная запись связана с %s' flash.auth.account-linked-oauth: 'Учётная запись связана с %s'
@@ -334,7 +326,6 @@ auth.totp.help: TOTP — это метод двухфакторной аутен
auth.totp.use: Использовать TOTP auth.totp.use: Использовать TOTP
auth.totp.regenerate-recovery-codes: Сгенерировать коды восстановления заново auth.totp.regenerate-recovery-codes: Сгенерировать коды восстановления заново
auth.totp: Одноразовый пароль по времени (TOTP) auth.totp: Одноразовый пароль по времени (TOTP)
flash.admin.sync-gist-languages: Обновление языков фрагментов…
settings.token-created: Токен создан, обязательно сохраните его, повторно он показан не будет! settings.token-created: Токен создан, обязательно сохраните его, повторно он показан не будет!
settings.token-last-used: Последнее использование settings.token-last-used: Последнее использование
settings.token-no-expiration: Бессрочно settings.token-no-expiration: Бессрочно
-9
View File
@@ -186,8 +186,6 @@ admin.versions: Sürümler
admin.ssh_keys: SSH anahtarları admin.ssh_keys: SSH anahtarları
admin.stats: İstatistikler admin.stats: İstatistikler
admin.actions: Eylemler 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.git-gc: Tüm Git depolarındaki gereksiz verileri temizle
admin.actions.sync-previews: Tüm gist önizlemelerini senkronize et 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 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.gist-deleted: Gist silindi
flash.admin.invitation-created: Davetiye oluşturuldu flash.admin.invitation-created: Davetiye oluşturuldu
flash.admin.invitation-deleted: Davetiye silindi 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.username-exists: Kullanıcı adı zaten mevcut
flash.auth.invalid-credentials: Geçersiz kimlik bilgileri 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.header.account: Hesap
settings.style.no-soft-wrap: Yumuşak Satır Kaydırma Yok settings.style.no-soft-wrap: Yumuşak Satır Kaydırma Yok
auth.totp: Zamana Dayalı Tek Kullanımlık Parola (TOTP) 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. 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. 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 auth.totp.enter-recovery-key: veya cihazınızı kaybettiyseniz kurtarma anahtarını kullanın
-8
View File
@@ -187,8 +187,6 @@ admin.versions: Версії
admin.ssh_keys: Ключі SSH admin.ssh_keys: Ключі SSH
admin.stats: Статистика admin.stats: Статистика
admin.actions: Дії admin.actions: Дії
admin.actions.sync-fs: Синхронізувати gists з файлової системи
admin.actions.sync-db: Синхронізувати gists з базою даних
admin.actions.git-gc: Збір сміття з репозиторіїв Git admin.actions.git-gc: Збір сміття з репозиторіїв Git
admin.actions.sync-previews: Синхронізувати всі gists перегляди admin.actions.sync-previews: Синхронізувати всі gists перегляди
admin.actions.reset-hooks: Скинути серверні Git hooks для всіх репозиторіїв admin.actions.reset-hooks: Скинути серверні Git hooks для всіх репозиторіїв
@@ -231,12 +229,6 @@ flash.admin.user-deleted: Користувач був видалений
flash.admin.gist-deleted: Gist був видалений flash.admin.gist-deleted: Gist був видалений
flash.admin.invitation-created: Запрошення було створено flash.admin.invitation-created: Запрошення було створено
flash.admin.invitation-deleted: Запрошення було видалено 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.username-exists: Це ім'я користувача вже існує
flash.auth.invalid-credentials: Недійсні облікові дані flash.auth.invalid-credentials: Недійсні облікові дані
-9
View File
@@ -177,8 +177,6 @@ admin.versions: 版本
admin.ssh_keys: SSH 密钥 admin.ssh_keys: SSH 密钥
admin.stats: 状态 admin.stats: 状态
admin.actions: 动作 admin.actions: 动作
admin.actions.sync-fs: 从文件系统同步 Gist
admin.actions.sync-db: 从数据库同步 Gist
admin.actions.git-gc: 对 Git 仓库执行垃圾回收 admin.actions.git-gc: 对 Git 仓库执行垃圾回收
admin.id: ID admin.id: ID
admin.user: 用户 admin.user: 用户
@@ -257,12 +255,6 @@ flash.admin.user-deleted: '用户已被删除'
flash.admin.gist-deleted: 'Gist 已被删除' flash.admin.gist-deleted: 'Gist 已被删除'
flash.admin.invitation-created: '邀请已被创建' flash.admin.invitation-created: '邀请已被创建'
flash.admin.invitation-deleted: '邀请已被删除' 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.username-exists: '用户名已存在'
flash.auth.invalid-credentials: '无效的凭证' flash.auth.invalid-credentials: '无效的凭证'
flash.auth.account-linked-oauth: '帐户已关联到 %s' flash.auth.account-linked-oauth: '帐户已关联到 %s'
@@ -330,7 +322,6 @@ gist.new.topics: 主题(用空格分隔)
validation.invalid-gist-topics: 无效的 Gists 主题,它们必须以字母或数字开头,长度不超过50个字符,并且可以包含连字符 validation.invalid-gist-topics: 无效的 Gists 主题,它们必须以字母或数字开头,长度不超过50个字符,并且可以包含连字符
gist.list.topic-results-topic: '%s 与主题匹配的所有 Gists' gist.list.topic-results-topic: '%s 与主题匹配的所有 Gists'
admin.actions.sync-gist-languages: 同步所有 gists 语言 admin.actions.sync-gist-languages: 同步所有 gists 语言
flash.admin.sync-gist-languages: 正在同步 Gist 语言...
gist.list.topic-results: 所有匹配主题的 Gist gist.list.topic-results: 所有匹配主题的 Gist
gist.search.help.topic: 具有给定主题的 Gists gist.search.help.topic: 具有给定主题的 Gists
gist.search.placeholder.title: 标题 gist.search.placeholder.title: 标题
-8
View File
@@ -157,8 +157,6 @@ admin.versions: 版本
admin.ssh_keys: SSH 金鑰 admin.ssh_keys: SSH 金鑰
admin.stats: 統計 admin.stats: 統計
admin.actions: 操作 admin.actions: 操作
admin.actions.sync-fs: 從系統同步 Gists
admin.actions.sync-db: 從資料庫同步 Gists
admin.actions.git-gc: 清理所有的 git 儲存庫 admin.actions.git-gc: 清理所有的 git 儲存庫
admin.actions.sync-previews: 同步所有 Gists 預覽 admin.actions.sync-previews: 同步所有 Gists 預覽
admin.actions.reset-hooks: 重置 Git 伺服器所有儲存庫的 Git hooks admin.actions.reset-hooks: 重置 Git 伺服器所有儲存庫的 Git hooks
@@ -226,12 +224,6 @@ flash.admin.user-deleted: ''
flash.admin.gist-deleted: '' flash.admin.gist-deleted: ''
flash.admin.invitation-created: '' flash.admin.invitation-created: ''
flash.admin.invitation-deleted: '' 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.username-exists: ''
flash.auth.invalid-credentials: '' flash.auth.invalid-credentials: ''
flash.auth.account-linked-oauth: '' flash.auth.account-linked-oauth: ''
+25 -3
View File
@@ -23,9 +23,14 @@ func NewValidator() *OpengistValidator {
_ = v.RegisterValidation("alphanumdashunderorempty", validateAlphaNumDashUnderOrEmpty) _ = v.RegisterValidation("alphanumdashunderorempty", validateAlphaNumDashUnderOrEmpty)
_ = v.RegisterValidation("gisttopics", validateGistTopics) _ = v.RegisterValidation("gisttopics", validateGistTopics)
_ = v.RegisterValidation("expirationdate", validateExpirationDate) _ = v.RegisterValidation("expirationdate", validateExpirationDate)
_ = v.RegisterValidation("themecolor", validateThemeColor)
return &OpengistValidator{v} return &OpengistValidator{v}
} }
var ThemeColors = []string{
"red", "amber", "emerald", "sky", "indigo", "purple", "neutral",
}
func (cv *OpengistValidator) Validate(i interface{}) error { func (cv *OpengistValidator) Validate(i interface{}) error {
return cv.v.Struct(i) return cv.v.Struct(i)
} }
@@ -69,7 +74,7 @@ func validateReservedKeywords(fl validator.FieldLevel) bool {
name := fl.Field().String() name := fl.Field().String()
restrictedNames := map[string]struct{}{} 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{}{} restrictedNames[restrictedName] = struct{}{}
} }
@@ -79,7 +84,9 @@ func validateReservedKeywords(fl validator.FieldLevel) bool {
} }
func validateAlphaNumDash(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 { func validateAlphaNumDashOrEmpty(fl validator.FieldLevel) bool {
@@ -87,13 +94,28 @@ func validateAlphaNumDashOrEmpty(fl validator.FieldLevel) bool {
} }
func validateAlphaNumDashUnder(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 { func validateAlphaNumDashUnderOrEmpty(fl validator.FieldLevel) bool {
return regexp.MustCompile(`^$|^[a-zA-Z0-9-_]+$`).MatchString(fl.Field().String()) 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 { func validateGistTopics(fl validator.FieldLevel) bool {
topicsInput := fl.Field().String() topicsInput := fl.Field().String()
if topicsInput == "" { if topicsInput == "" {
+54
View File
@@ -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"))
}
+82 -27
View File
@@ -2,59 +2,114 @@ package admin
import ( import (
"github.com/thomiceli/opengist/internal/actions" "github.com/thomiceli/opengist/internal/actions"
"github.com/thomiceli/opengist/internal/config"
"github.com/thomiceli/opengist/internal/web/context" "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 { func AdminSyncReposFromFS(ctx *context.Context) error {
ctx.AddFlash(ctx.Tr("flash.admin.sync-fs"), "success") return runAdminAction(ctx, actions.SyncReposFromFS, "flash.admin.sync-fs")
go actions.RunOnce(actions.SyncReposFromFS)
return ctx.RedirectTo("/admin-panel")
} }
func AdminSyncReposFromDB(ctx *context.Context) error { func AdminSyncReposFromDB(ctx *context.Context) error {
ctx.AddFlash(ctx.Tr("flash.admin.sync-db"), "success") return runAdminAction(ctx, actions.SyncReposFromDB, "flash.admin.sync-db")
go actions.RunOnce(actions.SyncReposFromDB)
return ctx.RedirectTo("/admin-panel")
} }
func AdminGcRepos(ctx *context.Context) error { func AdminGcRepos(ctx *context.Context) error {
ctx.AddFlash(ctx.Tr("flash.admin.git-gc"), "success") return runAdminAction(ctx, actions.GitGcRepos, "flash.admin.git-gc")
go actions.RunOnce(actions.GitGcRepos)
return ctx.RedirectTo("/admin-panel")
} }
func AdminSyncGistPreviews(ctx *context.Context) error { func AdminSyncGistPreviews(ctx *context.Context) error {
ctx.AddFlash(ctx.Tr("flash.admin.sync-previews"), "success") return runAdminAction(ctx, actions.SyncGistPreviews, "flash.admin.sync-previews")
go actions.RunOnce(actions.SyncGistPreviews)
return ctx.RedirectTo("/admin-panel")
} }
func AdminResetHooks(ctx *context.Context) error { func AdminResetHooks(ctx *context.Context) error {
ctx.AddFlash(ctx.Tr("flash.admin.reset-hooks"), "success") return runAdminAction(ctx, actions.ResetHooks, "flash.admin.reset-hooks")
go actions.RunOnce(actions.ResetHooks)
return ctx.RedirectTo("/admin-panel")
} }
func AdminIndexGists(ctx *context.Context) error { func AdminIndexGists(ctx *context.Context) error {
ctx.AddFlash(ctx.Tr("flash.admin.index-gists"), "success") return runAdminAction(ctx, actions.IndexGists, "flash.admin.index-gists")
go actions.RunOnce(actions.IndexGists)
return ctx.RedirectTo("/admin-panel")
} }
func AdminSyncGistLanguages(ctx *context.Context) error { func AdminSyncGistLanguages(ctx *context.Context) error {
ctx.AddFlash(ctx.Tr("flash.admin.sync-gist-languages"), "success") return runAdminAction(ctx, actions.SyncGistLanguages, "flash.admin.sync-gist-languages")
go actions.RunOnce(actions.SyncGistLanguages)
return ctx.RedirectTo("/admin-panel")
} }
func AdminDeleteExpiredGists(ctx *context.Context) error { func AdminDeleteExpiredGists(ctx *context.Context) error {
ctx.AddFlash(ctx.Tr("flash.admin.delete-expired-gists"), "success") return runAdminAction(ctx, actions.DeleteExpiredGists, "flash.admin.delete-expired-gists")
go actions.RunOnce(actions.DeleteExpiredGists)
return ctx.RedirectTo("/admin-panel")
} }
func AdminSyncSSHKeys(ctx *context.Context) error { func AdminSyncSSHKeys(ctx *context.Context) error {
ctx.AddFlash(ctx.Tr("flash.admin.sync-ssh-keys"), "success") return runAdminAction(ctx, actions.SyncSSHKeys, "flash.admin.sync-ssh-keys")
go actions.RunOnce(actions.SyncSSHKeys) }
return ctx.RedirectTo("/admin-panel")
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)
}
})
}
+6 -4
View File
@@ -44,6 +44,8 @@ func AdminIndex(ctx *context.Context) error {
} }
ctx.SetData("countKeys", countKeys) 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("syncReposFromFS", actions.IsRunning(actions.SyncReposFromFS))
ctx.SetData("syncReposFromDB", actions.IsRunning(actions.SyncReposFromDB)) ctx.SetData("syncReposFromDB", actions.IsRunning(actions.SyncReposFromDB))
ctx.SetData("gitGcRepos", actions.IsRunning(actions.GitGcRepos)) ctx.SetData("gitGcRepos", actions.IsRunning(actions.GitGcRepos))
@@ -108,7 +110,7 @@ func AdminUserDelete(ctx *context.Context) error {
opengistssh.SyncAuthorizedKeysLogged() opengistssh.SyncAuthorizedKeysLogged()
ctx.AddFlash(ctx.Tr("flash.admin.user-deleted"), "success") 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 { func AdminGistDelete(ctx *context.Context) error {
@@ -124,7 +126,7 @@ func AdminGistDelete(ctx *context.Context) error {
gist.RemoveFromIndex() gist.RemoveFromIndex()
ctx.AddFlash(ctx.Tr("flash.admin.gist-deleted"), "success") 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 { 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") 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 { 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") ctx.AddFlash(ctx.Tr("flash.admin.invitation-deleted"), "success")
return ctx.RedirectTo("/admin-panel/invitations") return ctx.RedirectTo("/-/admin-panel/invitations")
} }
+24 -23
View File
@@ -20,11 +20,12 @@ func TestAdminPages(t *testing.T) {
defer webtest.Teardown(t) defer webtest.Teardown(t)
urls := []string{ urls := []string{
"/admin-panel", "/-/admin-panel",
"/admin-panel/users", "/-/admin-panel/users",
"/admin-panel/gists", "/-/admin-panel/gists",
"/admin-panel/invitations", "/-/admin-panel/invitations",
"/admin-panel/configuration", "/-/admin-panel/configuration",
"/-/admin-panel/actions",
} }
s.Register(t, "thomas") s.Register(t, "thomas")
@@ -67,12 +68,12 @@ func TestAdminSetConfig(t *testing.T) {
s.Register(t, "nonadmin") s.Register(t, "nonadmin")
t.Run("NoUser", func(t *testing.T) { 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) { t.Run("NonAdminUser", func(t *testing.T) {
s.Login(t, "nonadmin") 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) { t.Run("AdminUser", func(t *testing.T) {
@@ -83,13 +84,13 @@ func TestAdminSetConfig(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, "0", val) 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) val, err = db.GetSetting(setting)
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, "1", val) 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) val, err = db.GetSetting(setting)
require.NoError(t, err) require.NoError(t, err)
@@ -110,12 +111,12 @@ func TestAdminPagination(t *testing.T) {
t.Run("Pagination", func(t *testing.T) { t.Run("Pagination", func(t *testing.T) {
s.Login(t, "thomas") s.Login(t, "thomas")
s.Request(t, "GET", "/admin-panel/users", 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=2", nil, 200)
s.Request(t, "GET", "/admin-panel/users?page=3", nil, 404) 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=0", nil, 200)
s.Request(t, "GET", "/admin-panel/users?page=-1", 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?page=a", nil, 200)
}) })
} }
@@ -147,11 +148,11 @@ func TestAdminUserOperations(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, int64(2), count) 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.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{}) count, err = db.CountAll(db.User{})
require.NoError(t, err) 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())) _, err = os.Stat(filepath.Join(config.GetHomeDir(), git.ReposDirectory, "nonadmin", gist1Db.Identifier()))
require.NoError(t, err) 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.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{}) count, err = db.CountAll(db.Gist{})
require.NoError(t, err) require.NoError(t, err)
@@ -217,7 +218,7 @@ func TestAdminInvitationOperations(t *testing.T) {
t.Run("Invitation", func(t *testing.T) { t.Run("Invitation", func(t *testing.T) {
s.Login(t, "thomas") s.Login(t, "thomas")
s.Request(t, "POST", "/admin-panel/invitations", url.Values{ s.Request(t, "POST", "/-/admin-panel/invitations", url.Values{
"nbMax": {""}, "nbMax": {""},
"expiredAtUnix": {""}, "expiredAtUnix": {""},
}, 302) }, 302)
@@ -228,7 +229,7 @@ func TestAdminInvitationOperations(t *testing.T) {
require.Equal(t, uint(10), invitation1.NbMax) require.Equal(t, uint(10), invitation1.NbMax)
require.InDelta(t, time.Now().Unix()+604800, invitation1.ExpiresAt, 10) 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"}, "nbMax": {"aa"},
"expiredAtUnix": {"1735722000"}, "expiredAtUnix": {"1735722000"},
}, 302) }, 302)
@@ -242,7 +243,7 @@ func TestAdminInvitationOperations(t *testing.T) {
NbMax: 10, NbMax: 10,
}) })
s.Request(t, "POST", "/admin-panel/invitations", url.Values{ s.Request(t, "POST", "/-/admin-panel/invitations", url.Values{
"nbMax": {"20"}, "nbMax": {"20"},
"expiredAtUnix": {"1735722000"}, "expiredAtUnix": {"1735722000"},
}, 302) }, 302)
@@ -260,7 +261,7 @@ func TestAdminInvitationOperations(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, int64(3), count) 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{}) count, err = db.CountAll(db.Invitation{})
require.NoError(t, err) require.NoError(t, err)
+9 -9
View File
@@ -48,7 +48,7 @@ func Oauth(ctx *context.Context) error {
provider, err := oauth.DefineProvider(providerStr, opengistUrl) provider, err := oauth.DefineProvider(providerStr, opengistUrl)
if err != nil { if err != nil {
ctx.AddFlash(ctx.Tr("error.oauth-unsupported"), "error") ctx.AddFlash(ctx.Tr("error.oauth-unsupported"), "error")
return ctx.Redirect(302, "/login") return ctx.Redirect(302, "/-/login")
} }
if err = provider.RegisterProvider(); err != nil { if err = provider.RegisterProvider(); err != nil {
@@ -63,7 +63,7 @@ func OauthCallback(ctx *context.Context) error {
provider, err := oauth.CompleteUserAuth(ctx) provider, err := oauth.CompleteUserAuth(ctx)
if err != nil { if err != nil {
ctx.AddFlash(fmt.Sprintf("%s: %s", ctx.Tr("auth.oauth.no-provider"), err.Error()), "error") 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 currUser := ctx.User
@@ -74,7 +74,7 @@ func OauthCallback(ctx *context.Context) error {
// check if this OAuth account is already linked to another user // check if this OAuth account is already linked to another user
if existingUser, err := db.GetUserByProvider(user.UserID, provider.GetProvider()); err == nil && existingUser != nil { 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") ctx.AddFlash(ctx.Tr("flash.auth.oauth-already-linked", config.C.OIDCProviderName), "error")
return ctx.RedirectTo("/settings") return ctx.RedirectTo("/-/settings")
} }
provider.UpdateUserDB(currUser) 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") 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()) userDB, err := db.GetUserByProvider(user.UserID, provider.GetProvider())
@@ -92,7 +92,7 @@ func OauthCallback(ctx *context.Context) error {
if err != nil { if err != nil {
if ctx.GetData("DisableSignup") == true { if ctx.GetData("DisableSignup") == true {
ctx.AddFlash(ctx.Tr("error.signup-disabled"), "error") ctx.AddFlash(ctx.Tr("error.signup-disabled"), "error")
return ctx.Redirect(302, "/login") return ctx.Redirect(302, "/-/login")
} }
if !errors.Is(err, gorm.ErrRecordNotFound) { if !errors.Is(err, gorm.ErrRecordNotFound) {
@@ -136,7 +136,7 @@ func OauthCallback(ctx *context.Context) error {
func OauthRegister(ctx *context.Context) error { func OauthRegister(ctx *context.Context) error {
if ctx.GetData("DisableSignup") == true { if ctx.GetData("DisableSignup") == true {
ctx.AddFlash(ctx.Tr("error.signup-disabled"), "error") ctx.AddFlash(ctx.Tr("error.signup-disabled"), "error")
return ctx.Redirect(302, "/login") return ctx.Redirect(302, "/-/login")
} }
sess := ctx.GetSession() sess := ctx.GetSession()
@@ -154,7 +154,7 @@ func OauthRegister(ctx *context.Context) error {
func ProcessOauthRegister(ctx *context.Context) error { func ProcessOauthRegister(ctx *context.Context) error {
if ctx.GetData("DisableSignup") == true { if ctx.GetData("DisableSignup") == true {
ctx.AddFlash(ctx.Tr("error.signup-disabled"), "error") ctx.AddFlash(ctx.Tr("error.signup-disabled"), "error")
return ctx.Redirect(302, "/login") return ctx.Redirect(302, "/-/login")
} }
sess := ctx.GetSession() 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") 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")
} }
+1 -1
View File
@@ -103,6 +103,6 @@ func TestOIDCLoginPKCE(t *testing.T) {
}) })
defer resp.Body.Close() defer resp.Body.Close()
require.Equal(t, "/login", resp.Request.URL.Path) require.Equal(t, "/-/login", resp.Request.URL.Path)
}) })
} }
+2 -2
View File
@@ -131,7 +131,7 @@ func ProcessLogin(ctx *context.Context) error {
if errors.As(err, &authErr) { if errors.As(err, &authErr) {
log.Warn().Msg("Invalid HTTP authentication attempt from " + ctx.RealIP()) log.Warn().Msg("Invalid HTTP authentication attempt from " + ctx.RealIP())
ctx.AddFlash(ctx.Tr("flash.auth.invalid-credentials"), "error") 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) return ctx.ErrorRes(500, "Authentication system error", nil)
} }
@@ -159,5 +159,5 @@ func ProcessLogin(ctx *context.Context) error {
func Logout(ctx *context.Context) error { func Logout(ctx *context.Context) error {
ctx.DeleteSession() ctx.DeleteSession()
ctx.DeleteCsrfCookie() ctx.DeleteCsrfCookie()
return ctx.RedirectTo("/all") return ctx.RedirectTo("/-/all")
} }
+36 -31
View File
@@ -16,7 +16,7 @@ func TestRegisterPage(t *testing.T) {
s.Register(t, "thomas") s.Register(t, "thomas")
t.Run("Form", func(t *testing.T) { 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{ s.TestCtxData(t, echo.Map{
"isLoginPage": false, "isLoginPage": false,
"disableForm": false, "disableForm": false,
@@ -26,10 +26,10 @@ func TestRegisterPage(t *testing.T) {
t.Run("FormDisabled", func(t *testing.T) { t.Run("FormDisabled", func(t *testing.T) {
s.Login(t, "thomas") 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.Logout()
s.Request(t, "GET", "/register", nil, 200) s.Request(t, "GET", "/-/register", nil, 200)
s.TestCtxData(t, echo.Map{ s.TestCtxData(t, echo.Map{
"disableSignup": true, "disableSignup": true,
}) })
@@ -37,9 +37,9 @@ func TestRegisterPage(t *testing.T) {
t.Run("FormDisabledWithInviteCode", func(t *testing.T) { t.Run("FormDisabledWithInviteCode", func(t *testing.T) {
s.Login(t, "thomas") 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"}, "nbMax": {"10"},
"expiredAtUnix": {""}, "expiredAtUnix": {""},
}, 302) }, 302)
@@ -49,11 +49,11 @@ func TestRegisterPage(t *testing.T) {
s.Logout() s.Logout()
s.Request(t, "GET", "/register", nil, 200) s.Request(t, "GET", "/-/register", nil, 200)
s.TestCtxData(t, echo.Map{ s.TestCtxData(t, echo.Map{
"disableSignup": true, "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{ s.TestCtxData(t, echo.Map{
"disableSignup": false, "disableSignup": false,
}) })
@@ -73,7 +73,7 @@ func TestProcessRegister(t *testing.T) {
require.True(t, user.IsAdmin) require.True(t, user.IsAdmin)
s.Logout() 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") user, err = db.GetUserByUsername("seconduser")
require.NoError(t, err) require.NoError(t, err)
require.False(t, user.IsAdmin) require.False(t, user.IsAdmin)
@@ -81,27 +81,32 @@ func TestProcessRegister(t *testing.T) {
}) })
t.Run("DuplicateUsername", func(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.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() s.Logout()
}) })
t.Run("InvalidUsername", func(t *testing.T) { 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: "", Password: "password123"}, 200)
s.Request(t, "POST", "/register", db.UserDTO{Username: "aze@", 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) { 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) { t.Run("RegisterDisabled", func(t *testing.T) {
s.Login(t, "thomas") 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.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") exists, err := db.UserExists("blocked")
require.NoError(t, err) require.NoError(t, err)
@@ -110,8 +115,8 @@ func TestProcessRegister(t *testing.T) {
t.Run("RegisterWithInvitationCode", func(t *testing.T) { t.Run("RegisterWithInvitationCode", func(t *testing.T) {
s.Login(t, "thomas") 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"}, "nbMax": {"10"},
"expiredAtUnix": {""}, "expiredAtUnix": {""},
}, 302) }, 302)
@@ -124,7 +129,7 @@ func TestProcessRegister(t *testing.T) {
s.Logout() 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") user, err := db.GetUserByUsername("inviteduser")
require.NoError(t, err) require.NoError(t, err)
@@ -142,7 +147,7 @@ func TestLoginPage(t *testing.T) {
s.Register(t, "thomas") s.Register(t, "thomas")
t.Run("Form", func(t *testing.T) { 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{ s.TestCtxData(t, echo.Map{
"isLoginPage": true, "isLoginPage": true,
"disableForm": false, "disableForm": false,
@@ -151,10 +156,10 @@ func TestLoginPage(t *testing.T) {
t.Run("FormDisabled", func(t *testing.T) { t.Run("FormDisabled", func(t *testing.T) {
s.Login(t, "thomas") 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.Logout()
s.Request(t, "GET", "/login", nil, 200) s.Request(t, "GET", "/-/login", nil, 200)
s.TestCtxData(t, echo.Map{ s.TestCtxData(t, echo.Map{
"disableForm": true, "disableForm": true,
}) })
@@ -168,7 +173,7 @@ func TestProcessLogin(t *testing.T) {
s.Register(t, "thomas") s.Register(t, "thomas")
t.Run("ValidCredentials", func(t *testing.T) { 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.Equal(t, "/", resp.Header.Get("Location"))
require.NotEmpty(t, s.SessionCookie) require.NotEmpty(t, s.SessionCookie)
require.Equal(t, "thomas", s.User().Username) require.Equal(t, "thomas", s.User().Username)
@@ -177,28 +182,28 @@ func TestProcessLogin(t *testing.T) {
}) })
t.Run("InvalidPassword", func(t *testing.T) { t.Run("InvalidPassword", func(t *testing.T) {
resp := s.Request(t, "POST", "/login", db.UserDTO{Username: "thomas", Password: "wrongpassword"}, 302) resp := s.Request(t, "POST", "/-/login", db.UserDTO{Username: "thomas", Password: "wrongpassword"}, 302)
require.Equal(t, "/login", resp.Header.Get("Location")) require.Equal(t, "/-/login", resp.Header.Get("Location"))
require.Nil(t, s.User()) require.Nil(t, s.User())
}) })
t.Run("NonExistentUser", func(t *testing.T) { t.Run("NonExistentUser", func(t *testing.T) {
resp := s.Request(t, "POST", "/login", db.UserDTO{Username: "nonexistent", Password: "password"}, 302) resp := s.Request(t, "POST", "/-/login", db.UserDTO{Username: "nonexistent", Password: "password"}, 302)
require.Equal(t, "/login", resp.Header.Get("Location")) require.Equal(t, "/-/login", resp.Header.Get("Location"))
require.Nil(t, s.User()) require.Nil(t, s.User())
}) })
t.Run("EmptyCredentials", func(t *testing.T) { 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()) require.Nil(t, s.User())
}) })
t.Run("LoginFormDisabled", func(t *testing.T) { t.Run("LoginFormDisabled", func(t *testing.T) {
s.Login(t, "thomas") 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.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()) require.Nil(t, s.User())
}) })
} }
@@ -213,8 +218,8 @@ func TestLogout(t *testing.T) {
s.Login(t, "thomas") s.Login(t, "thomas")
require.Equal(t, "thomas", s.User().Username) require.Equal(t, "thomas", s.User().Username)
resp := s.Request(t, "GET", "/logout", nil, 302) resp := s.Request(t, "GET", "/-/logout", nil, 302)
require.Equal(t, "/all", resp.Header.Get("Location")) require.Equal(t, "/-/all", resp.Header.Get("Location"))
require.Nil(t, s.User()) require.Nil(t, s.User())
s.Request(t, "GET", "/", nil, 302) s.Request(t, "GET", "/", nil, 302)
}) })
+9 -6
View File
@@ -15,7 +15,7 @@ func BeginTotp(ctx *context.Context) error {
return ctx.ErrorRes(500, "Cannot check for user MFA", err) return ctx.ErrorRes(500, "Cannot check for user MFA", err)
} else if hasTotp { } else if hasTotp {
ctx.AddFlash(ctx.Tr("auth.totp.already-enabled"), "error") 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)) ogUrl, err := url.Parse(ctx.GetData("baseHttpUrl").(string))
@@ -36,6 +36,7 @@ func BeginTotp(ctx *context.Context) error {
ctx.SetData("totpSecret", totpSecret) ctx.SetData("totpSecret", totpSecret)
ctx.SetData("totpQrcode", qrcode) ctx.SetData("totpQrcode", qrcode)
ctx.SetData("settingsHeaderPage", "authentication")
return ctx.Html("totp.html") 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) return ctx.ErrorRes(500, "Cannot check for user MFA", err)
} else if hasTotp { } else if hasTotp {
ctx.AddFlash(ctx.Tr("auth.totp.already-enabled"), "error") ctx.AddFlash(ctx.Tr("auth.totp.already-enabled"), "error")
return ctx.RedirectTo("/settings/mfa") return ctx.RedirectTo("/-/settings/authentication")
} }
dto := &db.TOTPDTO{} dto := &db.TOTPDTO{}
@@ -58,7 +59,7 @@ func FinishTotp(ctx *context.Context) error {
if err := ctx.Validate(dto); err != nil { if err := ctx.Validate(dto); err != nil {
ctx.AddFlash("Invalid secret", "error") ctx.AddFlash("Invalid secret", "error")
return ctx.RedirectTo("/settings/totp/generate") return ctx.RedirectTo("/-/settings/totp/generate")
} }
sess := ctx.GetSession() sess := ctx.GetSession()
@@ -70,7 +71,7 @@ func FinishTotp(ctx *context.Context) error {
if !totp.Validate(dto.Code, secret) { if !totp.Validate(dto.Code, secret) {
ctx.AddFlash(ctx.Tr("auth.totp.invalid-code"), "error") ctx.AddFlash(ctx.Tr("auth.totp.invalid-code"), "error")
return ctx.RedirectTo("/settings/totp/generate") return ctx.RedirectTo("/-/settings/totp/generate")
} }
userTotp := &db.TOTP{ userTotp := &db.TOTP{
@@ -95,6 +96,7 @@ func FinishTotp(ctx *context.Context) error {
ctx.SaveSession(sess) ctx.SaveSession(sess)
ctx.SetData("recoveryCodes", codes) ctx.SetData("recoveryCodes", codes)
ctx.SetData("settingsHeaderPage", "authentication")
return ctx.Html("totp.html") 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") ctx.AddFlash(ctx.Tr("auth.totp.code-used", dto.Code), "warning")
redirectUrl = "/settings/mfa" redirectUrl = "/-/settings/authentication"
} }
sess.Values["user"] = userId sess.Values["user"] = userId
@@ -158,7 +160,7 @@ func DisableTotp(ctx *context.Context) error {
} }
ctx.AddFlash(ctx.Tr("auth.totp.disabled"), "success") ctx.AddFlash(ctx.Tr("auth.totp.disabled"), "success")
return ctx.RedirectTo("/settings/mfa") return ctx.RedirectTo("/-/settings/authentication")
} }
func RegenerateTotpRecoveryCodes(ctx *context.Context) error { func RegenerateTotpRecoveryCodes(ctx *context.Context) error {
@@ -174,5 +176,6 @@ func RegenerateTotpRecoveryCodes(ctx *context.Context) error {
} }
ctx.SetData("recoveryCodes", codes) ctx.SetData("recoveryCodes", codes)
ctx.SetData("settingsHeaderPage", "authentication")
return ctx.Html("totp.html") return ctx.Html("totp.html")
} }
+94 -42
View File
@@ -21,31 +21,41 @@ func AllGists(ctx *context.Context) error {
fromUserStr := ctx.Param("user") fromUserStr := ctx.Param("user")
userLogged := ctx.User userLogged := ctx.User
pageInt := handlers.GetPage(ctx) pageInt := handlers.GetPage(ctx)
mode := ctx.GetData("mode")
sort := "created" sort := "created"
order := "desc" order := "desc"
if userLogged != nil { // Some feeds (e.g. "recently liked" / "recently forked") have a fixed order:
if style := userLogged.GetStyle(); style != nil { // creation time descending, ignoring the user's default-sort style and the
if style.DefaultSort == "updated" { // sort/order query params. The template hides the sort dropdown when sortable
sort = "updated" // is false.
} sortable := mode != "all-liked" && mode != "all-forked"
if style.DefaultOrder == "asc" { ctx.SetData("sortable", sortable)
order = "asc"
if sortable {
if userLogged != nil {
if style := userLogged.GetStyle(); style != nil {
if style.DefaultSort == "updated" {
sort = "updated"
}
if style.DefaultOrder == "asc" {
order = "asc"
}
} }
} }
}
if ctx.QueryParam("sort") == "updated" { if ctx.QueryParam("sort") == "updated" {
sort = "updated" sort = "updated"
} else if ctx.QueryParam("sort") == "created" { } else if ctx.QueryParam("sort") == "created" {
sort = "created" sort = "created"
} }
if ctx.QueryParam("order") == "asc" { if ctx.QueryParam("order") == "asc" {
order = "asc" order = "asc"
} else if ctx.QueryParam("order") == "desc" { } else if ctx.QueryParam("order") == "desc" {
order = "desc" order = "desc"
}
} }
sortText := ctx.TrH("gist.list.sort-by-" + sort) sortText := ctx.TrH("gist.list.sort-by-" + sort)
@@ -67,24 +77,50 @@ func AllGists(ctx *context.Context) error {
currentUserId = 0 currentUserId = 0
} }
mode := ctx.GetData("mode")
if fromUserStr == "" { if fromUserStr == "" {
switch mode { switch mode {
case "search": case "search":
ctx.SetData("htmlTitle", ctx.TrH("gist.list.search-results")) ctx.SetData("htmlTitle", ctx.TrH("gist.list.search-results"))
ctx.SetData("searchQuery", ctx.QueryParam("q")) ctx.SetData("searchQuery", ctx.QueryParam("q"))
pagination.Query = ctx.QueryParam("q") pagination.Query = ctx.QueryParam("q")
urlPage = "search" urlPage = "-/search"
gists, err = db.GetAllGistsFromSearch(currentUserId, ctx.QueryParam("q"), pageInt-1, sort, order, "") gists, err = db.GetAllGistsFromSearch(currentUserId, ctx.QueryParam("q"), pageInt-1, sort, order, "")
case "topics": case "topics":
ctx.SetData("htmlTitle", ctx.TrH("gist.list.topic-results-topic", ctx.Param("topic"))) ctx.SetData("htmlTitle", ctx.TrH("gist.list.topic-results-topic", ctx.Param("topic")))
ctx.SetData("topic", ctx.Param("topic")) ctx.SetData("topic", ctx.Param("topic"))
urlPage = "topics/" + ctx.Param("topic") urlPage = "-/topics/" + ctx.Param("topic")
gists, err = db.GetAllGistsFromSearch(currentUserId, "", pageInt-1, sort, order, 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": case "all":
ctx.SetData("currentPage", "all")
ctx.SetData("htmlTitle", ctx.TrH("gist.list.all")) ctx.SetData("htmlTitle", ctx.TrH("gist.list.all"))
urlPage = "all" urlPage = "-/all"
gists, err = db.GetAllGistsForCurrentUser(currentUserId, nil, pageInt-1, sort, order, 11, 10)
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 { } else {
var fromUser *db.User var fromUser *db.User
@@ -99,6 +135,12 @@ func AllGists(ctx *context.Context) error {
} }
ctx.SetData("fromUser", fromUser) 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 { if countFromUser, err := db.CountAllGistsFromUser(fromUser.ID, currentUserId); err != nil {
return ctx.ErrorRes(500, "Error counting gists", err) return ctx.ErrorRes(500, "Error counting gists", err)
} else { } else {
@@ -134,25 +176,7 @@ func AllGists(ctx *context.Context) error {
} else { } else {
ctx.SetData("languages", languages) ctx.SetData("languages", languages)
} }
title := ctx.QueryParam("title") title, language, visibility, topics := readGistFilters(ctx, pagination)
language := ctx.QueryParam("language")
visibility := ctx.QueryParam("visibility")
topicsStr := ctx.QueryParam("topics")
topics := strings.Fields(topicsStr)
if len(topics) > 10 {
topics = topics[:10]
}
slices.Sort(topics)
topics = slices.Compact(topics)
pagination.Title = title
pagination.Language = language
pagination.Visibility = visibility
pagination.Topics = topicsStr
ctx.SetData("title", title)
ctx.SetData("language", language)
ctx.SetData("visibility", visibility)
ctx.SetData("topics", topicsStr)
ctx.SetData("htmlTitle", ctx.TrH("gist.list.all-from", fromUserStr)) 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) gists, count, err = db.GetAllGistsFromUser(fromUser.ID, currentUserId, title, language, visibility, topics, pageInt-1, sort, order)
ctx.SetData("countFromUser", count) ctx.SetData("countFromUser", count)
@@ -178,6 +202,34 @@ func AllGists(ctx *context.Context) error {
return ctx.Html("all.html") 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. // Search handles the search page for gists.
// //
// It takes a query parameter "q" which is a search query in the format: // It takes a query parameter "q" which is a search query in the format:
+5 -1
View File
@@ -17,6 +17,8 @@ import (
) )
func Create(ctx *context.Context) error { 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")) ctx.SetData("htmlTitle", ctx.TrH("gist.new.create-a-new-gist"))
return ctx.Html("create.html") return ctx.Html("create.html")
} }
@@ -139,7 +141,9 @@ func ProcessCreate(ctx *context.Context) error {
if isCreate { if isCreate {
gist = dto.ToGist() gist = dto.ToGist()
gist.ExpiresAt = dto.ExpiresAtTimestamp() 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) gist = dto.ToExistingGist(gist)
} }
+38
View File
@@ -139,3 +139,41 @@ func TestArchive(t *testing.T) {
}, 302) }, 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())
}
+1
View File
@@ -82,6 +82,7 @@ func Forks(ctx *context.Context) error {
return ctx.ErrorRes(404, ctx.Tr("error.page-not-found"), nil) 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("htmlTitle", ctx.TrH("gist.forks.for", gist.Title))
ctx.SetData("revision", "HEAD") ctx.SetData("revision", "HEAD")
return ctx.Html("forks.html") return ctx.Html("forks.html")
+12 -2
View File
@@ -144,6 +144,13 @@ func GistJs(ctx *context.Context) error {
autoMode = false 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) gist := ctx.GetData("gist").(*db.Gist)
var files []*git.File var files []*git.File
@@ -259,7 +266,7 @@ func escapeJavaScriptContent(htmlContent, cssUrl, themeUrl string, autoMode bool
<style> <style>
@import url(${css1}); @import url(${css1});
@import url(${css2}); @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> </style>
<div class="container">${content}</div> <div class="container">${content}</div>
%s; %s;
@@ -313,7 +320,10 @@ func setGistCSP(ctx *context.Context) {
"default-src 'self'; "+ "default-src 'self'; "+
"script-src 'self' 'nonce-"+nonce+"'; "+ "script-src 'self' 'nonce-"+nonce+"'; "+
"style-src 'self' 'unsafe-inline'; "+ "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:; "+ "font-src 'self' data:; "+
"connect-src 'self'; "+ "connect-src 'self'; "+
// 'self' (not 'none') so same-origin PDF previews keep working via // 'self' (not 'none') so same-origin PDF previews keep working via
+2 -2
View File
@@ -293,7 +293,7 @@ func TestGistAccess(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
s.Login(t, "thomas") s.Login(t, "thomas")
for k, v := range tt.settings { 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) { t.Run("Owner", func(t *testing.T) {
@@ -321,7 +321,7 @@ func TestGistAccess(t *testing.T) {
s.Login(t, "thomas") s.Login(t, "thomas")
for k := range tt.settings { 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)
} }
}) })
} }
+1
View File
@@ -46,6 +46,7 @@ func Likes(ctx *context.Context) error {
return ctx.ErrorRes(404, ctx.Tr("error.page-not-found"), nil) 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("htmlTitle", ctx.TrH("gist.likes.for", gist.Title))
ctx.SetData("revision", "HEAD") ctx.SetData("revision", "HEAD")
return ctx.Html("likes.html") return ctx.Html("likes.html")
+43
View File
@@ -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())
}
+32
View File
@@ -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")
}
+58
View File
@@ -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")
}
+2 -2
View File
@@ -100,7 +100,7 @@ func TestGitClonePull(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
s.Login(t, "thomas") s.Login(t, "thomas")
for k, v := range tt.settings { 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 { for _, ct := range tt.creds {
@@ -124,7 +124,7 @@ func TestGitClonePull(t *testing.T) {
// Reset settings // Reset settings
s.Login(t, "thomas") s.Login(t, "thomas")
for k := range tt.settings { 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: "", Topics: "",
}, 302) }, 302)
s.Request(t, "POST", "/settings/ssh-keys", db.SSHKeyDTO{ s.Request(t, "POST", "/-/settings/ssh-keys", db.SSHKeyDTO{
Title: "Test SSH Key", 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`, Content: `ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAklOUpkDHrfHY17SbrmTIpNLTGK9Tjom/BWDSUGPl+nafzlHDTYW7hdI4yZ5ew18JH4JW9jbhUFrviQzM7xlELEVf4h9lFX5QVkbPppSwg0cda3Pbv7kOdJ/MTyBlWXFCR+HAo3FXRitBqxiX1nKhXpHAZsMciLq8V6RjsNAQwdsdMFvSlVK/7XAt3FaoJoAsncM1Q9x5+3V0Ww68/eIFmb1zuUFljQJKprrX88XypNDvjYNby6vw/Pb0rwert/EnmZ+AW4OZPnTPI89ZPmVMLuayrD2cE86Z/il8b+gw3r3+1nKatmIkjn2so1d01QraTlMqVSsbxNrRFi9wrf+M7Q== admin@admin.local`,
}, 302) }, 302)
@@ -36,7 +36,7 @@ func AccessTokensProcess(ctx *context.Context) error {
if err := ctx.Validate(dto); err != nil { if err := ctx.Validate(dto); err != nil {
ctx.AddFlash(validator.ValidationMessages(&err, ctx.GetData("locale").(*i18n.Locale)), "error") 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() token := dto.ToAccessToken()
@@ -54,19 +54,19 @@ func AccessTokensProcess(ctx *context.Context) error {
// Show the token once to the user // Show the token once to the user
ctx.AddFlash(ctx.Tr("settings.token-created"), "success") ctx.AddFlash(ctx.Tr("settings.token-created"), "success")
ctx.AddFlash(plainToken, "success") ctx.AddFlash(plainToken, "success")
return ctx.RedirectTo("/settings/access-tokens") return ctx.RedirectTo("/-/settings/access-tokens")
} }
func AccessTokensDelete(ctx *context.Context) error { func AccessTokensDelete(ctx *context.Context) error {
user := ctx.User user := ctx.User
tokenID, err := strconv.Atoi(ctx.Param("id")) tokenID, err := strconv.Atoi(ctx.Param("id"))
if err != nil { if err != nil {
return ctx.RedirectTo("/settings/access-tokens") return ctx.RedirectTo("/-/settings/access-tokens")
} }
token, err := db.GetAccessTokenByID(uint(tokenID)) token, err := db.GetAccessTokenByID(uint(tokenID))
if err != nil || token.UserID != user.ID { 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 { if err := token.Delete(); err != nil {
@@ -74,5 +74,5 @@ func AccessTokensDelete(ctx *context.Context) error {
} }
ctx.AddFlash(ctx.Tr("settings.token-deleted"), "success") 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) { t.Run("RequiresAuth", func(t *testing.T) {
s.Logout() 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) { t.Run("AccessTokensPage", func(t *testing.T) {
s.Login(t, "thomas") 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) { t.Run("CreateReadToken", func(t *testing.T) {
s.Login(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: "test-token", Name: "test-token",
ScopeGist: db.ReadPermission, ScopeGist: db.ReadPermission,
}, 302) }, 302)
@@ -44,7 +44,7 @@ func TestAccessTokensCRUD(t *testing.T) {
t.Run("CreateExpiringToken", func(t *testing.T) { t.Run("CreateExpiringToken", func(t *testing.T) {
s.Login(t, "thomas") s.Login(t, "thomas")
tomorrow := time.Now().AddDate(0, 0, 1).Format("2006-01-02") 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", Name: "expiring-token",
ScopeGist: db.ReadWritePermission, ScopeGist: db.ReadWritePermission,
ExpiresAt: tomorrow, ExpiresAt: tomorrow,
@@ -57,7 +57,7 @@ func TestAccessTokensCRUD(t *testing.T) {
t.Run("DeleteToken", func(t *testing.T) { t.Run("DeleteToken", func(t *testing.T) {
s.Login(t, "thomas") 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) tokens, err := db.GetAccessTokensByUserID(1)
require.NoError(t, err) require.NoError(t, err)
@@ -279,7 +279,7 @@ func TestCreateTokenWithUserScope(t *testing.T) {
s.Register(t, "thomas") s.Register(t, "thomas")
s.Login(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", Name: "with-user",
ScopeGist: db.ReadPermission, ScopeGist: db.ReadPermission,
ScopeUser: db.ReadPermission, ScopeUser: db.ReadPermission,
@@ -311,7 +311,7 @@ func TestAccessTokenWithRequireLogin(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, token.Create()) 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() s.Logout()
headers := map[string]string{"Authorization": "Token " + plainToken} headers := map[string]string{"Authorization": "Token " + plainToken}
+5 -5
View File
@@ -37,7 +37,7 @@ func EmailProcess(ctx *context.Context) error {
} }
ctx.AddFlash(ctx.Tr("flash.user.email-updated"), "success") ctx.AddFlash(ctx.Tr("flash.user.email-updated"), "success")
return ctx.RedirectTo("/settings") return ctx.RedirectTo("/-/settings")
} }
func AccountDeleteProcess(ctx *context.Context) error { func AccountDeleteProcess(ctx *context.Context) error {
@@ -48,7 +48,7 @@ func AccountDeleteProcess(ctx *context.Context) error {
} }
opengistssh.SyncAuthorizedKeysLogged() opengistssh.SyncAuthorizedKeysLogged()
return ctx.RedirectTo("/all") return ctx.RedirectTo("/-/all")
} }
func UsernameProcess(ctx *context.Context) error { func UsernameProcess(ctx *context.Context) error {
@@ -61,13 +61,13 @@ func UsernameProcess(ctx *context.Context) error {
if err := ctx.Validate(dto); err != nil { if err := ctx.Validate(dto); err != nil {
ctx.AddFlash(validator.ValidationMessages(&err, ctx.GetData("locale").(*i18n.Locale)), "error") 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 !strings.EqualFold(dto.Username, user.Username) {
if exists, err := db.UserExists(dto.Username); err != nil || exists { if exists, err := db.UserExists(dto.Username); err != nil || exists {
ctx.AddFlash(ctx.Tr("flash.auth.username-exists"), "error") 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") ctx.AddFlash(ctx.Tr("flash.user.username-updated"), "success")
return ctx.RedirectTo("/settings") return ctx.RedirectTo("/-/settings")
} }
+8 -5
View File
@@ -13,12 +13,12 @@ func PasskeyDelete(ctx *context.Context) error {
user := ctx.User user := ctx.User
keyId, err := strconv.Atoi(ctx.Param("id")) keyId, err := strconv.Atoi(ctx.Param("id"))
if err != nil { if err != nil {
return ctx.RedirectTo("/settings") return ctx.RedirectTo("/-/settings")
} }
passkey, err := db.GetCredentialByIDDB(uint(keyId)) passkey, err := db.GetCredentialByIDDB(uint(keyId))
if err != nil || passkey.UserID != user.ID { if err != nil || passkey.UserID != user.ID {
return ctx.RedirectTo("/settings") return ctx.RedirectTo("/-/settings")
} }
if err := passkey.Delete(); err != nil { 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") ctx.AddFlash(ctx.Tr("flash.auth.passkey-deleted"), "success")
return ctx.RedirectTo("/settings") return ctx.RedirectTo("/-/settings")
} }
func PasswordProcess(ctx *context.Context) error { func PasswordProcess(ctx *context.Context) error {
@@ -40,7 +40,10 @@ func PasswordProcess(ctx *context.Context) error {
if err := ctx.Validate(dto); err != nil { if err := ctx.Validate(dto); err != nil {
ctx.AddFlash(validator.ValidationMessages(&err, ctx.GetData("locale").(*i18n.Locale)), "error") 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) 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") ctx.AddFlash(ctx.Tr("flash.user.password-updated"), "success")
return ctx.RedirectTo("/settings") return ctx.RedirectTo("/-/settings")
} }
+6 -6
View File
@@ -32,12 +32,12 @@ func AvatarProcess(ctx *context.Context) error {
header, err := ctx.FormFile("avatar") header, err := ctx.FormFile("avatar")
if err != nil { if err != nil {
ctx.AddFlash(ctx.Tr("flash.user.avatar-invalid"), "error") ctx.AddFlash(ctx.Tr("flash.user.avatar-invalid"), "error")
return ctx.RedirectTo("/settings") return ctx.RedirectTo("/-/settings")
} }
if header.Size > maxAvatarSize { if header.Size > maxAvatarSize {
ctx.AddFlash(ctx.Tr("flash.user.avatar-too-large"), "error") ctx.AddFlash(ctx.Tr("flash.user.avatar-too-large"), "error")
return ctx.RedirectTo("/settings") return ctx.RedirectTo("/-/settings")
} }
src, err := header.Open() src, err := header.Open()
@@ -54,7 +54,7 @@ func AvatarProcess(ctx *context.Context) error {
ext, ok := allowedAvatarTypes[contentType] ext, ok := allowedAvatarTypes[contentType]
if !ok { if !ok {
ctx.AddFlash(ctx.Tr("flash.user.avatar-invalid"), "error") 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 { 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") ctx.AddFlash(ctx.Tr("flash.user.avatar-updated"), "success")
return ctx.RedirectTo("/settings") return ctx.RedirectTo("/-/settings")
} }
func AvatarDelete(ctx *context.Context) error { func AvatarDelete(ctx *context.Context) error {
user := ctx.User user := ctx.User
if !user.HasUploadedAvatar() { if !user.HasUploadedAvatar() {
return ctx.RedirectTo("/settings") return ctx.RedirectTo("/-/settings")
} }
removeAvatarFile(user) removeAvatarFile(user)
@@ -103,7 +103,7 @@ func AvatarDelete(ctx *context.Context) error {
} }
ctx.AddFlash(ctx.Tr("flash.user.avatar-deleted"), "success") ctx.AddFlash(ctx.Tr("flash.user.avatar-deleted"), "success")
return ctx.RedirectTo("/settings") return ctx.RedirectTo("/-/settings")
} }
func removeAvatarFile(user *db.User) { func removeAvatarFile(user *db.User) {
+11 -4
View File
@@ -2,6 +2,7 @@ package settings
import ( import (
"github.com/thomiceli/opengist/internal/db" "github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/validator"
"github.com/thomiceli/opengist/internal/web/context" "github.com/thomiceli/opengist/internal/web/context"
) )
@@ -9,6 +10,9 @@ func UserAccount(ctx *context.Context) error {
user := ctx.User user := ctx.User
ctx.SetData("email", user.Email) 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("hasPassword", user.Password != "")
ctx.SetData("disableForm", ctx.GetData("DisableLoginForm")) ctx.SetData("disableForm", ctx.GetData("DisableLoginForm"))
ctx.SetData("settingsHeaderPage", "account") ctx.SetData("settingsHeaderPage", "account")
@@ -16,7 +20,7 @@ func UserAccount(ctx *context.Context) error {
return ctx.Html("settings_account.html") return ctx.Html("settings_account.html")
} }
func UserMFA(ctx *context.Context) error { func UserAuthentication(ctx *context.Context) error {
user := ctx.User user := ctx.User
passkeys, err := db.GetAllCredentialsForUser(user.ID) passkeys, err := db.GetAllCredentialsForUser(user.ID)
@@ -31,9 +35,11 @@ func UserMFA(ctx *context.Context) error {
ctx.SetData("passkeys", passkeys) ctx.SetData("passkeys", passkeys)
ctx.SetData("hasTotp", hasTotp) 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")) ctx.SetData("htmlTitle", ctx.TrH("settings"))
return ctx.Html("settings_mfa.html") return ctx.Html("settings_authentication.html")
} }
func UserSSHKeys(ctx *context.Context) error { func UserSSHKeys(ctx *context.Context) error {
@@ -52,6 +58,7 @@ func UserSSHKeys(ctx *context.Context) error {
func UserStyle(ctx *context.Context) error { func UserStyle(ctx *context.Context) error {
ctx.SetData("settingsHeaderPage", "style") ctx.SetData("settingsHeaderPage", "style")
ctx.SetData("themeColors", validator.ThemeColors)
ctx.SetData("htmlTitle", ctx.TrH("settings")) ctx.SetData("htmlTitle", ctx.TrH("settings"))
return ctx.Html("settings_style.html") return ctx.Html("settings_style.html")
} }
@@ -72,5 +79,5 @@ func ProcessUserStyle(ctx *context.Context) error {
} }
ctx.AddFlash("Updated style", "success") ctx.AddFlash("Updated style", "success")
return ctx.RedirectTo("/settings/style") return ctx.RedirectTo("/-/settings/style")
} }
+7 -7
View File
@@ -21,7 +21,7 @@ func SshKeysProcess(ctx *context.Context) error {
if err := ctx.Validate(dto); err != nil { if err := ctx.Validate(dto); err != nil {
ctx.AddFlash(validator.ValidationMessages(&err, ctx.GetData("locale").(*i18n.Locale)), "error") ctx.AddFlash(validator.ValidationMessages(&err, ctx.GetData("locale").(*i18n.Locale)), "error")
return ctx.RedirectTo("/settings/ssh") return ctx.RedirectTo("/-/settings/ssh")
} }
key := dto.ToSSHKey() key := dto.ToSSHKey()
@@ -30,7 +30,7 @@ func SshKeysProcess(ctx *context.Context) error {
pubKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(key.Content)) pubKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(key.Content))
if err != nil { if err != nil {
ctx.AddFlash(ctx.Tr("flash.user.invalid-ssh-key"), "error") 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))) 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) return ctx.ErrorRes(500, "Cannot check if SSH key exists", err)
} }
ctx.AddFlash(ctx.Tr("settings.ssh-key-exists"), "error") 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 { if err := key.Create(); err != nil {
@@ -48,20 +48,20 @@ func SshKeysProcess(ctx *context.Context) error {
opengistssh.SyncAuthorizedKeysLogged() opengistssh.SyncAuthorizedKeysLogged()
ctx.AddFlash(ctx.Tr("flash.user.ssh-key-added"), "success") 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 { func SshKeysDelete(ctx *context.Context) error {
user := ctx.User user := ctx.User
keyId, err := strconv.Atoi(ctx.Param("id")) keyId, err := strconv.Atoi(ctx.Param("id"))
if err != nil { if err != nil {
return ctx.RedirectTo("/settings/ssh") return ctx.RedirectTo("/-/settings/ssh")
} }
key, err := db.GetSSHKeyByID(uint(keyId)) key, err := db.GetSSHKeyByID(uint(keyId))
if err != nil || key.UserID != user.ID { if err != nil || key.UserID != user.ID {
return ctx.RedirectTo("/settings/ssh") return ctx.RedirectTo("/-/settings/ssh")
} }
if err := key.Delete(); err != nil { if err := key.Delete(); err != nil {
@@ -70,5 +70,5 @@ func SshKeysDelete(ctx *context.Context) error {
opengistssh.SyncAuthorizedKeysLogged() opengistssh.SyncAuthorizedKeysLogged()
ctx.AddFlash(ctx.Tr("flash.user.ssh-key-deleted"), "success") ctx.AddFlash(ctx.Tr("flash.user.ssh-key-deleted"), "success")
return ctx.RedirectTo("/settings/ssh") return ctx.RedirectTo("/-/settings/ssh")
} }
+6 -6
View File
@@ -121,7 +121,7 @@ func (s *Server) errorHandler(err error, ctx echo.Context) {
if acceptJson || data["err_render"] == "json" { if acceptJson || data["err_render"] == "json" {
renderErr = ctx.JSON(httpErr.Code, httpErr) renderErr = ctx.JSON(httpErr.Code, httpErr)
} else { } else {
renderErr = ctx.Render(httpErr.Code, "error", data) renderErr = ctx.Render(httpErr.Code, "error.html", data)
} }
if renderErr != nil && !isClientGone(renderErr) { if renderErr != nil && !isClientGone(renderErr) {
@@ -209,7 +209,7 @@ func logged(next Handler) Handler {
if user != nil { if user != nil {
return next(ctx) return next(ctx)
} }
return ctx.RedirectTo("/all") return ctx.RedirectTo("/-/all")
} }
} }
@@ -229,7 +229,7 @@ func inOAuthRegisterSession(next Handler) Handler {
sess := ctx.GetSession() sess := ctx.GetSession()
_, ok := sess.Values["oauthProvider"].(string) _, ok := sess.Values["oauthProvider"].(string)
if !ok { if !ok {
return ctx.RedirectTo("/login") return ctx.RedirectTo("/-/login")
} }
return next(ctx) return next(ctx)
} }
@@ -252,7 +252,7 @@ func makeCheckRequireLogin(isSingleGistAccess bool) Middleware {
if !allow { if !allow {
ctx.AddFlash(ctx.Tr("flash.auth.must-be-logged-in"), "error") ctx.AddFlash(ctx.Tr("flash.auth.must-be-logged-in"), "error")
return ctx.RedirectTo("/login") return ctx.RedirectTo("/-/login")
} }
return next(ctx) return next(ctx)
} }
@@ -275,7 +275,7 @@ func checkFileUploadEnabled(next Handler) Handler {
// makeApiCheckRequireLogin is the /api/v1 counterpart of makeCheckRequireLogin: // makeApiCheckRequireLogin is the /api/v1 counterpart of makeCheckRequireLogin:
// it enforces the instance's RequireLogin / AllowGistsWithoutLogin settings on // it enforces the instance's RequireLogin / AllowGistsWithoutLogin settings on
// anonymous gist reads, but responds with a JSON 401 instead of redirecting to // 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. // apiBindAuth, so there is no token fallback to do here.
func makeApiCheckRequireLogin(isSingleGistAccess bool) Middleware { func makeApiCheckRequireLogin(isSingleGistAccess bool) Middleware {
return func(next Handler) Handler { return func(next Handler) Handler {
@@ -360,7 +360,7 @@ func sessionInit(next Handler) Handler {
ctx.SaveSession(sess) ctx.SaveSession(sess)
ctx.User = nil ctx.User = nil
ctx.SetData("userLogged", nil) ctx.SetData("userLogged", nil)
return ctx.RedirectTo("/all") return ctx.RedirectTo("/-/all")
} }
if user != nil { if user != nil {
ctx.User = user ctx.User = user
+41 -8
View File
@@ -7,6 +7,7 @@ import (
htmlpkg "html" htmlpkg "html"
"html/template" "html/template"
"io" "io"
"io/fs"
"net/http" "net/http"
"net/url" "net/url"
"path/filepath" "path/filepath"
@@ -29,9 +30,15 @@ import (
type Template struct { type Template struct {
templates *template.Template 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 { 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) 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") customPattern := filepath.Join(config.GetHomeDir(), "custom", "*.html")
matches, err := filepath.Glob(customPattern) matches, err := filepath.Glob(customPattern)
if err != nil { if err != nil {
log.Fatal().Err(err).Msg("Failed to check for custom templates") log.Fatal().Err(err).Msg("Failed to check for custom templates")
} }
if len(matches) > 0 { if len(matches) > 0 {
t, err = t.ParseGlob(customPattern) customTemplates, err = customTemplates.ParseGlob(customPattern)
if err != nil { if err != nil {
log.Fatal().Err(err).Msg("Failed to parse custom templates") log.Fatal().Err(err).Msg("Failed to parse custom templates")
} }
} }
s.echo.Renderer = &Template{ s.echo.Renderer = &Template{
templates: t, templates: customTemplates,
pages: pages,
} }
} }
func (s *Server) parseManifestEntries() { func (s *Server) parseManifestEntries() {
file, err := public.Files.Open(".vite/manifest.json") entries, err := parseManifest(public.Files)
if err != nil { 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) byteValue, err := io.ReadAll(file)
if err != nil { 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
} }
+21 -14
View File
@@ -42,11 +42,11 @@ func (s *Server) registerRoutes() {
r.Static("/avatar", settings.AvatarsDir()) r.Static("/avatar", settings.AvatarsDir())
r.GET("/register", auth.Register) r.GET("/-/register", auth.Register)
r.POST("/register", auth.ProcessRegister) r.POST("/-/register", auth.ProcessRegister)
r.GET("/login", auth.Login) r.GET("/-/login", auth.Login)
r.POST("/login", auth.ProcessLogin) r.POST("/-/login", auth.ProcessLogin)
r.GET("/logout", auth.Logout) r.GET("/-/logout", auth.Logout)
r.GET("/oauth/register", auth.OauthRegister, inOAuthRegisterSession) r.GET("/oauth/register", auth.OauthRegister, inOAuthRegisterSession)
r.POST("/oauth/register", auth.ProcessOauthRegister, inOAuthRegisterSession) r.POST("/oauth/register", auth.ProcessOauthRegister, inOAuthRegisterSession)
r.GET("/oauth/:provider", auth.Oauth) r.GET("/oauth/:provider", auth.Oauth)
@@ -61,11 +61,11 @@ func (s *Server) registerRoutes() {
r.GET("/mfa", auth.Mfa, inMFASession) r.GET("/mfa", auth.Mfa, inMFASession)
r.POST("/mfa/totp/assertion", auth.AssertTotp, inMFASession) r.POST("/mfa/totp/assertion", auth.AssertTotp, inMFASession)
sA := r.SubGroup("/settings") sA := r.SubGroup("/-/settings")
{ {
sA.Use(logged) sA.Use(logged)
sA.GET("", settings.UserAccount) sA.GET("", settings.UserAccount)
sA.GET("/mfa", settings.UserMFA) sA.GET("/authentication", settings.UserAuthentication)
sA.GET("/ssh", settings.UserSSHKeys) sA.GET("/ssh", settings.UserSSHKeys)
sA.GET("/style", settings.UserStyle) sA.GET("/style", settings.UserStyle)
sA.POST("/style", settings.ProcessUserStyle) sA.POST("/style", settings.ProcessUserStyle)
@@ -87,7 +87,7 @@ func (s *Server) registerRoutes() {
sA.POST("/totp/regenerate", auth.RegenerateTotpRecoveryCodes) sA.POST("/totp/regenerate", auth.RegenerateTotpRecoveryCodes)
} }
sB := r.SubGroup("/admin-panel") sB := r.SubGroup("/-/admin-panel")
{ {
sB.Use(adminPermission) sB.Use(adminPermission)
sB.GET("", admin.AdminIndex) sB.GET("", admin.AdminIndex)
@@ -98,6 +98,7 @@ func (s *Server) registerRoutes() {
sB.GET("/invitations", admin.AdminInvitations) sB.GET("/invitations", admin.AdminInvitations)
sB.POST("/invitations", admin.AdminInvitationsCreate) sB.POST("/invitations", admin.AdminInvitationsCreate)
sB.POST("/invitations/:id/delete", admin.AdminInvitationsDelete) sB.POST("/invitations/:id/delete", admin.AdminInvitationsDelete)
sB.GET("/actions", admin.AdminActions)
sB.POST("/sync-fs", admin.AdminSyncReposFromFS) sB.POST("/sync-fs", admin.AdminSyncReposFromFS)
sB.POST("/sync-db", admin.AdminSyncReposFromDB) sB.POST("/sync-db", admin.AdminSyncReposFromDB)
sB.POST("/gc-repos", admin.AdminGcRepos) sB.POST("/gc-repos", admin.AdminGcRepos)
@@ -172,19 +173,23 @@ func (s *Server) registerRoutes() {
r.Any("/api/*", noRouteFoundApi) 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() { if index.IndexEnabled() {
r.GET("/search", gist.Search, checkRequireLogin) r.GET("/-/search", gist.Search, checkRequireLogin)
} else { } 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", gist.AllGists, checkRequireLogin, setAllGistsMode("fromUser"))
r.GET("/:user/liked", gist.AllGists, checkRequireLogin, setAllGistsMode("liked")) r.GET("/:user/-/liked", gist.AllGists, checkRequireLogin, setAllGistsMode("liked"))
r.GET("/:user/forked", gist.AllGists, checkRequireLogin, setAllGistsMode("forked")) 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") sC := r.SubGroup("/:user/:gistname")
{ {
@@ -200,6 +205,8 @@ func (s *Server) registerRoutes() {
sC.HEAD("/raw/:revision/:file", gist.RawFile) sC.HEAD("/raw/:revision/:file", gist.RawFile)
sC.GET("/download/:revision/:file", gist.DownloadFile) sC.GET("/download/:revision/:file", gist.DownloadFile)
sC.HEAD("/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.GET("/edit", gist.Edit, logged, writePermission, notArchived)
sC.POST("/edit", gist.ProcessCreate, logged, writePermission, notArchived) sC.POST("/edit", gist.ProcessCreate, logged, writePermission, notArchived)
sC.POST("/like", gist.Like, logged) sC.POST("/like", gist.Like, logged)
+1 -1
View File
@@ -37,12 +37,12 @@ func NewServer(isDev bool) *Server {
} }
s.registerMiddlewares() s.registerMiddlewares()
s.setFuncMap()
s.echo.HTTPErrorHandler = s.errorHandler s.echo.HTTPErrorHandler = s.errorHandler
if !s.dev { if !s.dev {
s.parseManifestEntries() s.parseManifestEntries()
} }
s.setFuncMap()
s.registerRoutes() s.registerRoutes()
+3 -3
View File
@@ -154,11 +154,11 @@ func (s *Server) TestCtxData(t *testing.T, expected echo.Map) {
} }
func (s *Server) Register(t *testing.T, user string) { 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) { 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() { 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) { 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") s.Login(t, "thomas")
resp := s.Request(t, "POST", "/", url.Values{ resp := s.Request(t, "POST", "/", url.Values{
+27
View File
@@ -18,10 +18,13 @@
"@tailwindcss/forms": "^0.5.11", "@tailwindcss/forms": "^0.5.11",
"@tailwindcss/typography": "^0.5.20", "@tailwindcss/typography": "^0.5.20",
"@tailwindcss/vite": "^4.3.3", "@tailwindcss/vite": "^4.3.3",
"basecoat-css": "^0.3.11",
"codemirror": "^6.0.2", "codemirror": "^6.0.2",
"dompurify": "^3.4.12", "dompurify": "^3.4.12",
"github-markdown-css": "^5.9.0", "github-markdown-css": "^5.9.0",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"htmx.org": "^2.0.10",
"hyperscript.org": "^0.9.93",
"jdenticon": "^3.3.0", "jdenticon": "^3.3.0",
"katex": "^0.18.1", "katex": "^0.18.1",
"marked": "^18.0.6", "marked": "^18.0.6",
@@ -1451,6 +1454,13 @@
"node": "18 || 20 || >=22" "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": { "node_modules/binary-extensions": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@@ -1694,6 +1704,23 @@
"node": ">=12.0.0" "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": { "node_modules/ignore-by-default": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz",
+4 -1
View File
@@ -13,17 +13,20 @@
"@codemirror/lang-javascript": "^6.2.5", "@codemirror/lang-javascript": "^6.2.5",
"@codemirror/language": "^6.12.4", "@codemirror/language": "^6.12.4",
"@codemirror/language-data": "^6.5.2", "@codemirror/language-data": "^6.5.2",
"@lezer/highlight": "^1.2.3",
"@codemirror/state": "^6.7.1", "@codemirror/state": "^6.7.1",
"@codemirror/text": "^0.19.6", "@codemirror/text": "^0.19.6",
"@codemirror/view": "^6.43.6", "@codemirror/view": "^6.43.6",
"@lezer/highlight": "^1.2.3",
"@tailwindcss/forms": "^0.5.11", "@tailwindcss/forms": "^0.5.11",
"@tailwindcss/typography": "^0.5.20", "@tailwindcss/typography": "^0.5.20",
"@tailwindcss/vite": "^4.3.3", "@tailwindcss/vite": "^4.3.3",
"basecoat-css": "^0.3.11",
"codemirror": "^6.0.2", "codemirror": "^6.0.2",
"dompurify": "^3.4.12", "dompurify": "^3.4.12",
"github-markdown-css": "^5.9.0", "github-markdown-css": "^5.9.0",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"htmx.org": "^2.0.10",
"hyperscript.org": "^0.9.93",
"jdenticon": "^3.3.0", "jdenticon": "^3.3.0",
"katex": "^0.18.1", "katex": "^0.18.1",
"marked": "^18.0.6", "marked": "^18.0.6",
+17
View File
@@ -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));
}
+24 -4
View File
@@ -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/typography";
@plugin "@tailwindcss/forms"; @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 { @theme {
/* Custom gray palette */ /* Custom gray palette */
@@ -55,7 +75,7 @@
cursor: pointer; cursor: pointer;
} }
@import './ipynb.css'; @import './embed-ipynb.css';
@import "./style.css"; @import "./embed-style.css";
} }
} }
+402
View File
@@ -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;
}
}
+13 -4
View File
@@ -1,17 +1,26 @@
@import "katex/dist/katex.min.css"; @import "katex/dist/katex.min.css";
.jupyter.notebook { .jupyter.notebook {
padding: 0.5rem; padding: 0.5rem;
} }
.jupyter.notebook pre { .jupyter.notebook pre {
font-size: 0.8em !important; font-size: 0.8em !important;
} }
.jupyter.notebook .jupyter-cell { .jupyter.notebook .jupyter-cell {
padding: 0.5rem; padding: 0.5rem;
} }
.jupyter.notebook .jupyter-cell.code-cell { .jupyter.notebook .jupyter-cell.code-cell {
filter: drop-shadow(0 0 0.1rem rgba(0, 0, 0, 0.5)); 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;
} }
+16
View File
@@ -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");
-57
View File
@@ -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";
}
-46
View File
@@ -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
View File
@@ -5,14 +5,21 @@ import {HighlightStyle, LanguageDescription, syntaxHighlighting} from "@codemirr
import {languages} from "@codemirror/language-data"; import {languages} from "@codemirror/language-data";
import {tags} from "@lezer/highlight"; 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}); EditorView.theme({}, {dark: true});
let editorsjs: EditorView[] = []; let editorsjs: EditorView[] = [];
let editorHighlightCompartments: {editor: EditorView, conf: Compartment}[] = []; let editorHighlightCompartments: {editor: EditorView, conf: Compartment}[] = [];
let editorsParentdom = document.getElementById("editors")!; let allEditorsdom = editorsParentdom.querySelectorAll(":scope > .editor");
let allEditorsdom = document.querySelectorAll("#editors > .editor");
let firstEditordom = allEditorsdom[0];
const txtFacet = Facet.define<string>({ const txtFacet = Facet.define<string>({
combine(values) { 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 mdpreview = dom.querySelector(".md-preview") as HTMLElement;
let formfilename = dom.querySelector<HTMLInputElement>(".form-filename"); let formfilename = dom.querySelector<HTMLInputElement>(".form-filename");
@@ -184,13 +194,7 @@ document.addEventListener("DOMContentLoaded", () => {
setLineWrapping(editor, newWrapMode === "soft"); setLineWrapping(editor, newWrapMode === "soft");
}; };
dom.addEventListener("drop", (e) => { // drop-to-set-filename is handled by hyperscript on the .editor element.
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;
});
// remove editor on delete // remove editor on delete
let deleteBtns = dom.querySelector<HTMLButtonElement>("button.delete-file"); let deleteBtns = dom.querySelector<HTMLButtonElement>("button.delete-file");
@@ -205,7 +209,6 @@ document.addEventListener("DOMContentLoaded", () => {
if (hIdx !== -1) editorHighlightCompartments.splice(hIdx, 1); if (hIdx !== -1) editorHighlightCompartments.splice(hIdx, 1);
} }
dom.remove(); dom.remove();
checkForFirstDeleteButton();
}; };
} }
@@ -295,14 +298,11 @@ document.addEventListener("DOMContentLoaded", () => {
deleteBtn.onclick = () => { deleteBtn.onclick = () => {
if (!confirm("Are you sure you want to delete this file?")) return; if (!confirm("Are you sure you want to delete this file?")) return;
el.remove(); el.remove();
checkForFirstDeleteButton();
}; };
} }
} }
}); });
checkForFirstDeleteButton();
// Update syntax highlight theme when dark/light mode changes // Update syntax highlight theme when dark/light mode changes
new MutationObserver(() => { new MutationObserver(() => {
const hl = currentHighlight(); const hl = currentHighlight();
@@ -318,7 +318,6 @@ document.addEventListener("DOMContentLoaded", () => {
// creating the new codemirror editor and append it in the editor div // creating the new codemirror editor and append it in the editor div
editorsjs.push(newEditor(newEditorDom)); editorsjs.push(newEditor(newEditorDom));
editorsParentdom.append(newEditorDom); editorsParentdom.append(newEditorDom);
showDeleteButton(newEditorDom);
}; };
document.querySelector<HTMLFormElement>("form#create")!.onsubmit = () => { document.querySelector<HTMLFormElement>("form#create")!.onsubmit = () => {
@@ -375,45 +374,9 @@ document.addEventListener("DOMContentLoaded", () => {
window.onbeforeunload = null; window.onbeforeunload = null;
}; };
document.getElementById('gist-metadata-btn')!.onclick = (el) => { // metadata toggle is handled by hyperscript on the button in create.html.
let metadata = document.getElementById('gist-metadata')!; // Single-file delete-button visibility / input rounding is handled in CSS
metadata.classList.toggle('hidden'); // via #editors > .editor:only-child (no flash on load).
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();
}
// File upload functionality // File upload functionality
let uploadedFileUUIDs: {uuid: string, filename: string}[] = []; 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
View File
@@ -1 +1 @@
import "../css/embed.css" import '../css/embed.css';
+71
View File
@@ -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));
}
});
}
+260
View File
@@ -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);
});
}
+40
View File
@@ -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);
}
-78
View File
@@ -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;
});
}
+18 -4
View File
@@ -131,7 +131,21 @@ class IPynb {
} }
} }
// Process Jupyter notebooks // Render every notebook on the page. Rendering replaces the source <pre> with
document.querySelectorAll<HTMLElement>('.jupyter.notebook pre').forEach((el) => { // the rendered cells, so this is idempotent: once a notebook is mounted there is
new IPynb(el).mount(); // 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) => {
// 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();
})
);
});
}
+10
View File
@@ -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]');
}
+141 -184
View File
@@ -1,204 +1,161 @@
import '../css/tailwind.css'; import '../css/main.css';
import '../img/favicon-32.png'; import '../img/favicon-32.png';
import '../img/opengist.svg'; 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) => { let ipynbModule: Promise<typeof import('./ipynb')> | undefined;
PDFObject.embed(el.dataset.src || "", el);
})
if (document.querySelector('.mermaid') && document.documentElement.classList.contains('dark')) { const initIpynb = () => {
(window as any).mermaid?.initialize({ theme: 'dark', startOnLoad: true }); if (!document.querySelector('.jupyter.notebook pre')) return;
} ipynbModule ??= import('./ipynb');
void ipynbModule.then(({ initIpynb: renderNotebooks }) => renderNotebooks());
};
document.addEventListener('DOMContentLoaded', () => { const init = () => {
document.getElementById('user-btn')?.addEventListener("click" , () => { initGistFilters();
document.getElementById('user-menu')!.classList.toggle('hidden'); initGistLines();
}) initGistCheckboxes();
initIpynb();
initPdf();
initJdenticon();
};
document.querySelectorAll('form').forEach((form: HTMLFormElement) => { document.addEventListener('DOMContentLoaded', init);
form.onsubmit = () => { // Re-init after hx-boost / htmx content swaps.
form.querySelectorAll('input[type=datetime-local]').forEach((input: HTMLInputElement) => { document.body.addEventListener('htmx:afterSwap', init);
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;
};
})
// 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');
});
// 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 rev = document.querySelector<HTMLElement>('.revision-text'); let trickle: number | undefined;
if (rev) { let showTimer: number | undefined;
const fullRev = rev.innerHTML; let progress = 0;
const smallRev = fullRev.substring(0, 7);
rev.innerHTML = smallRev;
rev.onmouseover = () => { const set = (value: number) => {
rev.innerHTML = fullRev; progress = value;
}; bar.style.setProperty('--progress', String(value));
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');
}
}
}; };
if (location.hash) { const attach = () => {
colorhash(); // A history snapshot may have restored a stale copy of the bar (see the
} // beforeHistorySave handler below). Drop any that isn't ours.
window.onhashchange = colorhash; document.querySelectorAll('#page-progress').forEach((el) => {
if (el !== bar) el.remove();
document.getElementById('main-menu-button')!.onclick = () => {
document.getElementById('mobile-menu')!.classList.toggle('hidden');
};
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 || '';
};
}
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 || '';
};
}); });
} if (bar.parentElement !== document.body) document.body.appendChild(bar);
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');
}; };
attach();
document.body.addEventListener('htmx:afterSwap', attach);
// The bar is chrome, not page content, so keep it out of the DOM snapshot
document.querySelectorAll('.copy-gist-btn').forEach((e: HTMLElement) => { // htmx saves for back/forward navigation — otherwise a bar frozen mid-load
e.onclick = () => { // gets restored and sticks at the top. Detaching + clearing state leaves the
navigator.clipboard.writeText(e.parentNode!.parentNode!.querySelector<HTMLElement>('.gist-content')!.textContent || '').catch((err) => { // snapshot clean; attach() re-adds a fresh bar on the way back.
console.error('Could not copy text: ', err); document.body.addEventListener('htmx:beforeHistorySave', () => {
}); window.clearTimeout(showTimer);
}; window.clearInterval(trickle);
bar.classList.remove('is-loading');
set(0);
bar.remove();
}); });
const gistmenuvisibility = document.getElementById('gist-menu-visibility'); const start = () => {
if (gistmenuvisibility) { window.clearTimeout(showTimer);
let submitgistbutton = (document.getElementById('submit-gist') as HTMLInputElement); window.clearInterval(trickle);
document.getElementById('gist-visibility-menu-button')!.onclick = () => { // Delay showing so quick navigations don't flash the bar.
gistmenuvisibility!.classList.toggle('hidden'); showTimer = window.setTimeout(() => {
} attach();
const lastVisibility = localStorage.getItem('visibility'); bar.classList.add('is-loading');
Array.from(document.querySelectorAll('.gist-visibility-option')).forEach((el) => { set(0.08);
const visibility = (el as HTMLElement).dataset.visibility || '0'; // Creep toward (but never reach) the end while we wait.
(el as HTMLElement).onclick = () => { trickle = window.setInterval(() => {
submitgistbutton.textContent = (el as HTMLElement).dataset.btntext; if (progress < 0.9) set(progress + (0.9 - progress) * 0.1);
submitgistbutton!.value = visibility; }, 300);
localStorage.setItem('visibility', visibility); }, 150);
gistmenuvisibility!.classList.add('hidden'); };
}
if (lastVisibility === visibility) { const done = () => {
(el as HTMLElement).click(); 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;
} }
});
const expireselect = document.getElementById('expire') as HTMLSelectElement | null;
const expireat = document.getElementById('expire_at') as HTMLInputElement | null; // hx-boost stores a DOM snapshot for the back/forward cache. Components mark
if (expireselect && expireat) { // themselves initialized (basecoat: data-*-initialized, our filter:
const toggleExpireAt = () => { // data-filter-ready), but the restored snapshot keeps those markers while the
expireat.classList.toggle('hidden', expireselect.value !== 'custom'); // JS listeners are gone — so dropdowns etc. look dead. Strip the markers before
}; // the snapshot is saved, and re-initialize on restore.
expireselect.addEventListener('change', toggleExpireAt); const INIT_MARKERS = '[data-dropdown-menu-initialized], [data-filter-ready]';
toggleExpireAt(); const stripInitMarkers = () => {
} document.querySelectorAll(INIT_MARKERS).forEach((el) => {
el.removeAttribute('data-dropdown-menu-initialized');
const searchinput = document.getElementById('search') as HTMLInputElement; el.removeAttribute('data-filter-ready');
searchinput.addEventListener('focusin', () => { });
document.getElementById('search-help').classList.remove('hidden'); };
})
document.body.addEventListener('htmx:beforeHistorySave', stripInitMarkers);
searchinput.addEventListener('focusout', (e) => { document.body.addEventListener('htmx:historyRestore', () => {
document.getElementById('search-help').classList.add('hidden'); stripInitMarkers();
}) (window as unknown as { basecoat?: { initAll?: () => void } }).basecoat?.initAll?.();
init();
}); });
+30
View File
@@ -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();
}
});
}
-45
View File
@@ -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
View File
@@ -165,18 +165,29 @@ async function loginWithPasskey() {
} }
} }
document.addEventListener('DOMContentLoaded', () => { function initWebauthn() {
const registerButton = document.getElementById('bind-passkey-button'); const registerButton = document.getElementById('bind-passkey-button') as HTMLElement | null;
if (registerButton) { if (registerButton && !registerButton.dataset.passkeyBound) {
registerButton.dataset.passkeyBound = 'true';
registerButton.addEventListener('click', bindPasskey); registerButton.addEventListener('click', bindPasskey);
} }
if (document.documentURI.includes('/mfa')) { loginMethod = document.documentURI.includes('/mfa') ? 'assertion' : 'login';
loginMethod = "assertion"
}
const loginButton = document.getElementById('login-passkey-button'); const loginButton = document.getElementById('login-passkey-button') as HTMLElement | null;
if (loginButton) { if (loginButton && !loginButton.dataset.passkeyBound) {
loginButton.dataset.passkeyBound = 'true';
loginButton.addEventListener('click', loginWithPasskey); 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);
+5 -8
View File
@@ -18,16 +18,13 @@ export default defineConfig({
manifest: true, manifest: true,
rollupOptions: { rollupOptions: {
input: [ 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/main.ts',
'./public/ts/style_preferences.ts', './public/ts/editor.ts',
'./public/ts/webauthn.ts', './public/ts/webauthn.ts',
'./public/ts/auto.ts',
'./public/ts/light.ts',
'./public/ts/dark.ts',
'./public/ts/embed.ts',
] ]
}, },
assetsInlineLimit: 0, assetsInlineLimit: 0,
+1 -1
View File
@@ -20,4 +20,4 @@ cleanup() {
trap cleanup EXIT INT TERM trap cleanup EXIT INT TERM
# Wait for background processes # Wait for background processes
wait wait
-14
View File
@@ -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 }}
-30
View File
@@ -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 }}
-44
View File
@@ -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 }}
-302
View File
@@ -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 }}
-10
View File
@@ -1,10 +0,0 @@
{{ if false }}
{{/* prevent IDE errors */}}
<div><main>
{{ end }}
{{ define "gist_footer" }}
</main>
</div>
{{ end }}
-211
View File
@@ -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 }}
-8
View File
@@ -1,8 +0,0 @@
{{ if false }}{{/* prevent IDE errors */}}
<div><main>
{{ end }}
{{ define "settings_footer" }}
</main>
</div>
{{ end }}
-30
View File
@@ -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 }}
+1 -1
View File
@@ -2,5 +2,5 @@ package templates
import "embed" import "embed"
//go:embed */*.html //go:embed layouts/*.html partials/*.html pages/*.html
var Files embed.FS var Files embed.FS
+166
View File
@@ -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 }}
+50
View File
@@ -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 }}
+126 -145
View File
@@ -1,151 +1,132 @@
{{ template "header" .}} {{ define "content" }}
{{ template "admin_header" .}} <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="grid gap-4 lg:grid-cols-2">
<div class="p-6 bg-gray-50 dark:bg-gray-800 rounded-md border border-gray-200 dark:border-gray-700"> <!-- Read-only configuration -->
<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> <section class="bg-card h-fit rounded-lg border">
<dl class="dl-config"> <div class="border-b px-4 py-3">
<div class="relative col-span-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 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">General</span>
</div>
</div> </div>
<dt>Log level</dt><dd>{{ .c.LogLevel }}</dd> <div class="space-y-4 p-4">
<dt>Log output</dt><dd>{{ .c.LogOutput }}</dd> {{ template "admin_config_section" (dict "title" "General") }}
<dt>External URL</dt><dd>{{ .c.ExternalUrl }}</dd> <dl class="divide-y text-sm">
<dt>Opengist home</dt><dd>{{ .c.OpengistHome }}</dd> {{ template "admin_config_row" (dict "k" "Log level" "v" .c.LogLevel) }}
<dt>Database type</dt><dd>{{ .dbtype }}{{ if eq .dbtype "SQLite" }} ({{ .c.SqliteJournalMode }}){{ end }}</dd> {{ template "admin_config_row" (dict "k" "Log output" "v" .c.LogOutput) }}
<dt>Database name</dt><dd>{{ .dbname }}</dd> {{ template "admin_config_row" (dict "k" "External URL" "v" .c.ExternalUrl) }}
<dt>Index</dt><dd>{{ .c.Index }}</dd> {{ template "admin_config_row" (dict "k" "Opengist home" "v" .c.OpengistHome) }}
<dt>MeiliSearch Host</dt><dd>{{ .c.MeiliHost }}</dd> <div class="flex items-center justify-between gap-4 py-1.5">
<dt>MeiliSearch API Key</dt><dd>{{ if .c.MeiliAPIKey }}&#60;defined&#62;{{ end }}</dd> <dt class="text-muted-foreground">Database type</dt>
<dt>Git default branch</dt><dd>{{ .c.GitDefaultBranch }}</dd> <dd class="text-right font-medium break-all">{{ .dbtype }}{{ if eq .dbtype "SQLite" }} ({{ .c.SqliteJournalMode }}){{ end }}</dd>
<div class="relative col-span-3 mt-4"> </div>
<div class="absolute inset-0 flex items-center" aria-hidden="true"> {{ template "admin_config_row" (dict "k" "Database name" "v" .dbname) }}
<div class="w-full border-t border-gray-300"></div> {{ template "admin_config_row" (dict "k" "Index" "v" .c.Index) }}
</div> {{ template "admin_config_row" (dict "k" "MeiliSearch Host" "v" .c.MeiliHost) }}
<div class="relative flex justify-center"> {{ template "admin_config_defined" (dict "k" "MeiliSearch API Key" "v" .c.MeiliAPIKey) }}
<span class="bg-gray-50 dark:bg-gray-800 px-2 text-sm text-slate-700 dark:text-slate-300 font-bold">HTTP</span> {{ template "admin_config_row" (dict "k" "Git default branch" "v" .c.GitDefaultBranch) }}
</div> </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>
<dt>HTTP host</dt><dd>{{ .c.HttpHost }}</dd> </section>
<dt>HTTP port</dt><dd>{{ .c.HttpPort }}</dd>
<dt>HTTP Git enabled</dt><dd>{{ .c.HttpGit }}</dd> <!-- Toggleable settings -->
<div class="relative col-span-3 mt-4"> <section class="bg-card h-fit rounded-lg border">
<div class="absolute inset-0 flex items-center" aria-hidden="true"> {{ .csrfHtml }}
<div class="w-full border-t border-gray-300"></div> <ul class="divide-y">
</div> {{ 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) }}
<div class="relative flex justify-center"> {{ 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) }}
<span class="bg-gray-50 dark:bg-gray-800 px-2 text-sm text-slate-700 dark:text-slate-300 font-bold">SSH</span> {{ 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) }}
</div> {{ 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) }}
</div> {{ 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) }}
<dt>SSH Git enabled</dt><dd>{{ .c.SshGit }}</dd> </ul>
<dt>SSH host</dt><dd>{{ .c.SshHost }}</dd> </section>
<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 }}&#60;defined&#62;{{ end }}</dd>
<dt>Github Secret</dt><dd>{{ if .c.GithubSecret }}&#60;defined&#62;{{ end }}</dd>
<dt>GitLab client Key</dt><dd>{{ if .c.GitlabClientKey }}&#60;defined&#62;{{ end }}</dd>
<dt>GitLab Secret</dt><dd>{{ if .c.GitlabSecret }}&#60;defined&#62;{{ 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 }}&#60;defined&#62;{{ end }}</dd>
<dt>Gitea Secret</dt><dd>{{ if .c.GiteaSecret }}&#60;defined&#62;{{ 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 }}&#60;defined&#62;{{ end }}</dd>
<dt>OIDC Secret</dt><dd>{{ if .c.OIDCSecret }}&#60;defined&#62;{{ end }}</dd>
<dt>OIDC Discovery URL</dt><dd>{{ if .c.OIDCDiscoveryUrl }}&#60;defined&#62;{{ 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 }}&#60;defined&#62;{{ end }}</dd>
<dt>LDAP Search Base</dt><dd>{{ .c.LDAPSearchBase }}</dd>
<dt>LDAP Search Filter</dt><dd>{{ .c.LDAPSearchFilter }}</dd>
</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>
</div> </div>
{{ end }}
{{ template "admin_footer" .}} {{ define "admin_config_section" }}
{{ template "footer" .}} <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 }}&#60;defined&#62;{{ 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 }}
+47 -41
View File
@@ -1,43 +1,49 @@
{{ template "header" .}} {{ define "content" }}
{{ template "admin_header" .}} <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"> <section class="bg-card overflow-hidden rounded-lg border">
<table class="min-w-full divide-y divide-slate-300 dark:divide-gray-500"> <div class="overflow-x-auto">
<thead> <table class="w-full text-sm">
<tr> <thead class="text-muted-foreground border-b text-left">
<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> <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.gists.title" }}</th> <th scope="col" class="px-4 py-3 font-medium">{{ .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="px-4 py-3 font-medium">{{ .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.gists.private" }}</th> <th scope="col" class="px-4 py-3 font-medium">{{ .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.nb-files" }}</th> <th scope="col" class="px-4 py-3 font-medium">{{ .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-likes" }}</th> <th scope="col" class="px-4 py-3 font-medium">{{ .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.created_at" }}</th> <th scope="col" class="px-4 py-3 font-medium">{{ .locale.Tr "admin.gists.nb-likes" }}</th>
<th scope="col" class="relative whitespace-nowrap py-3.5 pl-3 pr-4 sm:pr-0"> <th scope="col" class="px-4 py-3 font-medium">{{ .locale.Tr "admin.created_at" }}</th>
<span class="sr-only">{{ .locale.Tr "admin.delete" }}</span> <th scope="col" class="px-4 py-3"><span class="sr-only">{{ .locale.Tr "admin.delete" }}</span></th>
</th> </tr>
</tr> </thead>
</thead> <tbody class="divide-y">
<tbody class="divide-y divide-slate-300 dark:divide-gray-500"> {{ range $gist := .data }}
{{ range $gist := .data }} <tr class="hover:bg-muted/40">
<tr> <td class="px-4 py-2">{{ $gist.ID }}</td>
<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="px-4 py-2"><a href="{{ $.c.ExternalUrl }}/{{ $gist.User.Username }}/{{ $gist.Identifier }}" class="text-primary hover:underline">{{ $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.Identifier }}">{{ $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="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="text-muted-foreground px-4 py-2">{{ $gist.Private }}</td>
<td class="whitespace-nowrap px-2 py-2 text-sm text-slate-700 dark:text-slate-300">{{ $gist.Private }}</td> <td class="text-muted-foreground px-4 py-2">{{ $gist.NbFiles }}</td>
<td class="whitespace-nowrap px-2 py-2 text-sm text-slate-700 dark:text-slate-300">{{ $gist.NbFiles }}</td> <td class="text-muted-foreground px-4 py-2">{{ $gist.NbLikes }}</td>
<td class="whitespace-nowrap px-2 py-2 text-sm text-slate-700 dark:text-slate-300">{{ $gist.NbLikes }}</td> <td class="text-muted-foreground px-4 py-2">{{ $gist.CreatedAt | humanDate }}</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="px-4 py-2 text-right">
<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">
<form action="{{ $.c.ExternalUrl }}/admin-panel/gists/{{ $gist.ID }}/delete" method="POST"> {{ $.csrfHtml }}
{{ $.csrfHtml }} <button type="submit" class="text-destructive text-sm font-medium hover:underline"
<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> _="on click if not window.confirm('{{ $.locale.Tr "admin.gists.delete_confirm" }}') halt the event">{{ $.locale.Tr "admin.delete" }}</button>
</form> </form>
</td> </td>
</tr> </tr>
{{ end }} {{ end }}
</tbody> </tbody>
</table> </table>
</div>
</section>
{{ template "pagination" . }}
</div> </div>
{{ end }}
{{ template "admin_footer" .}}
{{ template "footer" .}}
+46 -115
View File
@@ -1,121 +1,52 @@
{{ template "header" .}} {{ define "content" }}
{{ template "admin_header" .}} <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="grid gap-4 sm:grid-cols-2">
<div class="sm:overflow-hidden "> <!-- Versions -->
<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"> <section class="bg-card rounded-lg border">
<div> <div class="border-b px-4 py-3">
<span class="text-base font-bold leading-6 text-slate-700 dark:text-slate-300">{{ .locale.Tr "admin.versions" }}</span> <h2 class="font-semibold">{{ .locale.Tr "admin.versions" }}</h2>
</div> </div>
<table class="table-fixed"> <dl class="divide-y text-sm">
<tbody> <div class="flex items-center justify-between px-4 py-2">
<tr> <dt class="text-muted-foreground">Opengist</dt>
<td class="whitespace-nowrap py-2 pr-3 text-sm text-slate-700 dark:text-slate-300 ">Opengist</td> <dd class="font-medium">{{ .opengistVersion }}</dd>
<td class="whitespace-nowrap px-2 py-2 text-sm font-medium text-slate-700 dark:text-slate-300">{{ .opengistVersion }}</td> </div>
</tr> <div class="flex items-center justify-between px-4 py-2">
<tr> <dt class="text-muted-foreground">Go</dt>
<td class="whitespace-nowrap py-2 pr-3 text-sm text-slate-700 dark:text-slate-300 ">Go</td> <dd class="font-medium">{{ .goVersion }}</dd>
<td class="whitespace-nowrap px-2 py-2 text-sm font-medium text-slate-700 dark:text-slate-300">{{ .goVersion }}</td> </div>
</tr> <div class="flex items-center justify-between px-4 py-2">
<tr> <dt class="text-muted-foreground">Git</dt>
<td class="whitespace-nowrap py-2 pr-3 text-sm text-slate-700 dark:text-slate-300 ">Git</td> <dd class="font-medium">{{ .gitVersion }}</dd>
<td class="whitespace-nowrap px-2 py-2 text-sm font-medium text-slate-700 dark:text-slate-300">{{ .gitVersion }} </td> </div>
</tr> </dl>
</tbody> </section>
</table>
</div>
</div>
<div class="sm:overflow-hidden "> <!-- Stats -->
<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"> <section class="bg-card rounded-lg border">
<div> <div class="border-b px-4 py-3">
<span class="text-base font-bold leading-6 text-slate-700 dark:text-slate-300">{{ .locale.Tr "admin.stats" }}</span> <h2 class="font-semibold">{{ .locale.Tr "admin.stats" }}</h2>
</div> </div>
<table class="table-fixed"> <dl class="divide-y text-sm">
<tbody> <div class="flex items-center justify-between px-4 py-2">
<tr> <dt class="text-muted-foreground">{{ .locale.Tr "admin.users" }}</dt>
<td class="whitespace-nowrap py-2 pr-3 text-sm text-slate-700 dark:text-slate-300 ">{{ .locale.Tr "admin.users" }}</td> <dd class="font-medium">{{ .countUsers }}</dd>
<td class="whitespace-nowrap px-2 py-2 text-sm font-medium text-slate-700 dark:text-slate-300">{{ .countUsers }}</td> </div>
</tr> <div class="flex items-center justify-between px-4 py-2">
<tr> <dt class="text-muted-foreground">{{ .locale.Tr "admin.gists" }}</dt>
<td class="whitespace-nowrap py-2 pr-3 text-sm text-slate-700 dark:text-slate-300 ">{{ .locale.Tr "admin.gists" }}</td> <dd class="font-medium">{{ .countGists }}</dd>
<td class="whitespace-nowrap px-2 py-2 text-sm font-medium text-slate-700 dark:text-slate-300">{{ .countGists }}</td> </div>
</tr> <div class="flex items-center justify-between px-4 py-2">
<tr> <dt class="text-muted-foreground">{{ .locale.Tr "admin.ssh_keys" }}</dt>
<td class="whitespace-nowrap py-2 pr-3 text-sm text-slate-700 dark:text-slate-300 ">{{ .locale.Tr "admin.ssh_keys" }}</td> <dd class="font-medium">{{ .countKeys }}</dd>
<td class="whitespace-nowrap px-2 py-2 text-sm font-medium text-slate-700 dark:text-slate-300">{{ .countKeys }}</td> </div>
</tr> </dl>
</tbody> </section>
</table>
</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>
</div> </div>
{{ end }}
{{ template "admin_footer" .}}
{{ template "footer" .}}
+71 -64
View File
@@ -1,68 +1,75 @@
{{ template "header" .}} {{ define "content" }}
{{ template "admin_header" .}} <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"> <!-- Create invitation -->
{{ .locale.Tr "admin.invitations.help" }} <section class="bg-card rounded-lg border">
</h3> <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>
</section>
<form method="POST"> <!-- Invitation list -->
<div class="flex space-x-4"> <section class="bg-card overflow-hidden rounded-lg border">
<div class="flex-1"> <div class="overflow-x-auto">
<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> <table class="w-full text-sm">
<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"> <thead class="text-muted-foreground border-b text-left">
</div> <tr>
<div class="flex-1"> <th scope="col" class="px-4 py-3 font-medium">{{ .locale.Tr "admin.invitations.code" }}</th>
<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> <th scope="col" class="px-4 py-3 font-medium">{{ .locale.Tr "admin.invitations.copy_link" }}</th>
<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"> <th scope="col" class="px-4 py-3 font-medium">{{ .locale.Tr "admin.invitations.uses" }}</th>
</div> <th scope="col" class="px-4 py-3 font-medium">{{ .locale.Tr "admin.invitations.expires_at" }}</th>
</div> <th scope="col" class="px-4 py-3"><span class="sr-only">{{ .locale.Tr "admin.delete" }}</span></th>
<div class="mt-4"> </tr>
<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> </thead>
</div> <tbody class="divide-y">
{{ .csrfHtml }} {{ range $invitation := .invitations }}
</form> <tr class="hover:bg-muted/40 {{ if not $invitation.IsUsable }}text-muted-foreground italic{{ end }}">
<hr class="my-4" /> <td class="px-4 py-2 font-mono">{{ $invitation.Code }}</td>
<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"> <td class="px-4 py-2">
<table class="min-w-full divide-y divide-slate-300 dark:divide-gray-500"> {{ if $invitation.IsUsable }}
<thead> <button type="button" class="text-muted-foreground hover:text-foreground cursor-pointer" title="{{ $.locale.Tr "admin.invitations.copy_link" }}"
<tr> _="on click call navigator.clipboard.writeText('{{ $.baseHttpUrl }}/-/register?code={{ $invitation.Code }}')
<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> then add .text-primary to me then wait 1s then remove .text-primary from me">
<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> <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>
<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> </button>
<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> {{ else }}
<th scope="col" class="relative whitespace-nowrap py-3.5 pl-3 pr-4 sm:pr-0"> <span class="italic">{{ $.locale.Tr "admin.invitations.expired" }}</span>
<span class="sr-only">{{ .locale.Tr "admin.delete" }}</span> {{ end }}
</th> </td>
</tr> <td class="px-4 py-2">{{ $invitation.NbUsed }}/{{ $invitation.NbMax }}</td>
</thead> <td class="px-4 py-2">{{ $invitation.ExpiresAt | humanDate }}</td>
<tbody class="divide-y divide-slate-300 dark:divide-gray-500"> <td class="px-4 py-2 text-right">
{{ range $invitation := .invitations }} <form action="{{ $.c.ExternalUrl }}/-/admin-panel/invitations/{{ $invitation.ID }}/delete" method="POST">
<tr class="{{ if $invitation.IsUsable }}text-slate-700 dark:text-slate-100{{ else }}text-gray-300 italic{{ end }}"> {{ $.csrfHtml }}
<td class="whitespace-nowrap py-2 px-2 text-sm">{{ $invitation.Code }}</td> <button type="submit" class="text-destructive text-sm font-medium not-italic hover:underline"
<td class="whitespace-nowrap py-2 px-2 text-sm items-center"> _="on click if not window.confirm('{{ $.locale.Tr "admin.invitations.delete_confirm" }}') halt the event">{{ $.locale.Tr "admin.delete" }}</button>
{{ if $invitation.IsUsable }} </form>
<span class="copy-invitation-link" data-link="{{ $.baseHttpUrl }}/register?code={{ $invitation.Code }}"> </td>
<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"> </tr>
<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>
{{ else }}
<span class="italic">{{ $.locale.Tr "admin.invitations.expired" }}</span>
{{ end }} {{ end }}
</td> </tbody>
<td class="whitespace-nowrap py-2 px-2 text-sm">{{ $invitation.NbUsed }}/{{ $invitation.NbMax }}</td> </table>
<td class="whitespace-nowrap px-2 py-2 text-sm"><span>{{ $invitation.ExpiresAt | humanDate }}</span></td> </div>
<td class="relative whitespace-nowrap py-2 pl-3 pr-4 text-right text-sm font-medium sm:pr-0"> </section>
<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>
</form>
</td>
</tr>
{{ end }}
</tbody>
</table>
</div> </div>
{{ end }}
{{ template "admin_footer" .}}
{{ template "footer" .}}

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