diff --git a/Makefile b/Makefile index d46f978..c1365f2 100644 --- a/Makefile +++ b/Makefile @@ -100,4 +100,4 @@ update_js_deps: update_go_deps: @echo "Updating Go dependencies..." - @go get -u ./... && go mod tidy \ No newline at end of file + @go get -u ./... && go mod tidy diff --git a/internal/actions/actions.go b/internal/actions/actions.go index 0606911..c7ff926 100644 --- a/internal/actions/actions.go +++ b/internal/actions/actions.go @@ -59,6 +59,15 @@ func IsRunning(actionType int) bool { return actionType >= 0 && actionType < numActions && running[actionType].Load() } +func IsPeriodic(actionType int) bool { + a, ok := registry[actionType] + return ok && a.spec != "" +} + +func Spec(actionType int) string { + return registry[actionType].spec +} + func RunOnce(actionType int) { a, ok := registry[actionType] if !ok { @@ -89,6 +98,7 @@ func RunOnce(actionType int) { }() log.Info().Msgf("Starting running action %d", actionType) + time.Sleep(100 * time.Millisecond) a.run() log.Info().Msgf("Finished running action %d", actionType) } diff --git a/internal/cli/admin_test.go b/internal/cli/admin_test.go index 7057d07..a08f675 100644 --- a/internal/cli/admin_test.go +++ b/internal/cli/admin_test.go @@ -129,10 +129,10 @@ func TestCmdAdminCreateUser_MissingPassword(t *testing.T) { func TestCmdAdminCreateUser_InvalidUsername(t *testing.T) { // Reserved names must be rejected with the same rules as the web form. - err := runCreateUser(t, "--username", "login", "--password", "pw") + err := runCreateUser(t, "--username", "oauth", "--password", "pw") require.Error(t, err) - _, fetchErr := db.GetUserByUsername("login") + _, fetchErr := db.GetUserByUsername("oauth") require.Error(t, fetchErr, "reserved user must not be persisted") } diff --git a/internal/db/action_lock.go b/internal/db/action_lock.go index c0e6692..6de22df 100644 --- a/internal/db/action_lock.go +++ b/internal/db/action_lock.go @@ -11,6 +11,11 @@ import ( // keyed by Action (the action's identifier); LockedUntil holds the Unix // timestamp the current lease expires at (0 = free). type ActionLock struct { + // autoIncrement:false is required: Action holds the action's own identifier, + // and the first action (SyncReposFromFS) is 0. Without this, GORM treats the + // int primary key as auto-increment and, on Create with the zero value, lets + // the DB assign an id instead — so the action=0 row never exists and its lock + // can never be acquired. Action int `gorm:"primaryKey;autoIncrement:false"` LockedUntil int64 } diff --git a/internal/db/gist.go b/internal/db/gist.go index 96fbdd6..4d5c31d 100644 --- a/internal/db/gist.go +++ b/internal/db/gist.go @@ -167,6 +167,94 @@ func GetAllGistsForCurrentUser(currentUserId uint, since *time.Time, offset int, return gists, err } +// GetAllGistsForCurrentUserFiltered is the filterable variant of +// GetAllGistsForCurrentUser used by the explore "All gists" page. It applies the +// same visibility rule (public gists plus currentUserId's own) and optionally +// narrows by title, language, visibility and topics, returning the total match +// count alongside the page of results. +func GetAllGistsForCurrentUserFiltered(currentUserId uint, title string, language string, visibility string, topics []string, offset int, sort string, order string) ([]*Gist, int64, error) { + var gists []*Gist + var count int64 + + baseQuery := db.Preload("User").Preload("Forked.User").Preload("Topics"). + Where("gists.private = 0 or gists.user_id = ?", currentUserId). + Model(&Gist{}) + + if title != "" { + baseQuery = baseQuery.Where("gists.title like ?", "%"+title+"%") + } + + if language != "" { + baseQuery = baseQuery.Joins("join gist_languages on gists.id = gist_languages.gist_id"). + Where("gist_languages.language = ?", language) + } + + if visibility != "" { + baseQuery = baseQuery.Where("gists.private = ?", ParseVisibility(visibility)) + } + + if len(topics) > 0 { + baseQuery = baseQuery.Joins("join gist_topics on gists.id = gist_topics.gist_id"). + Where("gist_topics.topic in ?", topics) + } + + err := baseQuery.Count(&count).Error + if err != nil { + return nil, 0, err + } + + err = baseQuery.Limit(11). + Offset(offset * 10). + Order("gists." + sort + "_at " + order). + Find(&gists).Error + + return gists, count, err +} + +// GetAllGistsByTopicFiltered returns gists carrying `topic` that are visible to +// currentUserId, optionally narrowed further by the faceted filter params +// (title, language, visibility and extra topics). The path topic is always +// required; any extra topics from the filter are ANDed on top so the result +// stays scoped to the topic page. +func GetAllGistsByTopicFiltered(currentUserId uint, topic string, title string, language string, visibility string, topics []string, offset int, sort string, order string) ([]*Gist, int64, error) { + var gists []*Gist + var count int64 + + baseQuery := db.Preload("User").Preload("Forked.User").Preload("Topics"). + Where("((gists.private = 0) or (gists.private > 0 and gists.user_id = ?))", currentUserId). + Where("exists (select 1 from gist_topics where gist_topics.gist_id = gists.id and gist_topics.topic = ?)", topic). + Model(&Gist{}) + + if title != "" { + baseQuery = baseQuery.Where("gists.title like ?", "%"+title+"%") + } + + if language != "" { + baseQuery = baseQuery.Joins("join gist_languages on gists.id = gist_languages.gist_id"). + Where("gist_languages.language = ?", language) + } + + if visibility != "" { + baseQuery = baseQuery.Where("gists.private = ?", ParseVisibility(visibility)) + } + + for _, t := range topics { + baseQuery = baseQuery.Where("exists (select 1 from gist_topics where gist_topics.gist_id = gists.id and gist_topics.topic = ?)", t) + } + + err := baseQuery.Count(&count).Error + if err != nil { + return nil, 0, err + } + + err = baseQuery.Limit(11). + Offset(offset * 10). + Order("gists." + sort + "_at " + order). + Find(&gists).Error + + return gists, count, err +} + // GetAllGistsFromUserVisibleTo returns gists owned by fromUserId, filtered // to what currentUserId is allowed to see (public always; private/unlisted // only when currentUserId == fromUserId). Same pagination/since shape as @@ -344,6 +432,26 @@ func CountAllGistsLikedByUser(fromUserId uint, currentUserId uint) (int64, error return count, err } +func likedAllStatement(currentUserId uint) *gorm.DB { + return db.Preload("User").Preload("Forked.User").Preload("Topics"). + Where("((gists.private = 0) or (gists.private > 0 and gists.user_id = ?))", currentUserId). + Where("exists (select 1 from likes where likes.gist_id = gists.id)") +} + +func GetAllGistsLiked(currentUserId uint, since *time.Time, offset int, sort string, order string, limit int, perPage int) ([]*Gist, error) { + var gists []*Gist + query := likedAllStatement(currentUserId) + if since != nil { + query = query.Where("gists.updated_at >= ?", since.Unix()) + } + err := query. + Limit(limit). + Offset(offset * perPage). + Order("gists." + sort + "_at " + order). + Find(&gists).Error + return gists, err +} + func forkedStatement(fromUserId uint, currentUserId uint) *gorm.DB { return db.Preload("User").Preload("Forked.User").Preload("Topics"). Where("gists.forked_id is not null and ((gists.private = 0) or (gists.private > 0 and gists.user_id = ?))", currentUserId). @@ -375,6 +483,29 @@ func CountAllGistsForkedByUser(fromUserId uint, currentUserId uint) (int64, erro return count, err } +// forkedAllStatement scopes the explore "recently forked" feed to the source +// gists that have been forked from - i.e. gists that at least one other gist +// points to via forked_id - rather than the fork copies themselves. +func forkedAllStatement(currentUserId uint) *gorm.DB { + return db.Preload("User").Preload("Forked.User").Preload("Topics"). + Where("((gists.private = 0) or (gists.private > 0 and gists.user_id = ?))", currentUserId). + Where("exists (select 1 from gists as forks where forks.forked_id = gists.id)") +} + +func GetAllGistsForked(currentUserId uint, since *time.Time, offset int, sort string, order string, limit int, perPage int) ([]*Gist, error) { + var gists []*Gist + query := forkedAllStatement(currentUserId) + if since != nil { + query = query.Where("gists.updated_at >= ?", since.Unix()) + } + err := query. + Limit(limit). + Offset(offset * perPage). + Order("gists." + sort + "_at " + order). + Find(&gists).Error + return gists, err +} + // applySince narrows a gist query to rows updated at or after `since` when it // is non-nil, matching the filter the API list queries apply. Kept separate so // the count helpers stay in sync with their Find counterparts. @@ -1005,6 +1136,29 @@ type VisibilityDTO struct { Private Visibility `validate:"number,min=0,max=2" form:"private"` } +// GistMetadataDTO carries the editable gist metadata (title, URL path, +// description, topics) without the files, so the settings page can update them +// independently of the gist content. +type GistMetadataDTO struct { + Title string `validate:"max=250" form:"title"` + Description string `validate:"max=1000" form:"description"` + URL string `validate:"max=32,alphanumdashorempty" form:"url"` + Topics string `validate:"gisttopics" form:"topics"` +} + +func (dto *GistMetadataDTO) ToExistingGist(gist *Gist) *Gist { + gist.Title = dto.Title + gist.Description = dto.Description + gist.URL = dto.URL + topics := strings.Fields(dto.Topics) + gistTopics := make([]GistTopic, 0, len(topics)) + for _, topic := range topics { + gistTopics = append(gistTopics, GistTopic{Topic: topic}) + } + gist.Topics = gistTopics + return gist +} + type FileDTO struct { Filename string `validate:"excludes=\x2f,excludes=\x5c,max=255"` Content string diff --git a/internal/db/gist_language.go b/internal/db/gist_language.go index ea77ece..9e3833d 100644 --- a/internal/db/gist_language.go +++ b/internal/db/gist_language.go @@ -27,3 +27,53 @@ func GetGistLanguagesForUser(fromUserId, currentUserId uint) ([]struct { return results, err } + +// GetGistLanguages returns the most common languages across every gist visible +// to currentUserId (public gists plus their own). Used to populate the language +// facet on the explore "All gists" filter. +func GetGistLanguages(currentUserId uint) ([]struct { + Language string + Count int64 +}, error) { + var results []struct { + Language string + Count int64 + } + + err := db.Model(&GistLanguage{}). + Select("language, count(*) as count"). + Joins("JOIN gists ON gists.id = gist_languages.gist_id"). + Where("((gists.private = 0) or (gists.private > 0 and gists.user_id = ?))", currentUserId). + Group("language"). + Order("count DESC"). + Limit(15). + Find(&results).Error + + return results, err +} + +// GetGistLanguagesByTopic returns the most common languages across the gists +// carrying `topic` that are visible to currentUserId. Used to populate the +// language facet on the /-/topics/{topic} filter so it only lists languages +// present among that topic's gists. +func GetGistLanguagesByTopic(currentUserId uint, topic string) ([]struct { + Language string + Count int64 +}, error) { + var results []struct { + Language string + Count int64 + } + + err := db.Model(&GistLanguage{}). + Select("language, count(*) as count"). + Joins("JOIN gists ON gists.id = gist_languages.gist_id"). + Where("((gists.private = 0) or (gists.private > 0 and gists.user_id = ?))", currentUserId). + Where("exists (select 1 from gist_topics where gist_topics.gist_id = gists.id and gist_topics.topic = ?)", topic). + Group("language"). + Order("count DESC"). + Limit(15). + Find(&results).Error + + return results, err +} diff --git a/internal/db/gist_topic.go b/internal/db/gist_topic.go index cf52a9e..078589a 100644 --- a/internal/db/gist_topic.go +++ b/internal/db/gist_topic.go @@ -4,3 +4,28 @@ type GistTopic struct { GistID uint `gorm:"primaryKey"` Topic string `gorm:"primaryKey;size:50"` } + +// TopicCount is a topic together with the number of gists tagged with it. +type TopicCount struct { + Topic string + Count int64 +} + +// GetTopicsWithCount returns topics in use, together with how many gists carry +// each, ordered from most to least used and paginated (limit is typically +// perPage+1 so callers can detect a following page). Only topics on gists +// visible to currentUserId are counted (public gists, plus the user's own +// private ones). +func GetTopicsWithCount(currentUserId uint, offset int, limit int, perPage int) ([]*TopicCount, error) { + var topics []*TopicCount + err := db.Model(&GistTopic{}). + Select("gist_topics.topic as topic, count(*) as count"). + Joins("join gists on gists.id = gist_topics.gist_id"). + Where("gists.private = 0 or gists.user_id = ?", currentUserId). + Group("gist_topics.topic"). + Order("count desc, gist_topics.topic asc"). + Limit(limit). + Offset(offset * perPage). + Find(&topics).Error + return topics, err +} diff --git a/internal/db/user.go b/internal/db/user.go index 2a2a8e5..35cff60 100644 --- a/internal/db/user.go +++ b/internal/db/user.go @@ -124,6 +124,70 @@ func GetAllUsers(offset int) ([]*User, error) { return users, err } +// UserWithNbGists is a user together with the number of gists they own that are +// visible to the viewer (public gists, plus the viewer's own private ones). +type UserWithNbGists struct { + *User + NbGists int64 +} + +// GetUsersWithGistCounts returns a paginated, sorted page of users for the +// explore "Users" list, each annotated with how many gists they own that are +// visible to currentUserId. When query is non-empty, users are filtered to those +// whose username contains it (case-insensitive). sortColumn must be a +// whitelisted column name ("username_normalized" or "created_at") and order +// "asc" or "desc"; both are validated by the caller. limit is typically +// perPage+1 so callers can detect a following page. +func GetUsersWithGistCounts(currentUserId uint, query string, offset int, limit int, perPage int, sortColumn string, order string) ([]*UserWithNbGists, error) { + var users []*User + stmt := db.Model(&User{}) + if query != "" { + stmt = stmt.Where("username_normalized LIKE ?", "%"+strings.ToLower(query)+"%") + } + if err := stmt. + Order(sortColumn + " " + order). + Limit(limit). + Offset(offset * perPage). + Find(&users).Error; err != nil { + return nil, err + } + + result := make([]*UserWithNbGists, 0, len(users)) + if len(users) == 0 { + return result, nil + } + + ids := make([]uint, len(users)) + for i, u := range users { + ids[i] = u.ID + } + + type countRow struct { + UserID uint + Count int64 + } + var rows []countRow + if err := db.Model(&Gist{}). + Select("user_id, count(*) as count"). + Where("user_id IN ?", ids). + Where("private = 0 or user_id = ?", currentUserId). + Group("user_id"). + Find(&rows).Error; err != nil { + return nil, err + } + + counts := make(map[uint]int64, len(rows)) + for _, r := range rows { + counts[r.UserID] = r.Count + } + + for _, u := range users { + result = append(result, &UserWithNbGists{User: u, NbGists: counts[u.ID]}) + } + + return result, nil +} + func GetUserByUsername(username string) (*User, error) { user := new(User) err := db. @@ -301,6 +365,7 @@ type UserUsernameDTO struct { type UserStyleDTO struct { SoftWrap bool `form:"softwrap" json:"soft_wrap"` + ThemeColor string `form:"themecolor" json:"theme_color" validate:"omitempty,themecolor"` RemovedLineColor string `form:"removedlinecolor" json:"removed_line_color" validate:"min=0,max=7"` AddedLineColor string `form:"addedlinecolor" json:"added_line_color" validate:"min=0,max=7"` GitLineColor string `form:"gitlinecolor" json:"git_line_color" validate:"min=0,max=7"` diff --git a/internal/git/mime.go b/internal/git/mime.go index 6c16876..9647418 100644 --- a/internal/git/mime.go +++ b/internal/git/mime.go @@ -21,8 +21,7 @@ func (mt MimeType) IsText() bool { } func (mt MimeType) IsCSV() bool { - return strings.HasPrefix(mt.ContentType, "text/csv") && - (strings.HasSuffix(mt.extension, ".csv")) + return strings.HasSuffix(mt.extension, ".csv") && mt.IsText() } func (mt MimeType) IsImage() bool { diff --git a/internal/i18n/locales/ar-SY.yml b/internal/i18n/locales/ar-SY.yml index 503b4c7..c9a0213 100644 --- a/internal/i18n/locales/ar-SY.yml +++ b/internal/i18n/locales/ar-SY.yml @@ -289,8 +289,6 @@ admin.versions: الإصدارات admin.ssh_keys: مفاتيح SSH admin.stats: الإحصائيات admin.actions: الإجراءات -admin.actions.sync-fs: مزامنة المقاطع من نظام الملفات -admin.actions.sync-db: مزامنة المقاطع من قاعدة البيانات admin.actions.git-gc: تنفيذ جمع القمامة لجميع مستودعات Git admin.actions.sync-previews: مزامنة معاينات جميع المقاطع admin.actions.reset-hooks: إعادة تعيين خطافات خادم Git لجميع المستودعات @@ -335,13 +333,6 @@ flash.admin.user-deleted: تم حذف المستخدم flash.admin.gist-deleted: تم حذف المقطع flash.admin.invitation-created: تم إنشاء الدعوة flash.admin.invitation-deleted: تم حذف الدعوة -flash.admin.sync-fs: تتم مزامنة المستودعات من نظام الملفات... -flash.admin.sync-db: تتم مزامنة المستودعات من قاعدة البيانات... -flash.admin.git-gc: جارٍ تنفيذ جمع القمامة للمستودعات... -flash.admin.sync-previews: تتم مزامنة معاينات المقاطع... -flash.admin.reset-hooks: جارٍ إعادة تعيين خطافات خادم Git لجميع المستودعات... -flash.admin.index-gists: جارٍ إعادة بناء فهرس البحث... -flash.admin.sync-gist-languages: تتم مزامنة لغات المقاطع... flash.auth.username-exists: اسم المستخدم موجود بالفعل flash.auth.invalid-credentials: بيانات الاعتماد غير صالحة diff --git a/internal/i18n/locales/cs-CZ.yml b/internal/i18n/locales/cs-CZ.yml index 156e6ea..95bce41 100644 --- a/internal/i18n/locales/cs-CZ.yml +++ b/internal/i18n/locales/cs-CZ.yml @@ -155,8 +155,6 @@ admin.versions: Verze admin.ssh_keys: SSH klíče admin.stats: Statistiky admin.actions: Akce -admin.actions.sync-fs: Synchronizovat gisty ze souborového systému -admin.actions.sync-db: Synchronizovat gisty z databáze admin.actions.git-gc: Garbage collect git repozitářů admin.id: ID admin.user: Uživatel @@ -201,14 +199,8 @@ flash.admin.user-deleted: '' flash.admin.gist-deleted: '' flash.admin.invitation-created: '' flash.admin.invitation-deleted: '' -flash.admin.sync-fs: '' -flash.admin.sync-db: '' -flash.admin.git-gc: '' -flash.admin.sync-previews: '' gist.new.create-a-new-gist: '' gist.edit.edit-gist: '' -flash.admin.reset-hooks: '' -flash.admin.index-gists: '' flash.auth.username-exists: '' flash.auth.invalid-credentials: '' flash.auth.account-linked-oauth: '' diff --git a/internal/i18n/locales/de-DE.yml b/internal/i18n/locales/de-DE.yml index 82523c9..2e59e36 100644 --- a/internal/i18n/locales/de-DE.yml +++ b/internal/i18n/locales/de-DE.yml @@ -187,8 +187,6 @@ admin.versions: 'Versionen' admin.ssh_keys: 'SSH Schlüssel' admin.stats: 'Statistiken' admin.actions: 'Aktionen' -admin.actions.sync-fs: 'Gists auf dem Dateisystem sychronisieren' -admin.actions.sync-db: 'Gists von der Datenbank synchronisieren' admin.actions.git-gc: '„garbage collection“ bei allen git Repositories ausführen' admin.actions.sync-previews: 'Alle Gist Vorschauen synchronisieren' admin.actions.reset-hooks: 'Alle Git server Hooks für alle Repositories synchronisieren' @@ -231,12 +229,6 @@ flash.admin.user-deleted: 'Benutzer wurde gelöscht' flash.admin.gist-deleted: 'Gist wurde gelöscht' flash.admin.invitation-created: 'Einladung wurde erstellt' flash.admin.invitation-deleted: 'Einladung wurde gelöscht' -flash.admin.sync-fs: 'Synchronisiere Repositories vom Dateisystem...' -flash.admin.sync-db: 'Synchronisiere Repositories aus der Datenbank...' -flash.admin.git-gc: 'Sammle Repositories...' -flash.admin.sync-previews: 'Synchronisiere Gist-Vorschauen...' -flash.admin.reset-hooks: 'Setze Git-Server-Hooks für alle Repositories zurück...' -flash.admin.index-gists: 'Suchindex wird neu aufgebaut...' flash.auth.username-exists: 'Benutzername existiert bereits' flash.auth.invalid-credentials: 'Ungültige Anmeldeinformationen' @@ -305,7 +297,6 @@ auth.totp.save-recovery-codes: Speichere deine Wiederherstellungscodes an einem error.not-in-mfa-session: Nutzer ist nicht in einer Zwei-Faktor-Sitzung gist.revision.binary-file-changes: Änderungen an Binärdateien werden nicht angezeigt error.no-file-uploaded: Keine Datei hochgeladen -flash.admin.sync-gist-languages: Gist-Sprachen werden synchronisiert … validation.invalid-gist-topics: Ungültige Gist-Themen. Sie müssen mit einem Buchstaben oder einer Zahl beginnen, dürfen maximal 50 Zeichen lang sein und dürfen Bindestriche enthalten gist.new.topics: Themen (durch Leerzeichen getrennt) gist.preview-non-available: Vorschau nicht verfügbar diff --git a/internal/i18n/locales/en-US.yml b/internal/i18n/locales/en-US.yml index 749964b..231f2f9 100644 --- a/internal/i18n/locales/en-US.yml +++ b/internal/i18n/locales/en-US.yml @@ -6,6 +6,7 @@ gist.header.like: Like gist.header.unlike: Unlike gist.header.fork: Fork gist.header.edit: Edit +gist.header.edit-files: Edit files gist.header.delete: Delete gist.header.archive: Archive gist.header.unarchive: Unarchive @@ -16,6 +17,7 @@ gist.header.last-active: Last active gist.header.expires: Expires gist.header.select-tab: Select a tab gist.header.code: Code +gist.header.more-actions: More actions gist.header.revisions: Revisions gist.header.revision: Revision gist.header.clone-http: Clone via %s @@ -35,6 +37,7 @@ gist.watch-full-file: View the full file. gist.file-not-valid: This file is not a valid CSV file. gist.no-content: No files found gist.preview-non-available: Preview not available +gist.embed.view-all-files: View all files gist.new.new_gist: New gist gist.new.title: Title @@ -74,9 +77,18 @@ gist.edit.cancel: Cancel gist.edit.save: Save gist.delete.confirm: Are you sure you want to delete this gist ? +gist.settings.visibility-help: Control who can see and find this gist. +gist.settings.archive-help: Archived gists become read-only. You can unarchive at any time. +gist.settings.danger-zone: Danger zone +gist.settings.delete-gist: Delete this gist +gist.settings.delete-help: Once deleted, this gist and all its revisions are gone for good. + gist.list.joined: Joined gist.list.all: All gists +gist.list.recently-liked: Recently liked +gist.list.recently-forked: Recently forked gist.list.search-results: Search results +gist.list.search-for: Results for gist.list.sort: Sort gist.list.sort-by-created: created gist.list.sort-by-updated: updated @@ -84,19 +96,31 @@ gist.list.order-by-asc: Least recently gist.list.order-by-desc: Recently gist.list.select-tab: Select a tab gist.list.liked: Liked -gist.list.likes: likes +gist.list.likes: Likes gist.list.forked: Forked gist.list.forked-from: Forked from -gist.list.forks: forks +gist.list.forks: Forks gist.list.files: files gist.list.last-active: Last active gist.list.no-gists: No gists +gist.list.topics: Topics +gist.list.users: Users +gist.list.no-topics: No topics +gist.list.topic: Topic +gist.list.gists: Gists gist.list.all-liked-by: All gists liked by %s gist.list.all-forked-by: All gists forked by %s gist.list.all-from: All gists from %s gist.list.topic-results-topic: All gists matching topic %s gist.list.topic-results: All gists matching topic +explore.users.no-users: No users +explore.users.search-placeholder: Search users +explore.users.sort-username-asc: Username (A → Z) +explore.users.sort-username-desc: Username (Z → A) +explore.users.sort-joined-desc: Newest +explore.users.sort-joined-asc: Oldest + gist.search.found: gists found gist.search.no-results: No gists found @@ -175,6 +199,7 @@ settings.change-password-help: Change your password to login to Opengist via HTT settings.password-label-title: Password settings.header.account: Account settings.header.mfa: MFA +settings.header.authentication: Authentication settings.header.ssh: SSH settings.header.tokens: Access tokens settings.header.style: Style @@ -270,6 +295,7 @@ auth.totp.scan-qr-code: Scan the QR code below with your authenticator app to en error: Error +error.go-home: Back to home error.page-not-found: Page not found error.bad-request: Bad request error.signup-disabled: Signing up is disabled @@ -287,7 +313,11 @@ error.cannot-open-file: Cannot open uploaded file header.menu.all: All header.menu.new: New +header.menu.topics: Topics +header.menu.users: Users header.menu.search: Search +header.menu.recently-liked: Recently liked +header.menu.recently-forked: Recently forked header.menu.my-gists: My gists header.menu.liked: Liked header.menu.admin: Admin @@ -299,6 +329,8 @@ header.menu.light: Light header.menu.dark: Dark header.menu.system: System footer.powered-by: Powered by %s +ui.feedback-new: Give feedback on the new UI +sidebar.collapse: Collapse pagination.older: Older pagination.newer: Newer @@ -316,8 +348,11 @@ admin.versions: Versions admin.ssh_keys: SSH keys admin.stats: Stats admin.actions: Actions -admin.actions.sync-fs: Synchronize gists from filesystem -admin.actions.sync-db: Synchronize gists from database +admin.actions.subtitle: Run maintenance tasks manually. Some tasks also run automatically on a schedule. +admin.actions.run: Run +admin.actions.running: Running +admin.actions.sync-fs: Delete gists without a repository on disk +admin.actions.sync-db: Delete repositories without a gist in the database admin.actions.git-gc: Garbage collect all git repositories admin.actions.sync-previews: Synchronize all gists previews admin.actions.reset-hooks: Reset Git server hooks for all repositories @@ -366,15 +401,6 @@ flash.admin.user-deleted: User has been deleted flash.admin.gist-deleted: Gist has been deleted flash.admin.invitation-created: Invitation has been created flash.admin.invitation-deleted: Invitation has been deleted -flash.admin.sync-fs: Syncing repositories from filesystem... -flash.admin.sync-db: Syncing repositories from database... -flash.admin.git-gc: Garbage collecting repositories... -flash.admin.sync-previews: Syncing Gist previews... -flash.admin.reset-hooks: Resetting Git server hooks for all repositories... -flash.admin.index-gists: Rebuilding search index... -flash.admin.sync-gist-languages: Syncing Gist languages... -flash.admin.delete-expired-gists: Deleting expired gists... -flash.admin.sync-ssh-keys: Regenerating the authorized_keys file... flash.auth.username-exists: Username already exists flash.auth.invalid-credentials: Invalid credentials @@ -392,6 +418,7 @@ flash.gist.visibility-changed: Gist visibility has been changed flash.gist.archived: Gist has been archived flash.gist.unarchived: Gist has been unarchived flash.gist.deleted: Gist has been deleted +flash.gist.updated: Gist has been updated flash.gist.fork-own-gist: Unable to fork own gists flash.gist.forked: Gist has been forked diff --git a/internal/i18n/locales/es-ES.yml b/internal/i18n/locales/es-ES.yml index 4200e8f..d44a6ec 100644 --- a/internal/i18n/locales/es-ES.yml +++ b/internal/i18n/locales/es-ES.yml @@ -148,8 +148,6 @@ admin.versions: Versiones admin.ssh_keys: Claves SSH admin.stats: Estadísticas admin.actions: Acciones -admin.actions.sync-fs: Sincronizar gists desde el sistema de archivos -admin.actions.sync-db: Sincronizar gists desde la base de datos admin.actions.git-gc: Recolectar basura en los repositorios Git admin.id: ID admin.user: Usuario @@ -226,12 +224,6 @@ flash.admin.user-deleted: 'El usuario ha sido eliminado' flash.admin.gist-deleted: 'El gist ha sido eliminado' flash.admin.invitation-created: 'La invitación ha sido creada' flash.admin.invitation-deleted: 'La invitación ha sido eliminada' -flash.admin.sync-fs: 'Sincronizando repositorios desde el sistema de archivos...' -flash.admin.sync-db: 'Sincronizando repositorios desde la base de datos...' -flash.admin.git-gc: 'Recolectando basura en los repositorios...' -flash.admin.sync-previews: 'Sincronizando vistas previas de gists...' -flash.admin.reset-hooks: 'Reseteando hooks del servidor Git en todos los repositorios...' -flash.admin.index-gists: 'Reconstruyendo índice de búsqueda...' flash.auth.username-exists: 'El nombre de usuario ya existe' flash.auth.invalid-credentials: 'Credenciales incorrectas' flash.auth.account-linked-oauth: 'Cuenta vinculada a %s' diff --git a/internal/i18n/locales/fr-FR.yml b/internal/i18n/locales/fr-FR.yml index 236fba6..df67c08 100644 --- a/internal/i18n/locales/fr-FR.yml +++ b/internal/i18n/locales/fr-FR.yml @@ -148,8 +148,6 @@ admin.versions: Versions admin.ssh_keys: Clés SSH admin.stats: Statistiques admin.actions: Actions -admin.actions.sync-fs: Synchroniser les gists depuis le système de fichiers -admin.actions.sync-db: Synchroniser les gists depuis la base de données admin.actions.git-gc: Nettoyage des dépôts git admin.id: ID admin.user: Utilisateur @@ -226,12 +224,6 @@ flash.admin.user-deleted: 'L''utilisateur a été supprimé' flash.admin.gist-deleted: 'Le gist a été supprimée' flash.admin.invitation-created: 'L''invitation a été créée' flash.admin.invitation-deleted: 'L''invitation a été supprimée' -flash.admin.sync-fs: 'Synchronisation des dépôts à partir du système de fichiers...' -flash.admin.sync-db: 'Synchronisation des dépôts à partir de la base de données...' -flash.admin.git-gc: 'Nettoyage des dépôts...' -flash.admin.sync-previews: 'Synchronisation des aperçus du Gist...' -flash.admin.reset-hooks: 'Réinitialisation des hooks du serveur Git pour tous les dépôts...' -flash.admin.index-gists: 'Reconstruction de l''index de recherche...' flash.auth.username-exists: 'Nom d''utilisateur déjà utilisé' flash.auth.invalid-credentials: 'Identifiants non valides' flash.auth.account-linked-oauth: 'Compte lié à %s' diff --git a/internal/i18n/locales/hu-HU.yml b/internal/i18n/locales/hu-HU.yml index 61e091b..61170ae 100644 --- a/internal/i18n/locales/hu-HU.yml +++ b/internal/i18n/locales/hu-HU.yml @@ -165,8 +165,6 @@ admin.versions: Verziók admin.ssh_keys: SSH kulcsok admin.stats: Statisztikák admin.actions: Műveletek -admin.actions.sync-fs: Gistek szinkronizálása a fájlrendszerrel -admin.actions.sync-db: Gistek szinkronizálása az adatbázissal admin.actions.git-gc: Használatlan git repository-k eltávolítása admin.actions.sync-previews: Gist előnézetek szinkronizálása admin.actions.reset-hooks: Git server hook-ok alaphelyzetbe állítása minden repository-nál @@ -227,12 +225,6 @@ flash.admin.user-deleted: '' flash.admin.gist-deleted: '' flash.admin.invitation-created: '' flash.admin.invitation-deleted: '' -flash.admin.sync-fs: '' -flash.admin.sync-db: '' -flash.admin.git-gc: '' -flash.admin.sync-previews: '' -flash.admin.reset-hooks: '' -flash.admin.index-gists: '' flash.auth.username-exists: '' flash.auth.invalid-credentials: '' flash.auth.account-linked-oauth: '' diff --git a/internal/i18n/locales/it_IT.yml b/internal/i18n/locales/it_IT.yml index 395330b..f7d2d86 100644 --- a/internal/i18n/locales/it_IT.yml +++ b/internal/i18n/locales/it_IT.yml @@ -186,8 +186,6 @@ admin.versions: 'Versioni' admin.ssh_keys: 'Chiavi SSH' admin.stats: 'Statistiche' admin.actions: 'Azioni' -admin.actions.sync-fs: 'Sincronizza gists dal filesystem' -admin.actions.sync-db: 'Sincronizza gists dal database' admin.actions.git-gc: 'Esegui la garbage collection da tutti i repositories' admin.actions.sync-previews: 'Sincronizza tutte le anteprime dei gists' admin.actions.reset-hooks: 'Resetta tutti gli hook del server Git per tutti i repositories' @@ -230,12 +228,6 @@ flash.admin.user-deleted: 'L''utente è stato eliminato' flash.admin.gist-deleted: 'Il gist è stato eliminato' flash.admin.invitation-created: 'L''invito è stato creato' flash.admin.invitation-deleted: 'L''invito è stato eliminato' -flash.admin.sync-fs: 'Sincronizzando i repositories dal filesystem...' -flash.admin.sync-db: 'Sincronizzando i repositories dal database...' -flash.admin.git-gc: 'Eseguendo il garbage collector dei repositories...' -flash.admin.sync-previews: 'Sincronizzando le anteprime dei gists...' -flash.admin.reset-hooks: 'Resettando gli hook di Git per tutti i repositories...' -flash.admin.index-gists: 'Ricostruzione indice di ricerca...' flash.auth.username-exists: 'Il nome utente esiste già' flash.auth.invalid-credentials: 'Credenziali errate' @@ -295,7 +287,6 @@ auth.totp: Time based one-time password (TOTP) auth.totp.help: Il TOTP è un metodo di autenticazione a due fattori che usa una chiave segreta per generare una one-time password (OTP). error.not-in-mfa-session: Non stai usando l'autenticazione a due fattori admin.actions.sync-gist-languages: Sincronizza tutte le lingue dei gist -flash.admin.sync-gist-languages: Sincronizzazione delle lingue gist... flash.auth.passkey-registred: Passkey %s registrata flash.auth.passkey-deleted: Passkey eliminata validation.invalid-gist-topics: 'Argomenti del gist non validi: devono iniziare con una lettera o un numero ed essere composti da al massimo 50 caratteri. Possono includere trattini' diff --git a/internal/i18n/locales/ja_JP.yml b/internal/i18n/locales/ja_JP.yml index a83763b..895b8a5 100644 --- a/internal/i18n/locales/ja_JP.yml +++ b/internal/i18n/locales/ja_JP.yml @@ -236,8 +236,6 @@ admin.versions: '' admin.ssh_keys: '' admin.stats: '' admin.actions: '' -admin.actions.sync-fs: '' -admin.actions.sync-db: '' admin.actions.git-gc: '' admin.actions.sync-previews: '' admin.actions.reset-hooks: '' @@ -282,13 +280,6 @@ flash.admin.user-deleted: '' flash.admin.gist-deleted: '' flash.admin.invitation-created: '' flash.admin.invitation-deleted: '' -flash.admin.sync-fs: '' -flash.admin.sync-db: '' -flash.admin.git-gc: '' -flash.admin.sync-previews: '' -flash.admin.reset-hooks: '' -flash.admin.index-gists: '' -flash.admin.sync-gist-languages: '' flash.auth.username-exists: '' flash.auth.invalid-credentials: '' diff --git a/internal/i18n/locales/ko-KR.yml b/internal/i18n/locales/ko-KR.yml index 222a994..c3cc78b 100644 --- a/internal/i18n/locales/ko-KR.yml +++ b/internal/i18n/locales/ko-KR.yml @@ -366,15 +366,6 @@ flash.admin.user-deleted: 사용자가 삭제되었습니다 flash.admin.gist-deleted: Gist가 삭제되었습니다 flash.admin.invitation-created: 초대가 생성되었습니다 flash.admin.invitation-deleted: 초대가 삭제되었습니다 -flash.admin.sync-fs: 파일 시스템에서 리포지토리를 동기화하는 중... -flash.admin.sync-db: 데이터베이스에서 리포지토리를 동기화하는 중... -flash.admin.git-gc: 리포지토리를 가비지 컬렉션하는 중... -flash.admin.sync-previews: Gist 미리보기를 동기화하는 중... -flash.admin.reset-hooks: 모든 리포지토리의 Git 서버 훅을 초기화하는 중... -flash.admin.index-gists: 검색 인덱스를 재구성하는 중... -flash.admin.sync-gist-languages: Gist 언어를 동기화하는 중... -flash.admin.delete-expired-gists: 만료된 gist를 삭제하는 중... -flash.admin.sync-ssh-keys: authorized_keys 파일을 재생성하는 중... flash.auth.username-exists: 이미 존재하는 사용자 이름입니다 flash.auth.invalid-credentials: 잘못된 인증 정보입니다 diff --git a/internal/i18n/locales/pl_PL.yml b/internal/i18n/locales/pl_PL.yml index 99f8080..ec72fcd 100644 --- a/internal/i18n/locales/pl_PL.yml +++ b/internal/i18n/locales/pl_PL.yml @@ -222,8 +222,6 @@ admin.versions: 'Wersje' admin.ssh_keys: 'Klucze SSH' admin.stats: 'Statystyki' admin.actions: 'Akcje' -admin.actions.sync-fs: 'Synchronizuj Gisty z systemu plików' -admin.actions.sync-db: 'Synchronizuj Gisty z bazy danych' admin.actions.git-gc: 'Zbierz śmieci we wszystkich repozytoriach Git' admin.actions.sync-previews: 'Synchronizuj podglądy wszystkich Gistów' admin.actions.reset-hooks: 'Zresetuj hooki serwera Git dla wszystkich repozytoriów' @@ -266,12 +264,6 @@ flash.admin.user-deleted: 'Użytkownik został usunięty' flash.admin.gist-deleted: 'Gist został usunięty' flash.admin.invitation-created: 'Zaproszenie zostało stworzone' flash.admin.invitation-deleted: 'Zaproszenie zostało usunięte' -flash.admin.sync-fs: 'Synchronizowanie repozytoriów z systemu plików...' -flash.admin.sync-db: 'Synchronizowanie repozytoriów z bazy danych...' -flash.admin.git-gc: 'Zbieranie śmieci w repozytoriach...' -flash.admin.sync-previews: 'Synchronizowanie podglądów Gistów...' -flash.admin.reset-hooks: 'Resetowanie hooków serwera Git dla wszystkich repozytoriów...' -flash.admin.index-gists: 'Przebudowywanie indeksu wyszukiwania...' flash.auth.username-exists: 'Nazwa użytkownika już istnieje' flash.auth.invalid-credentials: 'Niepoprawne dane logowania' diff --git a/internal/i18n/locales/pt-BR.yml b/internal/i18n/locales/pt-BR.yml index ecad48c..e2463da 100644 --- a/internal/i18n/locales/pt-BR.yml +++ b/internal/i18n/locales/pt-BR.yml @@ -146,8 +146,6 @@ admin.versions: Versões admin.ssh_keys: Chaves SSH admin.stats: Estatísticas admin.actions: Ações -admin.actions.sync-fs: Sincronizar gists do sistema de arquivos -admin.actions.sync-db: Sincronizar gists do banco de dados admin.actions.git-gc: Coletar lixo nos repositórios Git admin.id: ID admin.user: Usuário @@ -172,7 +170,6 @@ admin.gists.private: Privado admin.gists.nb-files: Núm. de arquivos admin.gists.nb-likes: Núm. de curtidas admin.gists.delete_confirm: Quer excluir este gist? -flash.admin.index-gists: '' gist.header.embed: '' gist.header.embed-help: '' gist.new.url: '' @@ -228,11 +225,6 @@ flash.admin.user-deleted: '' flash.admin.gist-deleted: '' flash.admin.invitation-created: '' flash.admin.invitation-deleted: '' -flash.admin.sync-fs: '' -flash.admin.sync-db: '' -flash.admin.git-gc: '' -flash.admin.sync-previews: '' -flash.admin.reset-hooks: '' flash.auth.username-exists: '' flash.auth.invalid-credentials: '' flash.auth.account-linked-oauth: '' diff --git a/internal/i18n/locales/ru-RU.yml b/internal/i18n/locales/ru-RU.yml index 1dd27f5..c5f7fec 100644 --- a/internal/i18n/locales/ru-RU.yml +++ b/internal/i18n/locales/ru-RU.yml @@ -148,8 +148,6 @@ admin.versions: Версии admin.ssh_keys: Ключи SSH admin.stats: Статистика admin.actions: Действия -admin.actions.sync-fs: Синхронизировать фрагменты из файловой системы -admin.actions.sync-db: Синхронизировать фрагменты с базой данных admin.actions.git-gc: Сборка мусора в репозиториях Git admin.id: ID admin.user: Пользователь @@ -227,12 +225,6 @@ flash.admin.user-deleted: 'Пользователь удалён' flash.admin.gist-deleted: 'Фрагмент удалён' flash.admin.invitation-created: 'Приглашение создано' flash.admin.invitation-deleted: 'Приглашение удалено' -flash.admin.sync-fs: 'Выполняется синхронизация репозиториев с файловой системой…' -flash.admin.sync-db: 'Выполняется синхронизация репозиториев с базой данных…' -flash.admin.git-gc: 'Сборка мусора в репозиториях…' -flash.admin.sync-previews: 'Обновление предпросмотров фрагментов…' -flash.admin.reset-hooks: 'Пересоздание Git-хуков для всех репозиториев…' -flash.admin.index-gists: 'Перестроение поискового индекса…' flash.auth.username-exists: 'Такое имя пользователя уже занято' flash.auth.invalid-credentials: 'Некорректные данные для входа' flash.auth.account-linked-oauth: 'Учётная запись связана с %s' @@ -334,7 +326,6 @@ auth.totp.help: TOTP — это метод двухфакторной аутен auth.totp.use: Использовать TOTP auth.totp.regenerate-recovery-codes: Сгенерировать коды восстановления заново auth.totp: Одноразовый пароль по времени (TOTP) -flash.admin.sync-gist-languages: Обновление языков фрагментов… settings.token-created: Токен создан, обязательно сохраните его, повторно он показан не будет! settings.token-last-used: Последнее использование settings.token-no-expiration: Бессрочно diff --git a/internal/i18n/locales/tr-TR.yml b/internal/i18n/locales/tr-TR.yml index 4ded186..193489d 100644 --- a/internal/i18n/locales/tr-TR.yml +++ b/internal/i18n/locales/tr-TR.yml @@ -186,8 +186,6 @@ admin.versions: Sürümler admin.ssh_keys: SSH anahtarları admin.stats: İstatistikler admin.actions: Eylemler -admin.actions.sync-fs: Gistleri dosya sisteminden senkronize et -admin.actions.sync-db: Gistleri veri tabanından senkronize et admin.actions.git-gc: Tüm Git depolarındaki gereksiz verileri temizle admin.actions.sync-previews: Tüm gist önizlemelerini senkronize et admin.actions.reset-hooks: Tüm depolar için Git sunucu kancalarını sıfırla @@ -229,12 +227,6 @@ flash.admin.user-deleted: Kullanıcı silindi flash.admin.gist-deleted: Gist silindi flash.admin.invitation-created: Davetiye oluşturuldu flash.admin.invitation-deleted: Davetiye silindi -flash.admin.sync-fs: Depolar dosya sisteminden senkronize ediliyor... -flash.admin.sync-db: Depolar veri tabanından senkronize ediliyor... -flash.admin.git-gc: Depolardan gereksiz veriler temizleniyor... -flash.admin.sync-previews: Gist önizlemeleri senkronize ediliyor... -flash.admin.reset-hooks: Tüm depolar için Git sunucusu kancaları sıfırlanıyor... -flash.admin.index-gists: Arama dizini yeniden oluşturuluyor... flash.auth.username-exists: Kullanıcı adı zaten mevcut flash.auth.invalid-credentials: Geçersiz kimlik bilgileri @@ -274,7 +266,6 @@ auth.mfa.waiting-for-passkey-input: Tarayıcı etkileşiminden gelecek girdi bek settings.header.account: Hesap settings.style.no-soft-wrap: Yumuşak Satır Kaydırma Yok auth.totp: Zamana Dayalı Tek Kullanımlık Parola (TOTP) -flash.admin.sync-gist-languages: Gist dilleri senkronize ediliyor... auth.mfa.passkeys-help: Hesabınıza giriş yapmak ve çok faktörlü kimlik doğrulama yöntemi olarak kullanmak için bir geçiş anahtarı ekleyin. validation.invalid-gist-topics: Geçersiz gist konuları, harf veya rakamla başlamalı, 50 karakterden uzun olmamalı ve tire içerebilir. auth.totp.enter-recovery-key: veya cihazınızı kaybettiyseniz kurtarma anahtarını kullanın diff --git a/internal/i18n/locales/uk-UK.yml b/internal/i18n/locales/uk-UK.yml index 096032a..1961ca5 100644 --- a/internal/i18n/locales/uk-UK.yml +++ b/internal/i18n/locales/uk-UK.yml @@ -187,8 +187,6 @@ admin.versions: Версії admin.ssh_keys: Ключі SSH admin.stats: Статистика admin.actions: Дії -admin.actions.sync-fs: Синхронізувати gists з файлової системи -admin.actions.sync-db: Синхронізувати gists з базою даних admin.actions.git-gc: Збір сміття з репозиторіїв Git admin.actions.sync-previews: Синхронізувати всі gists перегляди admin.actions.reset-hooks: Скинути серверні Git hooks для всіх репозиторіїв @@ -231,12 +229,6 @@ flash.admin.user-deleted: Користувач був видалений flash.admin.gist-deleted: Gist був видалений flash.admin.invitation-created: Запрошення було створено flash.admin.invitation-deleted: Запрошення було видалено -flash.admin.sync-fs: Синхронізація репозиторіїв за файловою системою... -flash.admin.sync-db: Синхронізація репозиторіїв за базою даних... -flash.admin.git-gc: Збір сміття з репозиторіїв... -flash.admin.sync-previews: Синхронізація Gist переглядів... -flash.admin.reset-hooks: Скидання cерверниз Git hooks для всіх репозиторіїв... -flash.admin.index-gists: Перебудова пошукового індексу... flash.auth.username-exists: Це ім'я користувача вже існує flash.auth.invalid-credentials: Недійсні облікові дані diff --git a/internal/i18n/locales/zh-CN.yml b/internal/i18n/locales/zh-CN.yml index 3dbd2de..8cc9cbb 100644 --- a/internal/i18n/locales/zh-CN.yml +++ b/internal/i18n/locales/zh-CN.yml @@ -177,8 +177,6 @@ admin.versions: 版本 admin.ssh_keys: SSH 密钥 admin.stats: 状态 admin.actions: 动作 -admin.actions.sync-fs: 从文件系统同步 Gist -admin.actions.sync-db: 从数据库同步 Gist admin.actions.git-gc: 对 Git 仓库执行垃圾回收 admin.id: ID admin.user: 用户 @@ -257,12 +255,6 @@ flash.admin.user-deleted: '用户已被删除' flash.admin.gist-deleted: 'Gist 已被删除' flash.admin.invitation-created: '邀请已被创建' flash.admin.invitation-deleted: '邀请已被删除' -flash.admin.sync-fs: '正在从文件系统同步存储库...' -flash.admin.sync-db: '正在从数据库同步存储库...' -flash.admin.git-gc: '正在进行存储库垃圾回收...' -flash.admin.sync-previews: '正在同步 Gist 预览...' -flash.admin.reset-hooks: '正在重置所有存储库的 Git 服务挂钩...' -flash.admin.index-gists: '正在重建搜索索引...' flash.auth.username-exists: '用户名已存在' flash.auth.invalid-credentials: '无效的凭证' flash.auth.account-linked-oauth: '帐户已关联到 %s' @@ -330,7 +322,6 @@ gist.new.topics: 主题(用空格分隔) validation.invalid-gist-topics: 无效的 Gists 主题,它们必须以字母或数字开头,长度不超过50个字符,并且可以包含连字符 gist.list.topic-results-topic: '%s 与主题匹配的所有 Gists' admin.actions.sync-gist-languages: 同步所有 gists 语言 -flash.admin.sync-gist-languages: 正在同步 Gist 语言... gist.list.topic-results: 所有匹配主题的 Gist gist.search.help.topic: 具有给定主题的 Gists gist.search.placeholder.title: 标题 diff --git a/internal/i18n/locales/zh-TW.yml b/internal/i18n/locales/zh-TW.yml index 22234c5..037b8ff 100644 --- a/internal/i18n/locales/zh-TW.yml +++ b/internal/i18n/locales/zh-TW.yml @@ -157,8 +157,6 @@ admin.versions: 版本 admin.ssh_keys: SSH 金鑰 admin.stats: 統計 admin.actions: 操作 -admin.actions.sync-fs: 從系統同步 Gists -admin.actions.sync-db: 從資料庫同步 Gists admin.actions.git-gc: 清理所有的 git 儲存庫 admin.actions.sync-previews: 同步所有 Gists 預覽 admin.actions.reset-hooks: 重置 Git 伺服器所有儲存庫的 Git hooks @@ -226,12 +224,6 @@ flash.admin.user-deleted: '' flash.admin.gist-deleted: '' flash.admin.invitation-created: '' flash.admin.invitation-deleted: '' -flash.admin.sync-fs: '' -flash.admin.sync-db: '' -flash.admin.git-gc: '' -flash.admin.sync-previews: '' -flash.admin.reset-hooks: '' -flash.admin.index-gists: '' flash.auth.username-exists: '' flash.auth.invalid-credentials: '' flash.auth.account-linked-oauth: '' diff --git a/internal/validator/validator.go b/internal/validator/validator.go index b422223..4faa7cf 100644 --- a/internal/validator/validator.go +++ b/internal/validator/validator.go @@ -23,9 +23,14 @@ func NewValidator() *OpengistValidator { _ = v.RegisterValidation("alphanumdashunderorempty", validateAlphaNumDashUnderOrEmpty) _ = v.RegisterValidation("gisttopics", validateGistTopics) _ = v.RegisterValidation("expirationdate", validateExpirationDate) + _ = v.RegisterValidation("themecolor", validateThemeColor) return &OpengistValidator{v} } +var ThemeColors = []string{ + "red", "amber", "emerald", "sky", "indigo", "purple", "neutral", +} + func (cv *OpengistValidator) Validate(i interface{}) error { return cv.v.Struct(i) } @@ -69,7 +74,7 @@ func validateReservedKeywords(fl validator.FieldLevel) bool { name := fl.Field().String() restrictedNames := map[string]struct{}{} - for _, restrictedName := range []string{"assets", "register", "login", "logout", "settings", "admin-panel", "all", "search", "init", "healthcheck", "preview", "metrics", "mfa", "webauthn", "oauth"} { + for _, restrictedName := range []string{"api", "assets", "init", "healthcheck", "preview", "metrics", "mfa", "webauthn", "oauth"} { restrictedNames[restrictedName] = struct{}{} } @@ -79,7 +84,9 @@ func validateReservedKeywords(fl validator.FieldLevel) bool { } func validateAlphaNumDash(fl validator.FieldLevel) bool { - return regexp.MustCompile(`^[a-zA-Z0-9-]+$`).MatchString(fl.Field().String()) + value := fl.Field().String() + return regexp.MustCompile(`^[a-zA-Z0-9-]+$`).MatchString(value) && + regexp.MustCompile(`[a-zA-Z0-9]`).MatchString(value) } func validateAlphaNumDashOrEmpty(fl validator.FieldLevel) bool { @@ -87,13 +94,28 @@ func validateAlphaNumDashOrEmpty(fl validator.FieldLevel) bool { } func validateAlphaNumDashUnder(fl validator.FieldLevel) bool { - return regexp.MustCompile(`^[a-zA-Z0-9-_]+$`).MatchString(fl.Field().String()) + value := fl.Field().String() + return regexp.MustCompile(`^[a-zA-Z0-9-_]+$`).MatchString(value) && + regexp.MustCompile(`[a-zA-Z0-9]`).MatchString(value) } func validateAlphaNumDashUnderOrEmpty(fl validator.FieldLevel) bool { return regexp.MustCompile(`^$|^[a-zA-Z0-9-_]+$`).MatchString(fl.Field().String()) } +func validateThemeColor(fl validator.FieldLevel) bool { + color := fl.Field().String() + if color == "" { + return true + } + for _, c := range ThemeColors { + if c == color { + return true + } + } + return false +} + func validateGistTopics(fl validator.FieldLevel) bool { topicsInput := fl.Field().String() if topicsInput == "" { diff --git a/internal/validator/validator_test.go b/internal/validator/validator_test.go new file mode 100644 index 0000000..9ef36e3 --- /dev/null +++ b/internal/validator/validator_test.go @@ -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")) +} diff --git a/internal/web/handlers/admin/actions.go b/internal/web/handlers/admin/actions.go index ceec380..bee5a2c 100644 --- a/internal/web/handlers/admin/actions.go +++ b/internal/web/handlers/admin/actions.go @@ -2,59 +2,114 @@ package admin import ( "github.com/thomiceli/opengist/internal/actions" + "github.com/thomiceli/opengist/internal/config" "github.com/thomiceli/opengist/internal/web/context" ) +// actionView is the template model for a single row on the admin actions page. +type actionView struct { + Path string // POST endpoint suffix under /-/admin-panel, e.g. "sync-fs" + LabelKey string // i18n key for the action's label + Running bool // currently in progress in this instance + Periodic bool // also runs automatically on a schedule + Spec string // raw schedule spec, e.g. "@every 72h"; "" if not periodic +} + +// adminActions lists every action shown on the actions page, in display order. +// The Path values match the POST routes registered in the router. +var adminActions = []struct { + Type int + Path string + Key string +}{ + {actions.SyncReposFromFS, "sync-fs", "admin.actions.sync-fs"}, + {actions.SyncReposFromDB, "sync-db", "admin.actions.sync-db"}, + {actions.GitGcRepos, "gc-repos", "admin.actions.git-gc"}, + {actions.SyncGistPreviews, "sync-previews", "admin.actions.sync-previews"}, + {actions.ResetHooks, "reset-hooks", "admin.actions.reset-hooks"}, + {actions.IndexGists, "index-gists", "admin.actions.index-gists"}, + {actions.SyncGistLanguages, "sync-languages", "admin.actions.sync-gist-languages"}, + {actions.DeleteExpiredGists, "delete-expired-gists", "admin.actions.delete-expired-gists"}, + {actions.SyncSSHKeys, "sync-ssh-keys", "admin.actions.sync-ssh-keys"}, +} + +func AdminActions(ctx *context.Context) error { + ctx.SetData("htmlTitle", ctx.TrH("admin.actions")+" - "+ctx.TrH("admin.admin_panel")) + ctx.SetData("adminHeaderPage", "actions") + + manageSSHKeys := config.C.SshManagesAuthorizedKeys() + + views := make([]actionView, 0, len(adminActions)) + anyRunning := false + for _, a := range adminActions { + // The authorized_keys action is only relevant when Opengist manages the file. + if a.Type == actions.SyncSSHKeys && !manageSSHKeys { + continue + } + + running := actions.IsRunning(a.Type) + if running { + anyRunning = true + } + + views = append(views, actionView{ + Path: a.Path, + LabelKey: a.Key, + Running: running, + Periodic: actions.IsPeriodic(a.Type), + Spec: actions.Spec(a.Type), + }) + } + + ctx.SetData("actions", views) + ctx.SetData("anyRunning", anyRunning) + // Set when arriving right after triggering a run: keeps the list polling for + // a moment even if the goroutine hasn't flipped the running flag yet. + ctx.SetData("pollNow", ctx.QueryParam("run") == "1") + return ctx.Html("admin_actions.html") +} + func AdminSyncReposFromFS(ctx *context.Context) error { - ctx.AddFlash(ctx.Tr("flash.admin.sync-fs"), "success") - go actions.RunOnce(actions.SyncReposFromFS) - return ctx.RedirectTo("/admin-panel") + return runAdminAction(ctx, actions.SyncReposFromFS, "flash.admin.sync-fs") } func AdminSyncReposFromDB(ctx *context.Context) error { - ctx.AddFlash(ctx.Tr("flash.admin.sync-db"), "success") - go actions.RunOnce(actions.SyncReposFromDB) - return ctx.RedirectTo("/admin-panel") + return runAdminAction(ctx, actions.SyncReposFromDB, "flash.admin.sync-db") } func AdminGcRepos(ctx *context.Context) error { - ctx.AddFlash(ctx.Tr("flash.admin.git-gc"), "success") - go actions.RunOnce(actions.GitGcRepos) - return ctx.RedirectTo("/admin-panel") + return runAdminAction(ctx, actions.GitGcRepos, "flash.admin.git-gc") } func AdminSyncGistPreviews(ctx *context.Context) error { - ctx.AddFlash(ctx.Tr("flash.admin.sync-previews"), "success") - go actions.RunOnce(actions.SyncGistPreviews) - return ctx.RedirectTo("/admin-panel") + return runAdminAction(ctx, actions.SyncGistPreviews, "flash.admin.sync-previews") } func AdminResetHooks(ctx *context.Context) error { - ctx.AddFlash(ctx.Tr("flash.admin.reset-hooks"), "success") - go actions.RunOnce(actions.ResetHooks) - return ctx.RedirectTo("/admin-panel") + return runAdminAction(ctx, actions.ResetHooks, "flash.admin.reset-hooks") } func AdminIndexGists(ctx *context.Context) error { - ctx.AddFlash(ctx.Tr("flash.admin.index-gists"), "success") - go actions.RunOnce(actions.IndexGists) - return ctx.RedirectTo("/admin-panel") + return runAdminAction(ctx, actions.IndexGists, "flash.admin.index-gists") } func AdminSyncGistLanguages(ctx *context.Context) error { - ctx.AddFlash(ctx.Tr("flash.admin.sync-gist-languages"), "success") - go actions.RunOnce(actions.SyncGistLanguages) - return ctx.RedirectTo("/admin-panel") + return runAdminAction(ctx, actions.SyncGistLanguages, "flash.admin.sync-gist-languages") } func AdminDeleteExpiredGists(ctx *context.Context) error { - ctx.AddFlash(ctx.Tr("flash.admin.delete-expired-gists"), "success") - go actions.RunOnce(actions.DeleteExpiredGists) - return ctx.RedirectTo("/admin-panel") + return runAdminAction(ctx, actions.DeleteExpiredGists, "flash.admin.delete-expired-gists") } func AdminSyncSSHKeys(ctx *context.Context) error { - ctx.AddFlash(ctx.Tr("flash.admin.sync-ssh-keys"), "success") - go actions.RunOnce(actions.SyncSSHKeys) - return ctx.RedirectTo("/admin-panel") + return runAdminAction(ctx, actions.SyncSSHKeys, "flash.admin.sync-ssh-keys") +} + +func runAdminAction(ctx *context.Context, actionType int, legacyFlashKey string) error { + go actions.RunOnce(actionType) + if ctx.QueryParam("legacy") == "1" { + ctx.AddFlash(ctx.Tr(legacyFlashKey), "success") + return ctx.RedirectTo("/-/admin-panel") + } + return ctx.RedirectTo("/-/admin-panel/actions?run=1") } diff --git a/internal/web/handlers/admin/actions_test.go b/internal/web/handlers/admin/actions_test.go deleted file mode 100644 index 65cefdb..0000000 --- a/internal/web/handlers/admin/actions_test.go +++ /dev/null @@ -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) - } - }) -} diff --git a/internal/web/handlers/admin/admin.go b/internal/web/handlers/admin/admin.go index 653a472..9453df2 100644 --- a/internal/web/handlers/admin/admin.go +++ b/internal/web/handlers/admin/admin.go @@ -44,6 +44,8 @@ func AdminIndex(ctx *context.Context) error { } ctx.SetData("countKeys", countKeys) + // The legacy admin dashboard renders actions on this page. Keep supplying + // their state while the new UI uses the dedicated actions page. ctx.SetData("syncReposFromFS", actions.IsRunning(actions.SyncReposFromFS)) ctx.SetData("syncReposFromDB", actions.IsRunning(actions.SyncReposFromDB)) ctx.SetData("gitGcRepos", actions.IsRunning(actions.GitGcRepos)) @@ -108,7 +110,7 @@ func AdminUserDelete(ctx *context.Context) error { opengistssh.SyncAuthorizedKeysLogged() ctx.AddFlash(ctx.Tr("flash.admin.user-deleted"), "success") - return ctx.RedirectTo("/admin-panel/users") + return ctx.RedirectTo("/-/admin-panel/users") } func AdminGistDelete(ctx *context.Context) error { @@ -124,7 +126,7 @@ func AdminGistDelete(ctx *context.Context) error { gist.RemoveFromIndex() ctx.AddFlash(ctx.Tr("flash.admin.gist-deleted"), "success") - return ctx.RedirectTo("/admin-panel/gists") + return ctx.RedirectTo("/-/admin-panel/gists") } func AdminConfig(ctx *context.Context) error { @@ -187,7 +189,7 @@ func AdminInvitationsCreate(ctx *context.Context) error { } ctx.AddFlash(ctx.Tr("flash.admin.invitation-created"), "success") - return ctx.RedirectTo("/admin-panel/invitations") + return ctx.RedirectTo("/-/admin-panel/invitations") } func AdminInvitationsDelete(ctx *context.Context) error { @@ -202,5 +204,5 @@ func AdminInvitationsDelete(ctx *context.Context) error { } ctx.AddFlash(ctx.Tr("flash.admin.invitation-deleted"), "success") - return ctx.RedirectTo("/admin-panel/invitations") + return ctx.RedirectTo("/-/admin-panel/invitations") } diff --git a/internal/web/handlers/admin/admin_test.go b/internal/web/handlers/admin/admin_test.go index ad06bdd..b72f5af 100644 --- a/internal/web/handlers/admin/admin_test.go +++ b/internal/web/handlers/admin/admin_test.go @@ -20,11 +20,12 @@ func TestAdminPages(t *testing.T) { defer webtest.Teardown(t) urls := []string{ - "/admin-panel", - "/admin-panel/users", - "/admin-panel/gists", - "/admin-panel/invitations", - "/admin-panel/configuration", + "/-/admin-panel", + "/-/admin-panel/users", + "/-/admin-panel/gists", + "/-/admin-panel/invitations", + "/-/admin-panel/configuration", + "/-/admin-panel/actions", } s.Register(t, "thomas") @@ -67,12 +68,12 @@ func TestAdminSetConfig(t *testing.T) { s.Register(t, "nonadmin") t.Run("NoUser", func(t *testing.T) { - s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {db.SettingDisableSignup}, "value": {"1"}}, 404) + s.Request(t, "PUT", "/-/admin-panel/set-config", url.Values{"key": {db.SettingDisableSignup}, "value": {"1"}}, 404) }) t.Run("NonAdminUser", func(t *testing.T) { s.Login(t, "nonadmin") - s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {db.SettingDisableSignup}, "value": {"1"}}, 404) + s.Request(t, "PUT", "/-/admin-panel/set-config", url.Values{"key": {db.SettingDisableSignup}, "value": {"1"}}, 404) }) t.Run("AdminUser", func(t *testing.T) { @@ -83,13 +84,13 @@ func TestAdminSetConfig(t *testing.T) { require.NoError(t, err) require.Equal(t, "0", val) - s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {setting}, "value": {"1"}}, 200) + s.Request(t, "PUT", "/-/admin-panel/set-config", url.Values{"key": {setting}, "value": {"1"}}, 200) val, err = db.GetSetting(setting) require.NoError(t, err) require.Equal(t, "1", val) - s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {setting}, "value": {"0"}}, 200) + s.Request(t, "PUT", "/-/admin-panel/set-config", url.Values{"key": {setting}, "value": {"0"}}, 200) val, err = db.GetSetting(setting) require.NoError(t, err) @@ -110,12 +111,12 @@ func TestAdminPagination(t *testing.T) { t.Run("Pagination", func(t *testing.T) { s.Login(t, "thomas") - s.Request(t, "GET", "/admin-panel/users", nil, 200) - s.Request(t, "GET", "/admin-panel/users?page=2", nil, 200) - s.Request(t, "GET", "/admin-panel/users?page=3", nil, 404) - s.Request(t, "GET", "/admin-panel/users?page=0", nil, 200) - s.Request(t, "GET", "/admin-panel/users?page=-1", nil, 200) - s.Request(t, "GET", "/admin-panel/users?page=a", nil, 200) + s.Request(t, "GET", "/-/admin-panel/users", nil, 200) + s.Request(t, "GET", "/-/admin-panel/users?page=2", nil, 200) + s.Request(t, "GET", "/-/admin-panel/users?page=3", nil, 404) + s.Request(t, "GET", "/-/admin-panel/users?page=0", nil, 200) + s.Request(t, "GET", "/-/admin-panel/users?page=-1", nil, 200) + s.Request(t, "GET", "/-/admin-panel/users?page=a", nil, 200) }) } @@ -147,11 +148,11 @@ func TestAdminUserOperations(t *testing.T) { require.NoError(t, err) require.Equal(t, int64(2), count) - s.Request(t, "POST", "/admin-panel/users/2/delete", nil, 404) + s.Request(t, "POST", "/-/admin-panel/users/2/delete", nil, 404) s.Login(t, "thomas") - s.Request(t, "POST", "/admin-panel/users/2/delete", nil, 302) + s.Request(t, "POST", "/-/admin-panel/users/2/delete", nil, 302) count, err = db.CountAll(db.User{}) require.NoError(t, err) @@ -192,11 +193,11 @@ func TestAdminGistOperations(t *testing.T) { _, err = os.Stat(filepath.Join(config.GetHomeDir(), git.ReposDirectory, "nonadmin", gist1Db.Identifier())) require.NoError(t, err) - s.Request(t, "POST", "/admin-panel/gists/1/delete", nil, 404) + s.Request(t, "POST", "/-/admin-panel/gists/1/delete", nil, 404) s.Login(t, "thomas") - s.Request(t, "POST", "/admin-panel/gists/1/delete", nil, 302) + s.Request(t, "POST", "/-/admin-panel/gists/1/delete", nil, 302) count, err = db.CountAll(db.Gist{}) require.NoError(t, err) @@ -217,7 +218,7 @@ func TestAdminInvitationOperations(t *testing.T) { t.Run("Invitation", func(t *testing.T) { s.Login(t, "thomas") - s.Request(t, "POST", "/admin-panel/invitations", url.Values{ + s.Request(t, "POST", "/-/admin-panel/invitations", url.Values{ "nbMax": {""}, "expiredAtUnix": {""}, }, 302) @@ -228,7 +229,7 @@ func TestAdminInvitationOperations(t *testing.T) { require.Equal(t, uint(10), invitation1.NbMax) require.InDelta(t, time.Now().Unix()+604800, invitation1.ExpiresAt, 10) - s.Request(t, "POST", "/admin-panel/invitations", url.Values{ + s.Request(t, "POST", "/-/admin-panel/invitations", url.Values{ "nbMax": {"aa"}, "expiredAtUnix": {"1735722000"}, }, 302) @@ -242,7 +243,7 @@ func TestAdminInvitationOperations(t *testing.T) { NbMax: 10, }) - s.Request(t, "POST", "/admin-panel/invitations", url.Values{ + s.Request(t, "POST", "/-/admin-panel/invitations", url.Values{ "nbMax": {"20"}, "expiredAtUnix": {"1735722000"}, }, 302) @@ -260,7 +261,7 @@ func TestAdminInvitationOperations(t *testing.T) { require.NoError(t, err) require.Equal(t, int64(3), count) - s.Request(t, "POST", "/admin-panel/invitations/1/delete", nil, 302) + s.Request(t, "POST", "/-/admin-panel/invitations/1/delete", nil, 302) count, err = db.CountAll(db.Invitation{}) require.NoError(t, err) diff --git a/internal/web/handlers/auth/oauth.go b/internal/web/handlers/auth/oauth.go index 5d89c93..b7feffa 100644 --- a/internal/web/handlers/auth/oauth.go +++ b/internal/web/handlers/auth/oauth.go @@ -48,7 +48,7 @@ func Oauth(ctx *context.Context) error { provider, err := oauth.DefineProvider(providerStr, opengistUrl) if err != nil { ctx.AddFlash(ctx.Tr("error.oauth-unsupported"), "error") - return ctx.Redirect(302, "/login") + return ctx.Redirect(302, "/-/login") } if err = provider.RegisterProvider(); err != nil { @@ -63,7 +63,7 @@ func OauthCallback(ctx *context.Context) error { provider, err := oauth.CompleteUserAuth(ctx) if err != nil { ctx.AddFlash(fmt.Sprintf("%s: %s", ctx.Tr("auth.oauth.no-provider"), err.Error()), "error") - return ctx.Redirect(302, "/login") + return ctx.Redirect(302, "/-/login") } currUser := ctx.User @@ -74,7 +74,7 @@ func OauthCallback(ctx *context.Context) error { // check if this OAuth account is already linked to another user if existingUser, err := db.GetUserByProvider(user.UserID, provider.GetProvider()); err == nil && existingUser != nil { ctx.AddFlash(ctx.Tr("flash.auth.oauth-already-linked", config.C.OIDCProviderName), "error") - return ctx.RedirectTo("/settings") + return ctx.RedirectTo("/-/settings") } provider.UpdateUserDB(currUser) @@ -84,7 +84,7 @@ func OauthCallback(ctx *context.Context) error { } ctx.AddFlash(ctx.Tr("flash.auth.account-linked-oauth", config.C.OIDCProviderName), "success") - return ctx.RedirectTo("/settings") + return ctx.RedirectTo("/-/settings") } userDB, err := db.GetUserByProvider(user.UserID, provider.GetProvider()) @@ -92,7 +92,7 @@ func OauthCallback(ctx *context.Context) error { if err != nil { if ctx.GetData("DisableSignup") == true { ctx.AddFlash(ctx.Tr("error.signup-disabled"), "error") - return ctx.Redirect(302, "/login") + return ctx.Redirect(302, "/-/login") } if !errors.Is(err, gorm.ErrRecordNotFound) { @@ -136,7 +136,7 @@ func OauthCallback(ctx *context.Context) error { func OauthRegister(ctx *context.Context) error { if ctx.GetData("DisableSignup") == true { ctx.AddFlash(ctx.Tr("error.signup-disabled"), "error") - return ctx.Redirect(302, "/login") + return ctx.Redirect(302, "/-/login") } sess := ctx.GetSession() @@ -154,7 +154,7 @@ func OauthRegister(ctx *context.Context) error { func ProcessOauthRegister(ctx *context.Context) error { if ctx.GetData("DisableSignup") == true { ctx.AddFlash(ctx.Tr("error.signup-disabled"), "error") - return ctx.Redirect(302, "/login") + return ctx.Redirect(302, "/-/login") } sess := ctx.GetSession() @@ -292,8 +292,8 @@ func OauthUnlink(ctx *context.Context) error { } ctx.AddFlash(ctx.Tr("flash.auth.account-unlinked-oauth", config.C.OIDCProviderName), "success") - return ctx.RedirectTo("/settings") + return ctx.RedirectTo("/-/settings") } - return ctx.RedirectTo("/settings") + return ctx.RedirectTo("/-/settings") } diff --git a/internal/web/handlers/auth/oauth_test.go b/internal/web/handlers/auth/oauth_test.go index 4e1d7fa..ab50ad2 100644 --- a/internal/web/handlers/auth/oauth_test.go +++ b/internal/web/handlers/auth/oauth_test.go @@ -103,6 +103,6 @@ func TestOIDCLoginPKCE(t *testing.T) { }) defer resp.Body.Close() - require.Equal(t, "/login", resp.Request.URL.Path) + require.Equal(t, "/-/login", resp.Request.URL.Path) }) } diff --git a/internal/web/handlers/auth/password.go b/internal/web/handlers/auth/password.go index 6d1b9d2..acaf08b 100644 --- a/internal/web/handlers/auth/password.go +++ b/internal/web/handlers/auth/password.go @@ -131,7 +131,7 @@ func ProcessLogin(ctx *context.Context) error { if errors.As(err, &authErr) { log.Warn().Msg("Invalid HTTP authentication attempt from " + ctx.RealIP()) ctx.AddFlash(ctx.Tr("flash.auth.invalid-credentials"), "error") - return ctx.RedirectTo("/login") + return ctx.RedirectTo("/-/login") } return ctx.ErrorRes(500, "Authentication system error", nil) } @@ -159,5 +159,5 @@ func ProcessLogin(ctx *context.Context) error { func Logout(ctx *context.Context) error { ctx.DeleteSession() ctx.DeleteCsrfCookie() - return ctx.RedirectTo("/all") + return ctx.RedirectTo("/-/all") } diff --git a/internal/web/handlers/auth/password_test.go b/internal/web/handlers/auth/password_test.go index 3d606c8..45ebf07 100644 --- a/internal/web/handlers/auth/password_test.go +++ b/internal/web/handlers/auth/password_test.go @@ -16,7 +16,7 @@ func TestRegisterPage(t *testing.T) { s.Register(t, "thomas") t.Run("Form", func(t *testing.T) { - s.Request(t, "GET", "/register", nil, 200) + s.Request(t, "GET", "/-/register", nil, 200) s.TestCtxData(t, echo.Map{ "isLoginPage": false, "disableForm": false, @@ -26,10 +26,10 @@ func TestRegisterPage(t *testing.T) { t.Run("FormDisabled", func(t *testing.T) { s.Login(t, "thomas") - s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {"disable-signup"}, "value": {"1"}}, 200) + s.Request(t, "PUT", "/-/admin-panel/set-config", url.Values{"key": {"disable-signup"}, "value": {"1"}}, 200) s.Logout() - s.Request(t, "GET", "/register", nil, 200) + s.Request(t, "GET", "/-/register", nil, 200) s.TestCtxData(t, echo.Map{ "disableSignup": true, }) @@ -37,9 +37,9 @@ func TestRegisterPage(t *testing.T) { t.Run("FormDisabledWithInviteCode", func(t *testing.T) { s.Login(t, "thomas") - s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {"disable-signup"}, "value": {"1"}}, 200) + s.Request(t, "PUT", "/-/admin-panel/set-config", url.Values{"key": {"disable-signup"}, "value": {"1"}}, 200) - s.Request(t, "POST", "/admin-panel/invitations", url.Values{ + s.Request(t, "POST", "/-/admin-panel/invitations", url.Values{ "nbMax": {"10"}, "expiredAtUnix": {""}, }, 302) @@ -49,11 +49,11 @@ func TestRegisterPage(t *testing.T) { s.Logout() - s.Request(t, "GET", "/register", nil, 200) + s.Request(t, "GET", "/-/register", nil, 200) s.TestCtxData(t, echo.Map{ "disableSignup": true, }) - s.Request(t, "GET", "/register?code="+invitation.Code, nil, 200) + s.Request(t, "GET", "/-/register?code="+invitation.Code, nil, 200) s.TestCtxData(t, echo.Map{ "disableSignup": false, }) @@ -73,7 +73,7 @@ func TestProcessRegister(t *testing.T) { require.True(t, user.IsAdmin) s.Logout() - s.Request(t, "POST", "/register", db.UserDTO{Username: "seconduser", Password: "password123"}, 302) + s.Request(t, "POST", "/-/register", db.UserDTO{Username: "seconduser", Password: "password123"}, 302) user, err = db.GetUserByUsername("seconduser") require.NoError(t, err) require.False(t, user.IsAdmin) @@ -81,27 +81,32 @@ func TestProcessRegister(t *testing.T) { }) t.Run("DuplicateUsername", func(t *testing.T) { - s.Request(t, "POST", "/register", db.UserDTO{Username: "useraaa", Password: "password123"}, 302) + s.Request(t, "POST", "/-/register", db.UserDTO{Username: "useraaa", Password: "password123"}, 302) s.Logout() - s.Request(t, "POST", "/register", db.UserDTO{Username: "useraaa", Password: "password456"}, 200) + s.Request(t, "POST", "/-/register", db.UserDTO{Username: "useraaa", Password: "password456"}, 200) s.Logout() }) t.Run("InvalidUsername", func(t *testing.T) { - s.Request(t, "POST", "/register", db.UserDTO{Username: "", Password: "password123"}, 200) - s.Request(t, "POST", "/register", db.UserDTO{Username: "aze@", Password: "password123"}, 200) + s.Request(t, "POST", "/-/register", db.UserDTO{Username: "", Password: "password123"}, 200) + s.Request(t, "POST", "/-/register", db.UserDTO{Username: "aze@", Password: "password123"}, 200) + s.Request(t, "POST", "/-/register", db.UserDTO{Username: "-", Password: "password123"}, 200) + + exists, err := db.UserExists("-") + require.NoError(t, err) + require.False(t, exists) }) t.Run("EmptyPassword", func(t *testing.T) { - s.Request(t, "POST", "/register", db.UserDTO{Username: "newuser", Password: ""}, 200) + s.Request(t, "POST", "/-/register", db.UserDTO{Username: "newuser", Password: ""}, 200) }) t.Run("RegisterDisabled", func(t *testing.T) { s.Login(t, "thomas") - s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {"disable-signup"}, "value": {"1"}}, 200) + s.Request(t, "PUT", "/-/admin-panel/set-config", url.Values{"key": {"disable-signup"}, "value": {"1"}}, 200) s.Logout() - s.Request(t, "POST", "/register", db.UserDTO{Username: "blocked", Password: "password123"}, 403) + s.Request(t, "POST", "/-/register", db.UserDTO{Username: "blocked", Password: "password123"}, 403) exists, err := db.UserExists("blocked") require.NoError(t, err) @@ -110,8 +115,8 @@ func TestProcessRegister(t *testing.T) { t.Run("RegisterWithInvitationCode", func(t *testing.T) { s.Login(t, "thomas") - s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {"disable-signup"}, "value": {"1"}}, 200) - s.Request(t, "POST", "/admin-panel/invitations", url.Values{ + s.Request(t, "PUT", "/-/admin-panel/set-config", url.Values{"key": {"disable-signup"}, "value": {"1"}}, 200) + s.Request(t, "POST", "/-/admin-panel/invitations", url.Values{ "nbMax": {"10"}, "expiredAtUnix": {""}, }, 302) @@ -124,7 +129,7 @@ func TestProcessRegister(t *testing.T) { s.Logout() - s.Request(t, "POST", "/register?code="+invitation.Code, db.UserDTO{Username: "inviteduser", Password: "password123"}, 302) + s.Request(t, "POST", "/-/register?code="+invitation.Code, db.UserDTO{Username: "inviteduser", Password: "password123"}, 302) user, err := db.GetUserByUsername("inviteduser") require.NoError(t, err) @@ -142,7 +147,7 @@ func TestLoginPage(t *testing.T) { s.Register(t, "thomas") t.Run("Form", func(t *testing.T) { - s.Request(t, "GET", "/login", nil, 200) + s.Request(t, "GET", "/-/login", nil, 200) s.TestCtxData(t, echo.Map{ "isLoginPage": true, "disableForm": false, @@ -151,10 +156,10 @@ func TestLoginPage(t *testing.T) { t.Run("FormDisabled", func(t *testing.T) { s.Login(t, "thomas") - s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {"disable-login-form"}, "value": {"1"}}, 200) + s.Request(t, "PUT", "/-/admin-panel/set-config", url.Values{"key": {"disable-login-form"}, "value": {"1"}}, 200) s.Logout() - s.Request(t, "GET", "/login", nil, 200) + s.Request(t, "GET", "/-/login", nil, 200) s.TestCtxData(t, echo.Map{ "disableForm": true, }) @@ -168,7 +173,7 @@ func TestProcessLogin(t *testing.T) { s.Register(t, "thomas") t.Run("ValidCredentials", func(t *testing.T) { - resp := s.Request(t, "POST", "/login", db.UserDTO{Username: "thomas", Password: "thomas"}, 302) + resp := s.Request(t, "POST", "/-/login", db.UserDTO{Username: "thomas", Password: "thomas"}, 302) require.Equal(t, "/", resp.Header.Get("Location")) require.NotEmpty(t, s.SessionCookie) require.Equal(t, "thomas", s.User().Username) @@ -177,28 +182,28 @@ func TestProcessLogin(t *testing.T) { }) t.Run("InvalidPassword", func(t *testing.T) { - resp := s.Request(t, "POST", "/login", db.UserDTO{Username: "thomas", Password: "wrongpassword"}, 302) - require.Equal(t, "/login", resp.Header.Get("Location")) + resp := s.Request(t, "POST", "/-/login", db.UserDTO{Username: "thomas", Password: "wrongpassword"}, 302) + require.Equal(t, "/-/login", resp.Header.Get("Location")) require.Nil(t, s.User()) }) t.Run("NonExistentUser", func(t *testing.T) { - resp := s.Request(t, "POST", "/login", db.UserDTO{Username: "nonexistent", Password: "password"}, 302) - require.Equal(t, "/login", resp.Header.Get("Location")) + resp := s.Request(t, "POST", "/-/login", db.UserDTO{Username: "nonexistent", Password: "password"}, 302) + require.Equal(t, "/-/login", resp.Header.Get("Location")) require.Nil(t, s.User()) }) t.Run("EmptyCredentials", func(t *testing.T) { - s.Request(t, "POST", "/login", db.UserDTO{Username: "", Password: ""}, 302) + s.Request(t, "POST", "/-/login", db.UserDTO{Username: "", Password: ""}, 302) require.Nil(t, s.User()) }) t.Run("LoginFormDisabled", func(t *testing.T) { s.Login(t, "thomas") - s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {"disable-login-form"}, "value": {"1"}}, 200) + s.Request(t, "PUT", "/-/admin-panel/set-config", url.Values{"key": {"disable-login-form"}, "value": {"1"}}, 200) s.Logout() - s.Request(t, "POST", "/login", db.UserDTO{Username: "thomas", Password: "thomas"}, 403) + s.Request(t, "POST", "/-/login", db.UserDTO{Username: "thomas", Password: "thomas"}, 403) require.Nil(t, s.User()) }) } @@ -213,8 +218,8 @@ func TestLogout(t *testing.T) { s.Login(t, "thomas") require.Equal(t, "thomas", s.User().Username) - resp := s.Request(t, "GET", "/logout", nil, 302) - require.Equal(t, "/all", resp.Header.Get("Location")) + resp := s.Request(t, "GET", "/-/logout", nil, 302) + require.Equal(t, "/-/all", resp.Header.Get("Location")) require.Nil(t, s.User()) s.Request(t, "GET", "/", nil, 302) }) diff --git a/internal/web/handlers/auth/totp.go b/internal/web/handlers/auth/totp.go index 2b38235..5850d1c 100644 --- a/internal/web/handlers/auth/totp.go +++ b/internal/web/handlers/auth/totp.go @@ -15,7 +15,7 @@ func BeginTotp(ctx *context.Context) error { return ctx.ErrorRes(500, "Cannot check for user MFA", err) } else if hasTotp { ctx.AddFlash(ctx.Tr("auth.totp.already-enabled"), "error") - return ctx.RedirectTo("/settings/mfa") + return ctx.RedirectTo("/-/settings/authentication") } ogUrl, err := url.Parse(ctx.GetData("baseHttpUrl").(string)) @@ -36,6 +36,7 @@ func BeginTotp(ctx *context.Context) error { ctx.SetData("totpSecret", totpSecret) ctx.SetData("totpQrcode", qrcode) + ctx.SetData("settingsHeaderPage", "authentication") return ctx.Html("totp.html") @@ -48,7 +49,7 @@ func FinishTotp(ctx *context.Context) error { return ctx.ErrorRes(500, "Cannot check for user MFA", err) } else if hasTotp { ctx.AddFlash(ctx.Tr("auth.totp.already-enabled"), "error") - return ctx.RedirectTo("/settings/mfa") + return ctx.RedirectTo("/-/settings/authentication") } dto := &db.TOTPDTO{} @@ -58,7 +59,7 @@ func FinishTotp(ctx *context.Context) error { if err := ctx.Validate(dto); err != nil { ctx.AddFlash("Invalid secret", "error") - return ctx.RedirectTo("/settings/totp/generate") + return ctx.RedirectTo("/-/settings/totp/generate") } sess := ctx.GetSession() @@ -70,7 +71,7 @@ func FinishTotp(ctx *context.Context) error { if !totp.Validate(dto.Code, secret) { ctx.AddFlash(ctx.Tr("auth.totp.invalid-code"), "error") - return ctx.RedirectTo("/settings/totp/generate") + return ctx.RedirectTo("/-/settings/totp/generate") } userTotp := &db.TOTP{ @@ -95,6 +96,7 @@ func FinishTotp(ctx *context.Context) error { ctx.SaveSession(sess) ctx.SetData("recoveryCodes", codes) + ctx.SetData("settingsHeaderPage", "authentication") return ctx.Html("totp.html") } @@ -135,7 +137,7 @@ func AssertTotp(ctx *context.Context) error { } ctx.AddFlash(ctx.Tr("auth.totp.code-used", dto.Code), "warning") - redirectUrl = "/settings/mfa" + redirectUrl = "/-/settings/authentication" } sess.Values["user"] = userId @@ -158,7 +160,7 @@ func DisableTotp(ctx *context.Context) error { } ctx.AddFlash(ctx.Tr("auth.totp.disabled"), "success") - return ctx.RedirectTo("/settings/mfa") + return ctx.RedirectTo("/-/settings/authentication") } func RegenerateTotpRecoveryCodes(ctx *context.Context) error { @@ -174,5 +176,6 @@ func RegenerateTotpRecoveryCodes(ctx *context.Context) error { } ctx.SetData("recoveryCodes", codes) + ctx.SetData("settingsHeaderPage", "authentication") return ctx.Html("totp.html") } diff --git a/internal/web/handlers/gist/all.go b/internal/web/handlers/gist/all.go index 88bca4e..956f1c8 100644 --- a/internal/web/handlers/gist/all.go +++ b/internal/web/handlers/gist/all.go @@ -21,31 +21,41 @@ func AllGists(ctx *context.Context) error { fromUserStr := ctx.Param("user") userLogged := ctx.User pageInt := handlers.GetPage(ctx) + mode := ctx.GetData("mode") sort := "created" order := "desc" - if userLogged != nil { - if style := userLogged.GetStyle(); style != nil { - if style.DefaultSort == "updated" { - sort = "updated" - } - if style.DefaultOrder == "asc" { - order = "asc" + // Some feeds (e.g. "recently liked" / "recently forked") have a fixed order: + // creation time descending, ignoring the user's default-sort style and the + // sort/order query params. The template hides the sort dropdown when sortable + // is false. + sortable := mode != "all-liked" && mode != "all-forked" + ctx.SetData("sortable", sortable) + + if sortable { + if userLogged != nil { + if style := userLogged.GetStyle(); style != nil { + if style.DefaultSort == "updated" { + sort = "updated" + } + if style.DefaultOrder == "asc" { + order = "asc" + } } } - } - if ctx.QueryParam("sort") == "updated" { - sort = "updated" - } else if ctx.QueryParam("sort") == "created" { - sort = "created" - } + if ctx.QueryParam("sort") == "updated" { + sort = "updated" + } else if ctx.QueryParam("sort") == "created" { + sort = "created" + } - if ctx.QueryParam("order") == "asc" { - order = "asc" - } else if ctx.QueryParam("order") == "desc" { - order = "desc" + if ctx.QueryParam("order") == "asc" { + order = "asc" + } else if ctx.QueryParam("order") == "desc" { + order = "desc" + } } sortText := ctx.TrH("gist.list.sort-by-" + sort) @@ -67,24 +77,50 @@ func AllGists(ctx *context.Context) error { currentUserId = 0 } - mode := ctx.GetData("mode") if fromUserStr == "" { switch mode { case "search": ctx.SetData("htmlTitle", ctx.TrH("gist.list.search-results")) ctx.SetData("searchQuery", ctx.QueryParam("q")) pagination.Query = ctx.QueryParam("q") - urlPage = "search" + urlPage = "-/search" gists, err = db.GetAllGistsFromSearch(currentUserId, ctx.QueryParam("q"), pageInt-1, sort, order, "") case "topics": ctx.SetData("htmlTitle", ctx.TrH("gist.list.topic-results-topic", ctx.Param("topic"))) ctx.SetData("topic", ctx.Param("topic")) - urlPage = "topics/" + ctx.Param("topic") - gists, err = db.GetAllGistsFromSearch(currentUserId, "", pageInt-1, sort, order, ctx.Param("topic")) + urlPage = "-/topics/" + ctx.Param("topic") + + if languages, err := db.GetGistLanguagesByTopic(currentUserId, ctx.Param("topic")); err != nil { + return ctx.ErrorRes(500, "Error fetching languages", err) + } else { + ctx.SetData("languages", languages) + } + + title, language, visibility, topics := readGistFilters(ctx, pagination) + gists, _, err = db.GetAllGistsByTopicFiltered(currentUserId, ctx.Param("topic"), title, language, visibility, topics, pageInt-1, sort, order) case "all": + ctx.SetData("currentPage", "all") ctx.SetData("htmlTitle", ctx.TrH("gist.list.all")) - urlPage = "all" - gists, err = db.GetAllGistsForCurrentUser(currentUserId, nil, pageInt-1, sort, order, 11, 10) + urlPage = "-/all" + + if languages, err := db.GetGistLanguages(currentUserId); err != nil { + return ctx.ErrorRes(500, "Error fetching languages", err) + } else { + ctx.SetData("languages", languages) + } + + title, language, visibility, topics := readGistFilters(ctx, pagination) + gists, _, err = db.GetAllGistsForCurrentUserFiltered(currentUserId, title, language, visibility, topics, pageInt-1, sort, order) + case "all-liked": + ctx.SetData("currentPage", "recently-liked") + ctx.SetData("htmlTitle", ctx.TrH("gist.list.recently-liked")) + urlPage = "-/liked" + gists, err = db.GetAllGistsLiked(currentUserId, nil, pageInt-1, sort, order, 11, 10) + case "all-forked": + ctx.SetData("currentPage", "recently-forked") + ctx.SetData("htmlTitle", ctx.TrH("gist.list.recently-forked")) + urlPage = "-/forked" + gists, err = db.GetAllGistsForked(currentUserId, nil, pageInt-1, sort, order, 11, 10) } } else { var fromUser *db.User @@ -99,6 +135,12 @@ func AllGists(ctx *context.Context) error { } ctx.SetData("fromUser", fromUser) + // Highlight "My gists" on any of the logged-in user's own tabs + // (gists, liked, forked). + if userLogged != nil && fromUserStr == userLogged.Username { + ctx.SetData("currentPage", "mine") + } + if countFromUser, err := db.CountAllGistsFromUser(fromUser.ID, currentUserId); err != nil { return ctx.ErrorRes(500, "Error counting gists", err) } else { @@ -134,25 +176,7 @@ func AllGists(ctx *context.Context) error { } else { ctx.SetData("languages", languages) } - title := ctx.QueryParam("title") - language := ctx.QueryParam("language") - visibility := ctx.QueryParam("visibility") - topicsStr := ctx.QueryParam("topics") - topics := strings.Fields(topicsStr) - if len(topics) > 10 { - topics = topics[:10] - } - slices.Sort(topics) - topics = slices.Compact(topics) - pagination.Title = title - pagination.Language = language - pagination.Visibility = visibility - pagination.Topics = topicsStr - - ctx.SetData("title", title) - ctx.SetData("language", language) - ctx.SetData("visibility", visibility) - ctx.SetData("topics", topicsStr) + title, language, visibility, topics := readGistFilters(ctx, pagination) ctx.SetData("htmlTitle", ctx.TrH("gist.list.all-from", fromUserStr)) gists, count, err = db.GetAllGistsFromUser(fromUser.ID, currentUserId, title, language, visibility, topics, pageInt-1, sort, order) ctx.SetData("countFromUser", count) @@ -178,6 +202,34 @@ func AllGists(ctx *context.Context) error { return ctx.Html("all.html") } +// readGistFilters pulls the faceted filter query params (title, language, +// visibility, topics) shared by the explore "all"/"topics" feeds and the +// per-user gist list, mirrors them into pagination and template data, and +// returns the parsed values. +func readGistFilters(ctx *context.Context, pagination *handlers.PaginationParams) (title, language, visibility string, topics []string) { + title = ctx.QueryParam("title") + language = ctx.QueryParam("language") + visibility = ctx.QueryParam("visibility") + topicsStr := ctx.QueryParam("topics") + topics = strings.Fields(topicsStr) + if len(topics) > 10 { + topics = topics[:10] + } + slices.Sort(topics) + topics = slices.Compact(topics) + + pagination.Title = title + pagination.Language = language + pagination.Visibility = visibility + pagination.Topics = topicsStr + + ctx.SetData("title", title) + ctx.SetData("language", language) + ctx.SetData("visibility", visibility) + ctx.SetData("topics", topicsStr) + return +} + // Search handles the search page for gists. // // It takes a query parameter "q" which is a search query in the format: diff --git a/internal/web/handlers/gist/create.go b/internal/web/handlers/gist/create.go index 108ddbd..c31c19a 100644 --- a/internal/web/handlers/gist/create.go +++ b/internal/web/handlers/gist/create.go @@ -17,6 +17,8 @@ import ( ) func Create(ctx *context.Context) error { + ctx.SetData("currentPage", "new") + ctx.SetData("dto", new(db.GistDTO)) ctx.SetData("htmlTitle", ctx.TrH("gist.new.create-a-new-gist")) return ctx.Html("create.html") } @@ -139,7 +141,9 @@ func ProcessCreate(ctx *context.Context) error { if isCreate { gist = dto.ToGist() gist.ExpiresAt = dto.ExpiresAtTimestamp() - } else { + } else if ctx.FormValue("_edit_metadata") == "1" { + // The legacy editor still edits files and metadata in one form. The new + // editor omits this marker because metadata has its own settings page. gist = dto.ToExistingGist(gist) } diff --git a/internal/web/handlers/gist/edit_test.go b/internal/web/handlers/gist/edit_test.go index b139644..064962f 100644 --- a/internal/web/handlers/gist/edit_test.go +++ b/internal/web/handlers/gist/edit_test.go @@ -139,3 +139,41 @@ func TestArchive(t *testing.T) { }, 302) }) } + +func TestLegacyEditorUpdatesMetadata(t *testing.T) { + s := webtest.Setup(t) + defer webtest.Teardown(t) + + s.Register(t, "thomas") + _, original, username, identifier := s.CreateGist(t, "0") + + s.Login(t, "thomas") + s.Request(t, "POST", "/"+username+"/"+identifier+"/edit", url.Values{ + "_edit_metadata": {"1"}, + "title": {"Updated from legacy UI"}, + "description": {"Updated description"}, + "url": {"legacy-url"}, + "topics": {"legacy compatibility"}, + "name": {"file.txt"}, + "content": {"updated content"}, + }, 302) + + updated, err := db.GetGist(username, "legacy-url") + require.NoError(t, err) + require.Equal(t, original.ID, updated.ID) + require.Equal(t, "Updated from legacy UI", updated.Title) + require.Equal(t, "Updated description", updated.Description) + require.ElementsMatch(t, []string{"legacy", "compatibility"}, updated.TopicsSlice()) + + // A file-only edit from the new UI must keep metadata unchanged. + s.Request(t, "POST", "/"+username+"/legacy-url/edit", url.Values{ + "name": {"file.txt"}, + "content": {"new UI content"}, + }, 302) + + updated, err = db.GetGist(username, "legacy-url") + require.NoError(t, err) + require.Equal(t, "Updated from legacy UI", updated.Title) + require.Equal(t, "Updated description", updated.Description) + require.ElementsMatch(t, []string{"legacy", "compatibility"}, updated.TopicsSlice()) +} diff --git a/internal/web/handlers/gist/fork.go b/internal/web/handlers/gist/fork.go index a090d49..e27050b 100644 --- a/internal/web/handlers/gist/fork.go +++ b/internal/web/handlers/gist/fork.go @@ -82,6 +82,7 @@ func Forks(ctx *context.Context) error { return ctx.ErrorRes(404, ctx.Tr("error.page-not-found"), nil) } + ctx.SetData("page", "forks") ctx.SetData("htmlTitle", ctx.TrH("gist.forks.for", gist.Title)) ctx.SetData("revision", "HEAD") return ctx.Html("forks.html") diff --git a/internal/web/handlers/gist/gist.go b/internal/web/handlers/gist/gist.go index 7d6b276..acc6b04 100644 --- a/internal/web/handlers/gist/gist.go +++ b/internal/web/handlers/gist/gist.go @@ -144,6 +144,13 @@ func GistJs(ctx *context.Context) error { autoMode = false } + // In auto mode the card design follows the OS via a prefers-color-scheme + // media query (see .theme-auto in embed.css), so it no longer depends on the + // JS class-toggle alone. + if autoMode { + ctx.SetData("themeAuto", true) + } + gist := ctx.GetData("gist").(*db.Gist) var files []*git.File @@ -259,7 +266,7 @@ func escapeJavaScriptContent(htmlContent, cssUrl, themeUrl string, autoMode bool
${content}
%s; @@ -313,7 +320,10 @@ func setGistCSP(ctx *context.Context) { "default-src 'self'; "+ "script-src 'self' 'nonce-"+nonce+"'; "+ "style-src 'self' 'unsafe-inline'; "+ - "img-src 'self' data:; "+ + // https: (not just 'self') so remote avatars — Gravatar, GitHub/GitLab/ + // Gitea/OIDC OAuth avatars — and images embedded in rendered gist + // markdown are allowed. + "img-src 'self' data: https:; "+ "font-src 'self' data:; "+ "connect-src 'self'; "+ // 'self' (not 'none') so same-origin PDF previews keep working via diff --git a/internal/web/handlers/gist/gist_test.go b/internal/web/handlers/gist/gist_test.go index d6d6d91..66e6969 100644 --- a/internal/web/handlers/gist/gist_test.go +++ b/internal/web/handlers/gist/gist_test.go @@ -293,7 +293,7 @@ func TestGistAccess(t *testing.T) { t.Run(tt.name, func(t *testing.T) { s.Login(t, "thomas") for k, v := range tt.settings { - s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {k}, "value": {v}}, 200) + s.Request(t, "PUT", "/-/admin-panel/set-config", url.Values{"key": {k}, "value": {v}}, 200) } t.Run("Owner", func(t *testing.T) { @@ -321,7 +321,7 @@ func TestGistAccess(t *testing.T) { s.Login(t, "thomas") for k := range tt.settings { - s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {k}, "value": {"0"}}, 200) + s.Request(t, "PUT", "/-/admin-panel/set-config", url.Values{"key": {k}, "value": {"0"}}, 200) } }) } diff --git a/internal/web/handlers/gist/like.go b/internal/web/handlers/gist/like.go index f6fc424..26c14bd 100644 --- a/internal/web/handlers/gist/like.go +++ b/internal/web/handlers/gist/like.go @@ -46,6 +46,7 @@ func Likes(ctx *context.Context) error { return ctx.ErrorRes(404, ctx.Tr("error.page-not-found"), nil) } + ctx.SetData("page", "likes") ctx.SetData("htmlTitle", ctx.TrH("gist.likes.for", gist.Title)) ctx.SetData("revision", "HEAD") return ctx.Html("likes.html") diff --git a/internal/web/handlers/gist/settings.go b/internal/web/handlers/gist/settings.go new file mode 100644 index 0000000..8e73bdf --- /dev/null +++ b/internal/web/handlers/gist/settings.go @@ -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()) +} diff --git a/internal/web/handlers/gist/topics.go b/internal/web/handlers/gist/topics.go new file mode 100644 index 0000000..62e8c6c --- /dev/null +++ b/internal/web/handlers/gist/topics.go @@ -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") +} diff --git a/internal/web/handlers/gist/users.go b/internal/web/handlers/gist/users.go new file mode 100644 index 0000000..e56a255 --- /dev/null +++ b/internal/web/handlers/gist/users.go @@ -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") +} diff --git a/internal/web/handlers/git/http_test.go b/internal/web/handlers/git/http_test.go index 73df946..88bf58a 100644 --- a/internal/web/handlers/git/http_test.go +++ b/internal/web/handlers/git/http_test.go @@ -100,7 +100,7 @@ func TestGitClonePull(t *testing.T) { t.Run(tt.name, func(t *testing.T) { s.Login(t, "thomas") for k, v := range tt.settings { - s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {k}, "value": {v}}, 200) + s.Request(t, "PUT", "/-/admin-panel/set-config", url.Values{"key": {k}, "value": {v}}, 200) } for _, ct := range tt.creds { @@ -124,7 +124,7 @@ func TestGitClonePull(t *testing.T) { // Reset settings s.Login(t, "thomas") for k := range tt.settings { - s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {k}, "value": {"0"}}, 200) + s.Request(t, "PUT", "/-/admin-panel/set-config", url.Values{"key": {k}, "value": {"0"}}, 200) } }) } diff --git a/internal/web/handlers/metrics/metrics_test.go b/internal/web/handlers/metrics/metrics_test.go index 4d452fd..ecb10f5 100644 --- a/internal/web/handlers/metrics/metrics_test.go +++ b/internal/web/handlers/metrics/metrics_test.go @@ -31,7 +31,7 @@ func TestMetrics(t *testing.T) { Topics: "", }, 302) - s.Request(t, "POST", "/settings/ssh-keys", db.SSHKeyDTO{ + s.Request(t, "POST", "/-/settings/ssh-keys", db.SSHKeyDTO{ Title: "Test SSH Key", Content: `ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAklOUpkDHrfHY17SbrmTIpNLTGK9Tjom/BWDSUGPl+nafzlHDTYW7hdI4yZ5ew18JH4JW9jbhUFrviQzM7xlELEVf4h9lFX5QVkbPppSwg0cda3Pbv7kOdJ/MTyBlWXFCR+HAo3FXRitBqxiX1nKhXpHAZsMciLq8V6RjsNAQwdsdMFvSlVK/7XAt3FaoJoAsncM1Q9x5+3V0Ww68/eIFmb1zuUFljQJKprrX88XypNDvjYNby6vw/Pb0rwert/EnmZ+AW4OZPnTPI89ZPmVMLuayrD2cE86Z/il8b+gw3r3+1nKatmIkjn2so1d01QraTlMqVSsbxNrRFi9wrf+M7Q== admin@admin.local`, }, 302) diff --git a/internal/web/handlers/settings/access_token.go b/internal/web/handlers/settings/access_token.go index a912546..2d69f55 100644 --- a/internal/web/handlers/settings/access_token.go +++ b/internal/web/handlers/settings/access_token.go @@ -36,7 +36,7 @@ func AccessTokensProcess(ctx *context.Context) error { if err := ctx.Validate(dto); err != nil { ctx.AddFlash(validator.ValidationMessages(&err, ctx.GetData("locale").(*i18n.Locale)), "error") - return ctx.RedirectTo("/settings/access-tokens") + return ctx.RedirectTo("/-/settings/access-tokens") } token := dto.ToAccessToken() @@ -54,19 +54,19 @@ func AccessTokensProcess(ctx *context.Context) error { // Show the token once to the user ctx.AddFlash(ctx.Tr("settings.token-created"), "success") ctx.AddFlash(plainToken, "success") - return ctx.RedirectTo("/settings/access-tokens") + return ctx.RedirectTo("/-/settings/access-tokens") } func AccessTokensDelete(ctx *context.Context) error { user := ctx.User tokenID, err := strconv.Atoi(ctx.Param("id")) if err != nil { - return ctx.RedirectTo("/settings/access-tokens") + return ctx.RedirectTo("/-/settings/access-tokens") } token, err := db.GetAccessTokenByID(uint(tokenID)) if err != nil || token.UserID != user.ID { - return ctx.RedirectTo("/settings/access-tokens") + return ctx.RedirectTo("/-/settings/access-tokens") } if err := token.Delete(); err != nil { @@ -74,5 +74,5 @@ func AccessTokensDelete(ctx *context.Context) error { } ctx.AddFlash(ctx.Tr("settings.token-deleted"), "success") - return ctx.RedirectTo("/settings/access-tokens") + return ctx.RedirectTo("/-/settings/access-tokens") } diff --git a/internal/web/handlers/settings/access_token_test.go b/internal/web/handlers/settings/access_token_test.go index ba93947..62e7432 100644 --- a/internal/web/handlers/settings/access_token_test.go +++ b/internal/web/handlers/settings/access_token_test.go @@ -18,17 +18,17 @@ func TestAccessTokensCRUD(t *testing.T) { t.Run("RequiresAuth", func(t *testing.T) { s.Logout() - s.Request(t, "GET", "/settings/access-tokens", nil, 302) + s.Request(t, "GET", "/-/settings/access-tokens", nil, 302) }) t.Run("AccessTokensPage", func(t *testing.T) { s.Login(t, "thomas") - s.Request(t, "GET", "/settings/access-tokens", nil, 200) + s.Request(t, "GET", "/-/settings/access-tokens", nil, 200) }) t.Run("CreateReadToken", func(t *testing.T) { s.Login(t, "thomas") - s.Request(t, "POST", "/settings/access-tokens", db.AccessTokenDTO{ + s.Request(t, "POST", "/-/settings/access-tokens", db.AccessTokenDTO{ Name: "test-token", ScopeGist: db.ReadPermission, }, 302) @@ -44,7 +44,7 @@ func TestAccessTokensCRUD(t *testing.T) { t.Run("CreateExpiringToken", func(t *testing.T) { s.Login(t, "thomas") tomorrow := time.Now().AddDate(0, 0, 1).Format("2006-01-02") - s.Request(t, "POST", "/settings/access-tokens", db.AccessTokenDTO{ + s.Request(t, "POST", "/-/settings/access-tokens", db.AccessTokenDTO{ Name: "expiring-token", ScopeGist: db.ReadWritePermission, ExpiresAt: tomorrow, @@ -57,7 +57,7 @@ func TestAccessTokensCRUD(t *testing.T) { t.Run("DeleteToken", func(t *testing.T) { s.Login(t, "thomas") - s.Request(t, "DELETE", "/settings/access-tokens/1", nil, 302) + s.Request(t, "DELETE", "/-/settings/access-tokens/1", nil, 302) tokens, err := db.GetAccessTokensByUserID(1) require.NoError(t, err) @@ -279,7 +279,7 @@ func TestCreateTokenWithUserScope(t *testing.T) { s.Register(t, "thomas") s.Login(t, "thomas") - s.Request(t, "POST", "/settings/access-tokens", db.AccessTokenDTO{ + s.Request(t, "POST", "/-/settings/access-tokens", db.AccessTokenDTO{ Name: "with-user", ScopeGist: db.ReadPermission, ScopeUser: db.ReadPermission, @@ -311,7 +311,7 @@ func TestAccessTokenWithRequireLogin(t *testing.T) { require.NoError(t, err) require.NoError(t, token.Create()) - s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {db.SettingRequireLogin}, "value": {"1"}}, 200) + s.Request(t, "PUT", "/-/admin-panel/set-config", url.Values{"key": {db.SettingRequireLogin}, "value": {"1"}}, 200) s.Logout() headers := map[string]string{"Authorization": "Token " + plainToken} diff --git a/internal/web/handlers/settings/account.go b/internal/web/handlers/settings/account.go index 148db3f..5074461 100644 --- a/internal/web/handlers/settings/account.go +++ b/internal/web/handlers/settings/account.go @@ -37,7 +37,7 @@ func EmailProcess(ctx *context.Context) error { } ctx.AddFlash(ctx.Tr("flash.user.email-updated"), "success") - return ctx.RedirectTo("/settings") + return ctx.RedirectTo("/-/settings") } func AccountDeleteProcess(ctx *context.Context) error { @@ -48,7 +48,7 @@ func AccountDeleteProcess(ctx *context.Context) error { } opengistssh.SyncAuthorizedKeysLogged() - return ctx.RedirectTo("/all") + return ctx.RedirectTo("/-/all") } func UsernameProcess(ctx *context.Context) error { @@ -61,13 +61,13 @@ func UsernameProcess(ctx *context.Context) error { if err := ctx.Validate(dto); err != nil { ctx.AddFlash(validator.ValidationMessages(&err, ctx.GetData("locale").(*i18n.Locale)), "error") - return ctx.RedirectTo("/settings") + return ctx.RedirectTo("/-/settings") } if !strings.EqualFold(dto.Username, user.Username) { if exists, err := db.UserExists(dto.Username); err != nil || exists { ctx.AddFlash(ctx.Tr("flash.auth.username-exists"), "error") - return ctx.RedirectTo("/settings") + return ctx.RedirectTo("/-/settings") } } @@ -90,5 +90,5 @@ func UsernameProcess(ctx *context.Context) error { } ctx.AddFlash(ctx.Tr("flash.user.username-updated"), "success") - return ctx.RedirectTo("/settings") + return ctx.RedirectTo("/-/settings") } diff --git a/internal/web/handlers/settings/auth.go b/internal/web/handlers/settings/auth.go index 06afeb1..b585da4 100644 --- a/internal/web/handlers/settings/auth.go +++ b/internal/web/handlers/settings/auth.go @@ -13,12 +13,12 @@ func PasskeyDelete(ctx *context.Context) error { user := ctx.User keyId, err := strconv.Atoi(ctx.Param("id")) if err != nil { - return ctx.RedirectTo("/settings") + return ctx.RedirectTo("/-/settings") } passkey, err := db.GetCredentialByIDDB(uint(keyId)) if err != nil || passkey.UserID != user.ID { - return ctx.RedirectTo("/settings") + return ctx.RedirectTo("/-/settings") } if err := passkey.Delete(); err != nil { @@ -26,7 +26,7 @@ func PasskeyDelete(ctx *context.Context) error { } ctx.AddFlash(ctx.Tr("flash.auth.passkey-deleted"), "success") - return ctx.RedirectTo("/settings") + return ctx.RedirectTo("/-/settings") } func PasswordProcess(ctx *context.Context) error { @@ -40,7 +40,10 @@ func PasswordProcess(ctx *context.Context) error { if err := ctx.Validate(dto); err != nil { ctx.AddFlash(validator.ValidationMessages(&err, ctx.GetData("locale").(*i18n.Locale)), "error") - return ctx.Html("settings.html") + if ctx.FormValue("_legacy_account") == "1" { + return ctx.RedirectTo("/-/settings") + } + return ctx.RedirectTo("/-/settings/authentication") } password, err := passwordpkg.HashPassword(dto.Password) @@ -54,5 +57,5 @@ func PasswordProcess(ctx *context.Context) error { } ctx.AddFlash(ctx.Tr("flash.user.password-updated"), "success") - return ctx.RedirectTo("/settings") + return ctx.RedirectTo("/-/settings") } diff --git a/internal/web/handlers/settings/avatar.go b/internal/web/handlers/settings/avatar.go index 60f4563..de33790 100644 --- a/internal/web/handlers/settings/avatar.go +++ b/internal/web/handlers/settings/avatar.go @@ -32,12 +32,12 @@ func AvatarProcess(ctx *context.Context) error { header, err := ctx.FormFile("avatar") if err != nil { ctx.AddFlash(ctx.Tr("flash.user.avatar-invalid"), "error") - return ctx.RedirectTo("/settings") + return ctx.RedirectTo("/-/settings") } if header.Size > maxAvatarSize { ctx.AddFlash(ctx.Tr("flash.user.avatar-too-large"), "error") - return ctx.RedirectTo("/settings") + return ctx.RedirectTo("/-/settings") } src, err := header.Open() @@ -54,7 +54,7 @@ func AvatarProcess(ctx *context.Context) error { ext, ok := allowedAvatarTypes[contentType] if !ok { ctx.AddFlash(ctx.Tr("flash.user.avatar-invalid"), "error") - return ctx.RedirectTo("/settings") + return ctx.RedirectTo("/-/settings") } if _, err = src.Seek(0, io.SeekStart); err != nil { @@ -85,14 +85,14 @@ func AvatarProcess(ctx *context.Context) error { } ctx.AddFlash(ctx.Tr("flash.user.avatar-updated"), "success") - return ctx.RedirectTo("/settings") + return ctx.RedirectTo("/-/settings") } func AvatarDelete(ctx *context.Context) error { user := ctx.User if !user.HasUploadedAvatar() { - return ctx.RedirectTo("/settings") + return ctx.RedirectTo("/-/settings") } removeAvatarFile(user) @@ -103,7 +103,7 @@ func AvatarDelete(ctx *context.Context) error { } ctx.AddFlash(ctx.Tr("flash.user.avatar-deleted"), "success") - return ctx.RedirectTo("/settings") + return ctx.RedirectTo("/-/settings") } func removeAvatarFile(user *db.User) { diff --git a/internal/web/handlers/settings/settings.go b/internal/web/handlers/settings/settings.go index 918e481..43e6682 100644 --- a/internal/web/handlers/settings/settings.go +++ b/internal/web/handlers/settings/settings.go @@ -2,6 +2,7 @@ package settings import ( "github.com/thomiceli/opengist/internal/db" + "github.com/thomiceli/opengist/internal/validator" "github.com/thomiceli/opengist/internal/web/context" ) @@ -9,6 +10,9 @@ func UserAccount(ctx *context.Context) error { user := ctx.User ctx.SetData("email", user.Email) + // The legacy account page still contains the password form. The new UI + // moved it to Authentication, but keeping this data preserves old-UI + // behavior while both interfaces are available. ctx.SetData("hasPassword", user.Password != "") ctx.SetData("disableForm", ctx.GetData("DisableLoginForm")) ctx.SetData("settingsHeaderPage", "account") @@ -16,7 +20,7 @@ func UserAccount(ctx *context.Context) error { return ctx.Html("settings_account.html") } -func UserMFA(ctx *context.Context) error { +func UserAuthentication(ctx *context.Context) error { user := ctx.User passkeys, err := db.GetAllCredentialsForUser(user.ID) @@ -31,9 +35,11 @@ func UserMFA(ctx *context.Context) error { ctx.SetData("passkeys", passkeys) ctx.SetData("hasTotp", hasTotp) - ctx.SetData("settingsHeaderPage", "mfa") + ctx.SetData("hasPassword", user.Password != "") + ctx.SetData("disableForm", ctx.GetData("DisableLoginForm")) + ctx.SetData("settingsHeaderPage", "authentication") ctx.SetData("htmlTitle", ctx.TrH("settings")) - return ctx.Html("settings_mfa.html") + return ctx.Html("settings_authentication.html") } func UserSSHKeys(ctx *context.Context) error { @@ -52,6 +58,7 @@ func UserSSHKeys(ctx *context.Context) error { func UserStyle(ctx *context.Context) error { ctx.SetData("settingsHeaderPage", "style") + ctx.SetData("themeColors", validator.ThemeColors) ctx.SetData("htmlTitle", ctx.TrH("settings")) return ctx.Html("settings_style.html") } @@ -72,5 +79,5 @@ func ProcessUserStyle(ctx *context.Context) error { } ctx.AddFlash("Updated style", "success") - return ctx.RedirectTo("/settings/style") + return ctx.RedirectTo("/-/settings/style") } diff --git a/internal/web/handlers/settings/sshkey.go b/internal/web/handlers/settings/sshkey.go index 5fd22df..c0e2dfa 100644 --- a/internal/web/handlers/settings/sshkey.go +++ b/internal/web/handlers/settings/sshkey.go @@ -21,7 +21,7 @@ func SshKeysProcess(ctx *context.Context) error { if err := ctx.Validate(dto); err != nil { ctx.AddFlash(validator.ValidationMessages(&err, ctx.GetData("locale").(*i18n.Locale)), "error") - return ctx.RedirectTo("/settings/ssh") + return ctx.RedirectTo("/-/settings/ssh") } key := dto.ToSSHKey() @@ -30,7 +30,7 @@ func SshKeysProcess(ctx *context.Context) error { pubKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(key.Content)) if err != nil { ctx.AddFlash(ctx.Tr("flash.user.invalid-ssh-key"), "error") - return ctx.RedirectTo("/settings/ssh") + return ctx.RedirectTo("/-/settings/ssh") } key.Content = strings.TrimSpace(string(ssh.MarshalAuthorizedKey(pubKey))) @@ -39,7 +39,7 @@ func SshKeysProcess(ctx *context.Context) error { return ctx.ErrorRes(500, "Cannot check if SSH key exists", err) } ctx.AddFlash(ctx.Tr("settings.ssh-key-exists"), "error") - return ctx.RedirectTo("/settings/ssh") + return ctx.RedirectTo("/-/settings/ssh") } if err := key.Create(); err != nil { @@ -48,20 +48,20 @@ func SshKeysProcess(ctx *context.Context) error { opengistssh.SyncAuthorizedKeysLogged() ctx.AddFlash(ctx.Tr("flash.user.ssh-key-added"), "success") - return ctx.RedirectTo("/settings/ssh") + return ctx.RedirectTo("/-/settings/ssh") } func SshKeysDelete(ctx *context.Context) error { user := ctx.User keyId, err := strconv.Atoi(ctx.Param("id")) if err != nil { - return ctx.RedirectTo("/settings/ssh") + return ctx.RedirectTo("/-/settings/ssh") } key, err := db.GetSSHKeyByID(uint(keyId)) if err != nil || key.UserID != user.ID { - return ctx.RedirectTo("/settings/ssh") + return ctx.RedirectTo("/-/settings/ssh") } if err := key.Delete(); err != nil { @@ -70,5 +70,5 @@ func SshKeysDelete(ctx *context.Context) error { opengistssh.SyncAuthorizedKeysLogged() ctx.AddFlash(ctx.Tr("flash.user.ssh-key-deleted"), "success") - return ctx.RedirectTo("/settings/ssh") + return ctx.RedirectTo("/-/settings/ssh") } diff --git a/internal/web/server/middlewares.go b/internal/web/server/middlewares.go index 4876bcc..a9e3e30 100644 --- a/internal/web/server/middlewares.go +++ b/internal/web/server/middlewares.go @@ -121,7 +121,7 @@ func (s *Server) errorHandler(err error, ctx echo.Context) { if acceptJson || data["err_render"] == "json" { renderErr = ctx.JSON(httpErr.Code, httpErr) } else { - renderErr = ctx.Render(httpErr.Code, "error", data) + renderErr = ctx.Render(httpErr.Code, "error.html", data) } if renderErr != nil && !isClientGone(renderErr) { @@ -209,7 +209,7 @@ func logged(next Handler) Handler { if user != nil { return next(ctx) } - return ctx.RedirectTo("/all") + return ctx.RedirectTo("/-/all") } } @@ -229,7 +229,7 @@ func inOAuthRegisterSession(next Handler) Handler { sess := ctx.GetSession() _, ok := sess.Values["oauthProvider"].(string) if !ok { - return ctx.RedirectTo("/login") + return ctx.RedirectTo("/-/login") } return next(ctx) } @@ -252,7 +252,7 @@ func makeCheckRequireLogin(isSingleGistAccess bool) Middleware { if !allow { ctx.AddFlash(ctx.Tr("flash.auth.must-be-logged-in"), "error") - return ctx.RedirectTo("/login") + return ctx.RedirectTo("/-/login") } return next(ctx) } @@ -275,7 +275,7 @@ func checkFileUploadEnabled(next Handler) Handler { // makeApiCheckRequireLogin is the /api/v1 counterpart of makeCheckRequireLogin: // it enforces the instance's RequireLogin / AllowGistsWithoutLogin settings on // anonymous gist reads, but responds with a JSON 401 instead of redirecting to -// /login. ctx.User is already resolved from the Authorization header by +// /-/login. ctx.User is already resolved from the Authorization header by // apiBindAuth, so there is no token fallback to do here. func makeApiCheckRequireLogin(isSingleGistAccess bool) Middleware { return func(next Handler) Handler { @@ -360,7 +360,7 @@ func sessionInit(next Handler) Handler { ctx.SaveSession(sess) ctx.User = nil ctx.SetData("userLogged", nil) - return ctx.RedirectTo("/all") + return ctx.RedirectTo("/-/all") } if user != nil { ctx.User = user diff --git a/internal/web/server/renderer.go b/internal/web/server/renderer.go index bf73a3a..b727ab2 100644 --- a/internal/web/server/renderer.go +++ b/internal/web/server/renderer.go @@ -7,6 +7,7 @@ import ( htmlpkg "html" "html/template" "io" + "io/fs" "net/http" "net/url" "path/filepath" @@ -29,9 +30,15 @@ import ( type Template struct { templates *template.Template + // pages holds layout-based templates, keyed by file name (e.g. "all.html"). + // Each entry is a clone of the base layout with that page's "content" block parsed in. + pages map[string]*template.Template } func (t *Template) Render(w io.Writer, name string, data interface{}, _ echo.Context) error { + if tmpl, ok := t.pages[name]; ok { + return tmpl.ExecuteTemplate(w, "base", data) + } return t.templates.ExecuteTemplate(w, name, data) } @@ -230,33 +237,59 @@ func (s *Server) setFuncMap() { }, } - t := template.Must(template.New("t").Funcs(fm).ParseFS(templates.Files, "*/*.html")) + base := template.Must(template.New("base").Funcs(fm).ParseFS(templates.Files, "layouts/*.html", "partials/*.html")) + pagePaths, err := fs.Glob(templates.Files, "pages/*.html") + if err != nil { + log.Fatal().Err(err).Msg("Failed to glob new page templates") + } + pages := make(map[string]*template.Template, len(pagePaths)) + for _, p := range pagePaths { + cloned := template.Must(base.Clone()) + pages[filepath.Base(p)] = template.Must(cloned.ParseFS(templates.Files, p)) + } + + customTemplates := template.Must(base.Clone()) customPattern := filepath.Join(config.GetHomeDir(), "custom", "*.html") matches, err := filepath.Glob(customPattern) if err != nil { log.Fatal().Err(err).Msg("Failed to check for custom templates") } if len(matches) > 0 { - t, err = t.ParseGlob(customPattern) + customTemplates, err = customTemplates.ParseGlob(customPattern) if err != nil { log.Fatal().Err(err).Msg("Failed to parse custom templates") } } + s.echo.Renderer = &Template{ - templates: t, + templates: customTemplates, + pages: pages, } } func (s *Server) parseManifestEntries() { - file, err := public.Files.Open(".vite/manifest.json") + entries, err := parseManifest(public.Files) if err != nil { - log.Fatal().Err(err).Msg("Failed to open manifest.json") + log.Fatal().Err(err).Msg("Failed to load manifest.json") } + context.ManifestEntries = entries +} + +func parseManifest(files fs.FS) (map[string]context.Asset, error) { + file, err := files.Open(".vite/manifest.json") + if err != nil { + return nil, err + } + defer file.Close() + byteValue, err := io.ReadAll(file) if err != nil { - log.Fatal().Err(err).Msg("Failed to read manifest.json") + return nil, err } - if err = gojson.Unmarshal(byteValue, &context.ManifestEntries); err != nil { - log.Fatal().Err(err).Msg("Failed to unmarshal manifest.json") + + entries := make(map[string]context.Asset) + if err = gojson.Unmarshal(byteValue, &entries); err != nil { + return nil, err } + return entries, nil } diff --git a/internal/web/server/router.go b/internal/web/server/router.go index 2a69008..84c8242 100644 --- a/internal/web/server/router.go +++ b/internal/web/server/router.go @@ -42,11 +42,11 @@ func (s *Server) registerRoutes() { r.Static("/avatar", settings.AvatarsDir()) - r.GET("/register", auth.Register) - r.POST("/register", auth.ProcessRegister) - r.GET("/login", auth.Login) - r.POST("/login", auth.ProcessLogin) - r.GET("/logout", auth.Logout) + r.GET("/-/register", auth.Register) + r.POST("/-/register", auth.ProcessRegister) + r.GET("/-/login", auth.Login) + r.POST("/-/login", auth.ProcessLogin) + r.GET("/-/logout", auth.Logout) r.GET("/oauth/register", auth.OauthRegister, inOAuthRegisterSession) r.POST("/oauth/register", auth.ProcessOauthRegister, inOAuthRegisterSession) r.GET("/oauth/:provider", auth.Oauth) @@ -61,11 +61,11 @@ func (s *Server) registerRoutes() { r.GET("/mfa", auth.Mfa, inMFASession) r.POST("/mfa/totp/assertion", auth.AssertTotp, inMFASession) - sA := r.SubGroup("/settings") + sA := r.SubGroup("/-/settings") { sA.Use(logged) sA.GET("", settings.UserAccount) - sA.GET("/mfa", settings.UserMFA) + sA.GET("/authentication", settings.UserAuthentication) sA.GET("/ssh", settings.UserSSHKeys) sA.GET("/style", settings.UserStyle) sA.POST("/style", settings.ProcessUserStyle) @@ -87,7 +87,7 @@ func (s *Server) registerRoutes() { sA.POST("/totp/regenerate", auth.RegenerateTotpRecoveryCodes) } - sB := r.SubGroup("/admin-panel") + sB := r.SubGroup("/-/admin-panel") { sB.Use(adminPermission) sB.GET("", admin.AdminIndex) @@ -98,6 +98,7 @@ func (s *Server) registerRoutes() { sB.GET("/invitations", admin.AdminInvitations) sB.POST("/invitations", admin.AdminInvitationsCreate) sB.POST("/invitations/:id/delete", admin.AdminInvitationsDelete) + sB.GET("/actions", admin.AdminActions) sB.POST("/sync-fs", admin.AdminSyncReposFromFS) sB.POST("/sync-db", admin.AdminSyncReposFromDB) sB.POST("/gc-repos", admin.AdminGcRepos) @@ -172,19 +173,23 @@ func (s *Server) registerRoutes() { r.Any("/api/*", noRouteFoundApi) - r.GET("/all", gist.AllGists, checkRequireLogin, setAllGistsMode("all")) + r.GET("/-/all", gist.AllGists, checkRequireLogin, setAllGistsMode("all")) + r.GET("/-/liked", gist.AllGists, checkRequireLogin, setAllGistsMode("all-liked")) + r.GET("/-/forked", gist.AllGists, checkRequireLogin, setAllGistsMode("all-forked")) + r.GET("/-/topics", gist.Topics, checkRequireLogin) + r.GET("/-/users", gist.Users, checkRequireLogin) if index.IndexEnabled() { - r.GET("/search", gist.Search, checkRequireLogin) + r.GET("/-/search", gist.Search, checkRequireLogin) } else { - r.GET("/search", gist.AllGists, checkRequireLogin, setAllGistsMode("search")) + r.GET("/-/search", gist.AllGists, checkRequireLogin, setAllGistsMode("search")) } r.GET("/:user", gist.AllGists, checkRequireLogin, setAllGistsMode("fromUser")) - r.GET("/:user/liked", gist.AllGists, checkRequireLogin, setAllGistsMode("liked")) - r.GET("/:user/forked", gist.AllGists, checkRequireLogin, setAllGistsMode("forked")) + r.GET("/:user/-/liked", gist.AllGists, checkRequireLogin, setAllGistsMode("liked")) + r.GET("/:user/-/forked", gist.AllGists, checkRequireLogin, setAllGistsMode("forked")) - r.GET("/topics/:topic", gist.AllGists, checkRequireLogin, setAllGistsMode("topics")) + r.GET("/-/topics/:topic", gist.AllGists, checkRequireLogin, setAllGistsMode("topics")) sC := r.SubGroup("/:user/:gistname") { @@ -200,6 +205,8 @@ func (s *Server) registerRoutes() { sC.HEAD("/raw/:revision/:file", gist.RawFile) sC.GET("/download/:revision/:file", gist.DownloadFile) sC.HEAD("/download/:revision/:file", gist.DownloadFile) + sC.GET("/settings", gist.GistSettings, logged, writePermission) + sC.POST("/metadata", gist.EditMetadata, logged, writePermission, notArchived) sC.GET("/edit", gist.Edit, logged, writePermission, notArchived) sC.POST("/edit", gist.ProcessCreate, logged, writePermission, notArchived) sC.POST("/like", gist.Like, logged) diff --git a/internal/web/server/server.go b/internal/web/server/server.go index 4835b9e..3ce986d 100644 --- a/internal/web/server/server.go +++ b/internal/web/server/server.go @@ -37,12 +37,12 @@ func NewServer(isDev bool) *Server { } s.registerMiddlewares() - s.setFuncMap() s.echo.HTTPErrorHandler = s.errorHandler if !s.dev { s.parseManifestEntries() } + s.setFuncMap() s.registerRoutes() diff --git a/internal/web/test/server.go b/internal/web/test/server.go index ed7c7e1..2e246ce 100644 --- a/internal/web/test/server.go +++ b/internal/web/test/server.go @@ -154,11 +154,11 @@ func (s *Server) TestCtxData(t *testing.T, expected echo.Map) { } func (s *Server) Register(t *testing.T, user string) { - s.Request(t, "POST", "/register", db.UserDTO{Username: user, Password: user}, 302) + s.Request(t, "POST", "/-/register", db.UserDTO{Username: user, Password: user}, 302) } func (s *Server) Login(t *testing.T, user string) { - s.Request(t, "POST", "/login", db.UserDTO{Username: user, Password: user}, 302) + s.Request(t, "POST", "/-/login", db.UserDTO{Username: user, Password: user}, 302) } func (s *Server) Logout() { @@ -166,7 +166,7 @@ func (s *Server) Logout() { } func (s *Server) CreateGist(t *testing.T, visibility string) (gistPath string, gist *db.Gist, username, identifier string) { - s.Request(t, "POST", "/register", db.UserDTO{Username: "thomas", Password: "thomas"}, 0) + s.Request(t, "POST", "/-/register", db.UserDTO{Username: "thomas", Password: "thomas"}, 0) s.Login(t, "thomas") resp := s.Request(t, "POST", "/", url.Values{ diff --git a/package-lock.json b/package-lock.json index 5682c5f..42e8042 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,10 +18,13 @@ "@tailwindcss/forms": "^0.5.11", "@tailwindcss/typography": "^0.5.20", "@tailwindcss/vite": "^4.3.3", + "basecoat-css": "^0.3.11", "codemirror": "^6.0.2", "dompurify": "^3.4.12", "github-markdown-css": "^5.9.0", "highlight.js": "^11.11.1", + "htmx.org": "^2.0.10", + "hyperscript.org": "^0.9.93", "jdenticon": "^3.3.0", "katex": "^0.18.1", "marked": "^18.0.6", @@ -1451,6 +1454,13 @@ "node": "18 || 20 || >=22" } }, + "node_modules/basecoat-css": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/basecoat-css/-/basecoat-css-0.3.11.tgz", + "integrity": "sha512-cU91Egcg9AhKJ1HswpKFItHC//FWDt1T8AlL5Yk9Fh5I2WYe+QhN3MtiVJ2vemqFF4iAM1vfbikhhjc+vkr2zA==", + "dev": true, + "license": "MIT" + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -1694,6 +1704,23 @@ "node": ">=12.0.0" } }, + "node_modules/htmx.org": { + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.10.tgz", + "integrity": "sha512-kdeJe7ZVwaS6QMz/ebBIVtZdpwen6L0OQ5GOhPV9MKBb196TCZeZu4yA7ZIQsaLKv7EpXz+So7KSXNuHXhj7Cw==", + "dev": true, + "license": "0BSD" + }, + "node_modules/hyperscript.org": { + "version": "0.9.93", + "resolved": "https://registry.npmjs.org/hyperscript.org/-/hyperscript.org-0.9.93.tgz", + "integrity": "sha512-9Lr9SisBgUW5AjeTekwXErZJLlnFFfq8BiXDzaMGpmX8MA64XdSM3MFYDaWsSFj/ALxeWwtteVgWobKn8DYIkA==", + "dev": true, + "license": "BSD 2-Clause", + "bin": { + "hyperscript.org": "dist/platform/node-hyperscript.js" + } + }, "node_modules/ignore-by-default": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", diff --git a/package.json b/package.json index cdd7961..c19668b 100644 --- a/package.json +++ b/package.json @@ -13,17 +13,20 @@ "@codemirror/lang-javascript": "^6.2.5", "@codemirror/language": "^6.12.4", "@codemirror/language-data": "^6.5.2", - "@lezer/highlight": "^1.2.3", "@codemirror/state": "^6.7.1", "@codemirror/text": "^0.19.6", "@codemirror/view": "^6.43.6", + "@lezer/highlight": "^1.2.3", "@tailwindcss/forms": "^0.5.11", "@tailwindcss/typography": "^0.5.20", "@tailwindcss/vite": "^4.3.3", + "basecoat-css": "^0.3.11", "codemirror": "^6.0.2", "dompurify": "^3.4.12", "github-markdown-css": "^5.9.0", "highlight.js": "^11.11.1", + "htmx.org": "^2.0.10", + "hyperscript.org": "^0.9.93", "jdenticon": "^3.3.0", "katex": "^0.18.1", "marked": "^18.0.6", diff --git a/public/css/embed-ipynb.css b/public/css/embed-ipynb.css new file mode 100644 index 0000000..b60581a --- /dev/null +++ b/public/css/embed-ipynb.css @@ -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)); +} diff --git a/public/css/style.css b/public/css/embed-style.css similarity index 100% rename from public/css/style.css rename to public/css/embed-style.css diff --git a/public/css/embed.css b/public/css/embed.css index bd54411..296e851 100644 --- a/public/css/embed.css +++ b/public/css/embed.css @@ -1,9 +1,29 @@ +/* Self-contained stylesheet for the embeddable gist widget + (templates/partials/gist_embed.html). It is loaded inside the + web component's shadow DOM and deliberately does NOT depend + on the app's new token system — it ships its own scoped Tailwind + palette so + the embed renders identically wherever it is included. */ @plugin "@tailwindcss/typography"; @plugin "@tailwindcss/forms"; -@custom-variant dark (&:where(.dark, .dark *)); +/* Dark styling fires on EITHER: + - the .dark class (explicit ?dark, or the auto-mode JS toggle), OR + - the OS preference, but only when .theme-auto is present (auto mode) so an + explicit ?light embed on a dark-OS page stays light. + The media path means the card background follows the OS even if the JS + class-toggle never runs — matching how the syntax highlighting already works. */ +@custom-variant dark { + &:where(.dark, .dark *) { + @slot; + } + @media (prefers-color-scheme: dark) { + &:where(.theme-auto, .theme-auto *) { + @slot; + } + } +} -@source "../../templates/pages/gist_embed.html"; +@source "../../templates/partials/gist_embed.html"; @theme { /* Custom gray palette */ @@ -55,7 +75,7 @@ cursor: pointer; } - @import './ipynb.css'; - @import "./style.css"; + @import './embed-ipynb.css'; + @import "./embed-style.css"; } } diff --git a/public/css/globals.css b/public/css/globals.css new file mode 100644 index 0000000..577e4ff --- /dev/null +++ b/public/css/globals.css @@ -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. +
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 by type; mirror it for + +
+ +
- - -
- - {{ end }} -
- - -
- - - +
+ + + +
-
- -
- + - {{ .csrfHtml }} - + - - + -
+ + - -{{ template "footer" .}} +{{ end }} diff --git a/templates/pages/edit.html b/templates/pages/edit.html index 942bbc1..bec145a 100644 --- a/templates/pages/edit.html +++ b/templates/pages/edit.html @@ -1,112 +1,54 @@ -{{ template "header" .}} -
-
-
-
-

- {{ .locale.Tr "gist.edit.editing" }} {{ .gist.Title }} -

-
-
-
- {{ .csrfHtml }} -
- -
- - -
-
-
-
- {{ .csrfHtml }} - -
-
-
-
-
-
-
-

Metadata {{ if .dto.HasMetadata }}▲{{ else }}▼{{ end }}

- -
-
- {{ if .dto.Files }} - {{ range .dto.Files }} - {{ template "_editor" dict "Filename" .Filename "Content" .Content "Binary" .Binary "locale" $.locale }} - {{ end }} - {{ else }} - {{ template "_editor" . }} +{{ define "content" }} +
+

{{ .locale.Tr "gist.edit.editing" }} {{ .gist.Title }}

+ + + {{ .csrfHtml }} + + +
+ {{ if .dto.Files }} + {{ range .dto.Files }} + {{ template "_editor" dict "Filename" .Filename "Content" .Content "Binary" .Binary "locale" $.locale }} {{ end }} -
- {{ if not .c.DisableFileUpload }} -
- -
-
+ {{ else }} + {{ template "_editor" dict "Filename" "" "Content" "" "Binary" false "locale" .locale }} {{ end }} -
- - {{ .locale.Tr "gist.edit.cancel" }} - -
- {{ .csrfHtml }} - - - - -
+ {{ if not .c.DisableFileUpload }} +
+ +
+
+ {{ end }} + + +
+ + +
+ {{ .locale.Tr "gist.edit.cancel" }} + +
+
+ + + +
- -{{ template "footer" .}} +{{ end }} diff --git a/templates/pages/error.html b/templates/pages/error.html index fec6fd3..35555d5 100644 --- a/templates/pages/error.html +++ b/templates/pages/error.html @@ -1,16 +1,17 @@ -{{ define "error" }} -{{ template "header" .}} +{{ define "content" }} +
+
+ + + +
-
- - - - -

{{ .locale.Tr "error" }} {{ .error.Code }}

-

{{ httpStatusText .error.Code }}

- {{ if lt .error.Code 500 }} -

{{ .error.Message }}

+

{{ .locale.Tr "error" }} {{ .error.Code }}

+

{{ httpStatusText .error.Code }}

+ {{ if and (lt .error.Code 500) .error.Message }} +

{{ .error.Message }}

{{ end }} + + {{ .locale.Tr "error.go-home" }}
-{{ template "footer" .}} -{{end}} +{{ end }} diff --git a/templates/pages/explore_users.html b/templates/pages/explore_users.html new file mode 100644 index 0000000..eaea858 --- /dev/null +++ b/templates/pages/explore_users.html @@ -0,0 +1,81 @@ +{{ define "content" }} +
+
+

{{ .locale.Tr "gist.list.users" }}

+
+ + +
+
+
+ +
+ + + +
+ + + +
+ + {{ if ne (len .users) 0 }} +
+ + + + + + + + + {{ range .users }} + + + + + {{ end }} + +
{{ .locale.Tr "admin.user" }}{{ .locale.Tr "gist.list.gists" }}
+ + {{ if not (shouldGenerateAvatar .User $.DisableGravatar) }} + {{ .Username }} + {{ else }} + + {{ end }} + + {{ .Username }} + {{ $.locale.Tr "gist.list.joined" }} {{ .CreatedAt | humanTimeDiff }} + + + {{ .NbGists }}
+
+ {{ template "pagination" . }} + {{ else }} +
+ +

{{ .locale.Tr "explore.users.no-users" }}

+
+ {{ end }} +
+{{ end }} diff --git a/templates/pages/forks.html b/templates/pages/forks.html index 94c2299..df85d9b 100644 --- a/templates/pages/forks.html +++ b/templates/pages/forks.html @@ -1,43 +1,31 @@ -{{ template "header" .}} -{{ template "gist_header" .}} +{{ define "content" }} +
+ {{ template "gist_header" . }} + +

{{ .locale.Tr "gist.forks" }}

+ {{ if ne (len .forks) 0 }} - + {{ template "pagination" . }} {{ else }} -
- - - - -

{{ .locale.Tr "gist.forks.no" }}

+
+ +

{{ .locale.Tr "gist.forks.no" }}

{{ end }} -{{ template "gist_footer" .}} -{{ template "footer" .}} +
+{{ end }} diff --git a/templates/pages/gist.html b/templates/pages/gist.html index 2506927..82cc40c 100644 --- a/templates/pages/gist.html +++ b/templates/pages/gist.html @@ -1,150 +1,122 @@ -{{ template "header" .}} -{{ template "gist_header" .}} - {{ if .files }} -
- {{ if .hasMoreFiles }} -
- {{ .locale.Tr "gist.files-truncated" }} -
- {{ end }} - {{ range $file := .files }} -
-
-
+{{ define "content" }} +
+ {{ template "gist_header" . }} - - - - - {{ $file.Filename }} - - - - - - {{ $.locale.Tr "gist.raw" }} - - {{ if $file.MimeType.IsText }} - - {{ end }} - - - - - - - - -
- {{ if and $file.Truncated $file.MimeType.IsText }} -
- {{ $.locale.Tr "gist.file-truncated" }} {{ $.locale.Tr "gist.watch-full-file" }} -
- {{ end }} - {{ if not $file.MimeType.CanBeRendered }} -
- {{ $.locale.Tr "gist.file-raw" }} {{ $.locale.Tr "gist.watch-full-file" }} -
- {{ end }} - {{ if and (not $file.MimeType.IsText) ($file.MimeType.IsCSV) }} -
- {{ $.locale.Tr "gist.file-not-valid" }} -
- {{ end }} -
-
- {{ if $file.MimeType.IsText }} - {{ if eq $file.InternalType "CSVFile" }} - - - - {{ range $file.Header }} - - {{ end }} - - - - {{ range $file.Rows }} - - {{ range . }} - - {{ end }} - - {{ end }} -
{{ . }}
{{ . }}
- {{ else if isMarkdown $file.Filename }} -
{{ $file.HTML | safe }}
- {{ else if isMermaid $file.Filename }} -
{{ $file.HTML | safe }}
- {{ else if $file.MimeType.IsSVG }} -
{{ $file.HTML | safe }}
- {{ else if isJupyter $file.Filename }} -
-
- - - - -
- -
- {{ else }} -
- {{ $fileslug := slug $file.Filename }} - {{ if ne $file.Content "" }} - - - {{ $ii := "1" }} - {{ $i := toInt $ii }} - {{ range $line := $file.Lines }}{{ $i = inc $i }}{{ end }} - -
{{$i}}{{ $line | safe }}
- {{ end }} -
- {{ end }} - {{ else if $file.MimeType.IsImage }} -
- {{ $file.Filename }} -
- {{ else if $file.MimeType.IsAudio }} -
- -
- {{ else if $file.MimeType.IsVideo }} -
- -
- {{ else if $file.MimeType.IsPDF }} -
- {{ end }} -
-
- {{ end }} -
- {{ else }} -
- - - -

{{ .locale.Tr "gist.no-content" }}

-
+ {{ if .gist.Archived }} +
+ + {{ .locale.Tr "gist.header.archived-help" }} +
{{ end }} - - -
+ {{ if .revision }}{{ if ne .revision "HEAD" }} +

{{ .locale.Tr "gist.header.revision" }} {{ .revision }}

+ {{ end }}{{ end }} - + + {{ if .files }} +
+ {{ if .hasMoreFiles }} +
{{ .locale.Tr "gist.files-truncated" }}
+ {{ end }} -{{ template "gist_footer" .}} -{{ template "footer" .}} + {{ range $file := .files }} + {{ $fileslug := slug $file.Filename }} +
+
+ + + {{ $file.Filename }} + + + + {{ $.locale.Tr "gist.raw" }} + {{ if $file.MimeType.IsText }} + + + {{ end }} + + + + +
+ + {{ if and $file.Truncated $file.MimeType.IsText }} +

{{ $.locale.Tr "gist.file-truncated" }} {{ $.locale.Tr "gist.watch-full-file" }}

+ {{ end }} + {{ if not $file.MimeType.CanBeRendered }} +

{{ $.locale.Tr "gist.file-raw" }} {{ $.locale.Tr "gist.watch-full-file" }}

+ {{ end }} + {{ if and (not $file.MimeType.IsText) ($file.MimeType.IsCSV) }} +

{{ $.locale.Tr "gist.file-not-valid" }}

+ {{ end }} + +
+ {{ if $file.MimeType.IsText }} + {{ if eq $file.InternalType "CSVFile" }} + + {{ range $file.Header }}{{ end }} + {{ range $file.Rows }}{{ range . }}{{ end }}{{ end }} +
{{ . }}
{{ . }}
+ {{ else if isMarkdown $file.Filename }} +
{{ $file.HTML | safe }}
+ {{ else if isMermaid $file.Filename }} +
{{ $file.HTML | safe }}
+ {{ else if $file.MimeType.IsSVG }} +
{{ $file.HTML | safe }}
+ {{ else if isJupyter $file.Filename }} +
+
+ + + + +
+ +
+ {{ else if ne $file.Content "" }} +
+ + + {{ $i := 1 }} + {{ range $line := $file.Lines }}{{ $i = inc $i }}{{ end }} + +
{{ $i }}{{ $line | safe }}
+
+ {{ end }} + {{ else if $file.MimeType.IsImage }} +
{{ $file.Filename }}
+ {{ else if $file.MimeType.IsAudio }} +
+ {{ else if $file.MimeType.IsVideo }} +
+ {{ else if $file.MimeType.IsPDF }} +
+
+ + + + +
+
+ {{ end }} +
+
+ {{ end }} +
+ {{ else }} +
+ +

{{ .locale.Tr "gist.no-content" }}

+
+ {{ end }} +
+{{ end }} diff --git a/templates/pages/gist_settings.html b/templates/pages/gist_settings.html new file mode 100644 index 0000000..87db587 --- /dev/null +++ b/templates/pages/gist_settings.html @@ -0,0 +1,101 @@ +{{ define "content" }} +
+
+

{{ .locale.Tr "settings" }}

+

+ {{ .gist.User.Username }}/{{ .gist.Title }} +

+
+ + {{ if not .gist.Archived }} + +
+
+ {{ .csrfHtml }} +
+ + +
+
+ +
+ {{ .gist.User.Username }}/ + +
+
+
+ + +
+
+ + +
+
+ +
+
+
+ {{ end }} + + +
+
+
+

{{ .locale.Tr "gist.search.placeholder.visibility" }}

+

{{ .locale.Tr "gist.settings.visibility-help" }}

+
+
+ {{ .csrfHtml }} + + +
+
+
+ + +
+
+
+

+ {{ .locale.Tr "gist.header.archive" }} + {{ if .gist.Archived }}{{ .locale.Tr "gist.header.archived" }}{{ end }} +

+

{{ .locale.Tr "gist.settings.archive-help" }}

+
+
+ {{ .csrfHtml }} + +
+
+
+ + +
+
+

{{ .locale.Tr "gist.settings.danger-zone" }}

+
+
+
+

{{ .locale.Tr "gist.settings.delete-gist" }}

+

{{ .locale.Tr "gist.settings.delete-help" }}

+
+
+ {{ .csrfHtml }} + +
+
+
+
+{{ end }} diff --git a/templates/pages/likes.html b/templates/pages/likes.html index 0635760..cc0a60e 100644 --- a/templates/pages/likes.html +++ b/templates/pages/likes.html @@ -1,35 +1,28 @@ -{{ template "header" .}} -{{ template "gist_header" .}} +{{ define "content" }} +
+ {{ template "gist_header" . }} + +

{{ .locale.Tr "gist.likes" }}

+ {{ if ne (len .likers) 0 }} -

{{ .locale.Tr "gist.likes" }}

-
+
{{ range $user := .likers }} -
-
- {{ if not (shouldGenerateAvatar . $.DisableGravatar) }} - {{ $user.Username }}'s Avatar - {{ else }} - - {{ end }} - - -

{{ $user.Username }}

-
-
-
+ + {{ if not (shouldGenerateAvatar $user $.DisableGravatar) }} + {{ $user.Username }} + {{ else }} + + {{ end }} + {{ $user.Username }} + {{ end }}
-
- {{ template "_pagination" . }} -
+ {{ template "pagination" . }} {{ else }} -
- - - - -

{{ .locale.Tr "gist.likes.no" }}

+
+ +

{{ .locale.Tr "gist.likes.no" }}

{{ end }} -{{ template "gist_footer" .}} -{{ template "footer" .}} +
+{{ end }} diff --git a/templates/pages/mfa.html b/templates/pages/mfa.html index 2a4b8f0..99faea1 100644 --- a/templates/pages/mfa.html +++ b/templates/pages/mfa.html @@ -1,60 +1,43 @@ -{{ template "header" .}} - -
-
-
-

{{ .locale.Tr "auth.mfa" }}

+{{ define "content" }} +
+
+
+
-
-
-
- {{ if .hasWebauthn }} -
-
-

{{ .locale.Tr "auth.mfa.use-passkey-to-finish" }}

-
-
- - - -
-
-
- {{ .csrfHtml }} - -
-
-
- -
-
- {{ end }} +

{{ .locale.Tr "auth.mfa" }}

+
- {{ if .hasTotp }} -
-
-

{{ .locale.Tr "auth.totp.enter-code" }}

-
-
-

{{ .locale.Tr "auth.totp.enter-recovery-key" }}

-
-
-
- {{ .csrfHtml }} - -
- -
- -
-
-
- {{ end }} + {{ if .hasWebauthn }} +
+

{{ .locale.Tr "auth.mfa.use-passkey-to-finish" }}

+
+ {{ .csrfHtml }} + +
+ +
+ {{ end }} + + {{ if .hasTotp }} +
+
+

{{ .locale.Tr "auth.totp.enter-code" }}

+

{{ .locale.Tr "auth.totp.enter-recovery-key" }}

-
+
+ {{ .csrfHtml }} +
+ + +
+ +
+
+ {{ end }}
- - -{{ template "footer" .}} +{{ end }} diff --git a/templates/pages/oauth_register.html b/templates/pages/oauth_register.html index d7d688d..121d577 100644 --- a/templates/pages/oauth_register.html +++ b/templates/pages/oauth_register.html @@ -1,85 +1,48 @@ -{{ template "header" .}} -
-
-

- {{ .title }} -

-
-
-
-
-
-
+{{ define "content" }} +
+
+ {{ if .oauthAvatarURL }} + Avatar + {{ end }} +

{{ .title }}

+

+ {{ .locale.Tr "auth.oauth.signing-in-with" .c.OIDCProviderName }} +

+
-
- {{ if .oauthAvatarURL }} - Avatar - {{ end }} -

- {{ .locale.Tr "auth.oauth.signing-in-with" $.c.OIDCProviderName }} -

-
- -
-
- -
- -
-
- -
- -
- -
-

- {{ .locale.Tr "settings.email-help" }} -

-
- -
-
- -
- - {{ .locale.Tr "auth.oauth.cancel" }} - -
- {{ .csrfHtml }} -
-
-
+
+
+
+ +
-
-
-
-

{{ .locale.Tr "auth.oauth.existing-account" }}

-
- - - -
-

- {{ .locale.Tr "auth.oauth.already-have-account" $.c.OIDCProviderName }} -

- -
-
+ +
+ + +

{{ .locale.Tr "settings.email-help" }}

+ + + + {{ .csrfHtml }} + + + + +
+

{{ .locale.Tr "auth.oauth.existing-account" }}

+

+ {{ .locale.Tr "auth.oauth.already-have-account" .c.OIDCProviderName }} +

+ {{ .locale.Tr "auth.login" }}
-
+
+ +

+ {{ .locale.Tr "auth.oauth.cancel" }} +

-{{ template "footer" .}} +{{ end }} diff --git a/templates/pages/revisions.html b/templates/pages/revisions.html index e6d08e5..9fc47e4 100644 --- a/templates/pages/revisions.html +++ b/templates/pages/revisions.html @@ -1,119 +1,109 @@ -{{ template "header" .}} -{{ template "gist_header" .}} -{{ if ne (len .commits) 0 }} +{{ define "content" }} +
+ {{ template "gist_header" . }} -
+ {{ if ne (len .commits) 0 }} +
{{ range $commit := .commits }} -
-
-

- - - - {{ $user := $commit.User }} - {{ if not (shouldGenerateAvatar $user $.DisableGravatar) }} - - {{ else }} - - {{ end }} - {{if $user}}{{$user.Username}}{{else}}{{ $commit.AuthorName }}{{end}} {{ $.locale.Tr "gist.revision.revised" }} {{ $commit.Timestamp | humanTimeDiffStr }}. {{ $.locale.Tr "gist.revision.go-to-revision" }}

+
+
+

+ {{ $user := $commit.User }} + {{ if not (shouldGenerateAvatar $user $.DisableGravatar) }} + + {{ else }} + + {{ end }} + {{ if $user }}{{ $user.Username }}{{ else }}{{ $commit.AuthorName }}{{ end }} + {{ $.locale.Tr "gist.revision.revised" }} {{ $commit.Timestamp | humanTimeDiffStr }} + · + {{ slice $commit.Hash 0 7 }} +

{{ if gt $commit.FilesChanged 0 }} -

- - - - {{ $commit.FilesChanged }} {{ if eq $commit.FilesChanged 1 }}file{{ else }}files{{ end }} changed{{ if gt $commit.Additions 0 }}, {{ $commit.Additions }} {{ if eq $commit.Additions 1 }}insertion{{ else }}insertions{{ end }}{{ end }}{{ if gt $commit.Deletions 0 }}, {{ $commit.Deletions }} {{ if eq $commit.Deletions 1 }}deletion{{ else }}deletions{{ end }}{{ end }} +

+ {{ $commit.FilesChanged }} {{ if eq $commit.FilesChanged 1 }}file{{ else }}files{{ end }} changed{{ if gt $commit.Additions 0 }}, {{ $commit.Additions }} {{ if eq $commit.Additions 1 }}insertion{{ else }}insertions{{ end }}{{ end }}{{ if gt $commit.Deletions 0 }}, {{ $commit.Deletions }} {{ if eq $commit.Deletions 1 }}deletion{{ else }}deletions{{ end }}{{ end }} +

{{ end }} -

-
- {{ if ne (len $commit.Files) 0 }} - {{ range $file := $commit.Files }} -
-
-

- - - - {{ if $file.IsCreated }} - {{ $file.Filename }}({{ $.locale.Tr "gist.revision.file-created" }}) - {{ else if $file.IsDeleted }} - {{ $file.Filename }} ({{ $.locale.Tr "gist.revision.file-deleted" }}) - {{ else if ne $file.OldFilename $file.Filename }} - {{ $file.OldFilename }} {{ $.locale.Tr "gist.revision.file-renamed" }} {{ $file.Filename }} - {{ else }} - {{ $file.Filename }} - {{ end }} -

-
-
- {{ if $file.Truncated }} -

{{ $.locale.Tr "gist.revision.diff-truncated" }}

- {{ else if $file.IsBinary }} -

{{ $.locale.Tr "gist.revision.binary-file-changes" }}

- {{ else if and (eq $file.Content "") (ne $file.OldFilename "") }} -

{{ $.locale.Tr "gist.revision.file-renamed-no-changes" }}

- {{ else if eq $file.Content "" }} -

{{ $.locale.Tr "gist.revision.empty-file" }}

- {{ else }} - - - {{ $left := 0 }} - {{ $right := 0 }} - {{ range $line := split $file.Content "\n" }} - {{ if ne $line "" }}{{ if ne (index $line 0) 92 }} + + {{ if ne (len $commit.Files) 0 }} +
+ {{ range $file := $commit.Files }} +
+
+ + {{ if $file.IsCreated }} + {{ $file.Filename }} ({{ $.locale.Tr "gist.revision.file-created" }}) + {{ else if $file.IsDeleted }} + {{ $file.Filename }} ({{ $.locale.Tr "gist.revision.file-deleted" }}) + {{ else if ne $file.OldFilename $file.Filename }} + {{ $file.OldFilename }} {{ $.locale.Tr "gist.revision.file-renamed" }} {{ $file.Filename }} + {{ else }} + {{ $file.Filename }} + {{ end }} +
+
+ {{ if $file.Truncated }} +

{{ $.locale.Tr "gist.revision.diff-truncated" }}

+ {{ else if $file.IsBinary }} +

{{ $.locale.Tr "gist.revision.binary-file-changes" }}

+ {{ else if and (eq $file.Content "") (ne $file.OldFilename "") }} +

{{ $.locale.Tr "gist.revision.file-renamed-no-changes" }}

+ {{ else if eq $file.Content "" }} +

{{ $.locale.Tr "gist.revision.empty-file" }}

+ {{ else }} +
+ + {{ $left := 0 }} + {{ $right := 0 }} + {{ range $line := split $file.Content "\n" }} + {{ if ne $line "" }}{{ if ne (index $line 0) 92 }} + {{ if eq (index $line 0) 64 }} + {{ $left = toInt (index (splitGit (index (split $line "-") 1)) 0) }} + {{ $right = toInt (index (splitGit (index (split $line "+") 1)) 0) }} + {{ end }} + {{ if eq (index $line 0) 64 }} - {{ $left = toInt (index (splitGit (index (split $line "-") 1)) 0) }} - {{ $right = toInt (index (splitGit (index (split $line "+") 1)) 0) }} - {{ end }} - - {{ if eq (index $line 0) 64 }} - - {{ else }} - {{ if eq (index $line 0) 43 }} - - - {{ $right = inc $right }} - {{ else if eq (index $line 0) 45 }} - - - {{ $left = inc $left }} - {{ else if eq (index $line 0) 32 }} - - - {{ $left = inc $left }} - {{ $right = inc $right }} - {{ end }} + + {{ else }} + {{ if eq (index $line 0) 43 }} + + + {{ $right = inc $right }} + {{ else if eq (index $line 0) 45 }} + + + {{ $left = inc $left }} + {{ else if eq (index $line 0) 32 }} + + + {{ $left = inc $left }} + {{ $right = inc $right }} {{ end }} - - - - {{end}} - {{end}}{{end}} - -
{{ $right }}{{ $left }}{{ $left }}{{ $right }}{{ $right }}{{ $left }}{{ $left }}{{ $right }}{{ if ne (index $line 0) 64 }}{{ slice $line 0 1 }}{{ end }}{{ if ne (index $line 0) 64 }}{{ slice $line 1 }}{{ else }}{{ $line }}{{ end }}
- {{ end }} -
+ {{ end }} + {{ if ne (index $line 0) 64 }}{{ slice $line 0 1 }}{{ end }} + {{ if ne (index $line 0) 64 }}{{ slice $line 1 }}{{ else }}{{ $line }}{{ end }} + + {{ end }}{{ end }}{{ end }} + + + {{ end }}
- {{end}} - {{else}} -

{{ $.locale.Tr "gist.revision.no-changes" }}

- {{end}} + + {{ end }}
-
- {{end}} -
-
- {{ template "_pagination" . }} -
-{{ else }} -
- - - -

{{ .locale.Tr "gist.revision.no-revisions" }}

+ {{ else }} +

{{ $.locale.Tr "gist.revision.no-changes" }}

+ {{ end }} + + {{ end }} +
+ {{ template "pagination" . }} + {{ else }} +
+ +

{{ .locale.Tr "gist.revision.no-revisions" }}

+
+ {{ end }}
{{ end }} - -{{ template "gist_footer" .}} -{{ template "footer" .}} diff --git a/templates/pages/search.html b/templates/pages/search.html index a0dd9de..a82964c 100644 --- a/templates/pages/search.html +++ b/templates/pages/search.html @@ -1,40 +1,51 @@ -{{ template "header" .}} -
-
-
-
-

{{ .nbHits }} {{ .locale.Tr "gist.search.found" }}

-
+{{ define "content" }} +
+
+
+

{{ .locale.Tr "gist.list.search-results" }}

+ {{ .nbHits }} {{ .locale.Tr "gist.search.found" }}
-
-
- {{ if ne (len .gists) 0 }} -
-
-
- {{ range $lang, $count := .langs }} - - {{ $lang }} ({{ $count }}) - - {{end}} -
-
-
- {{ range $gist := .gists }} - {{ $nest := dict "gist" $gist "c" $.c "locale" $.locale "DisableGravatar" $.DisableGravatar }} - {{ template "_gist_preview" $nest }} - {{ end }} -
-
- {{ template "_pagination" . }} - {{ else }} -
- - - -

{{ .locale.Tr "gist.search.no-results" }}

-
+ {{ if .searchQuery }} +

+ {{ .locale.Tr "gist.list.search-for" }} + {{ .searchQuery }} +

{{ end }} -
+
+ + {{ if ne (len .gists) 0 }} +
+
+ {{ range $gist := .gists }} + {{ template "gist" (dict "gist" $gist "c" $.c "locale" $.locale "DisableGravatar" $.DisableGravatar "currentStyle" $.currentStyle) }} + {{ end }} + {{ template "pagination" . }} +
+ + {{ if ne (len .langs) 0 }} + + {{ end }} +
+ {{ else }} +
+ +

{{ .locale.Tr "gist.search.no-results" }}

+
+ {{ end }}
-{{ template "footer" .}} +{{ end }} diff --git a/templates/pages/settings_account.html b/templates/pages/settings_account.html index 63f710e..1146322 100644 --- a/templates/pages/settings_account.html +++ b/templates/pages/settings_account.html @@ -1,200 +1,93 @@ -{{ template "header" .}} -{{ template "settings_header" .}} -
-
-
-

- {{ .locale.Tr "settings.avatar" }} -

-

- {{ .locale.Tr "settings.avatar-help" }} -

-
-
- {{ if not (shouldGenerateAvatar .userLogged .DisableGravatar) }} - {{ .userLogged.Username }}'s Avatar - {{ else }} - - - {{ end }} -
-
-
- - {{ .csrfHtml }} -
- {{ if .userLogged.HasUploadedAvatar }} -
- - - {{ .csrfHtml }} -
- {{ end }} -
-
-
-
-
-
-
-

- {{ .locale.Tr "settings.change-username" }} -

-
-
-
- -
-
- +{{ define "content" }} +
+
+

{{ .locale.Tr "settings" }}

+

{{ .locale.Tr "settings.header.account" }}

+
- + +
+
+

{{ .locale.Tr "settings.avatar" }}

+

{{ .locale.Tr "settings.avatar-help" }}

+
+
+
+ {{ if not (shouldGenerateAvatar .userLogged .DisableGravatar) }} + {{ .userLogged.Username }} + {{ else }} + + {{ end }} +
+
+ {{ .csrfHtml }} + + {{ if .userLogged.HasUploadedAvatar }} +
+ {{ .csrfHtml }} + + +
+ {{ end }}
- {{ if not .disableForm }} -
-
-

- {{if .hasPassword}} - {{ .locale.Tr "settings.change-password" }} - {{else}} - {{ .locale.Tr "settings.create-password" }} - {{end}} -

-

- {{if .hasPassword}} - {{ .locale.Tr "settings.change-password-help" }} - {{else}} - {{ .locale.Tr "settings.create-password-help" }} - {{end}} -

-
-
- -
- -
-
- +
- - {{ .csrfHtml }} - -
+ +
+
+

{{ .locale.Tr "settings.change-username" }}

- {{ end }} -
-
-
-

- {{ .locale.Tr "settings.email" }} -

-

- {{ .locale.Tr "settings.email-help" }} -

-
-
-
- -
-
- + + {{ .csrfHtml }} + +
+ + +
+
+ +
+
+ + + +
+
+

{{ .locale.Tr "settings.email" }}

+

{{ .locale.Tr "settings.email-help" }}

+
+
+ {{ .csrfHtml }} +
+ + +
+
+ +
+
+
+ + +
+
+

{{ .locale.Tr "settings.delete-account" }}

+
+
+

{{ .locale.Tr "settings.delete-account" }}

+
{{ .csrfHtml }} + +
-
- {{ if or .githubOauth .gitlabOauth .giteaOauth .oidcOauth }} -
-
-

- {{ .locale.Tr "settings.link-accounts" }} -

-
- - {{ if .githubOauth }} - {{ if .userLogged.GithubID }} - - {{ .locale.Tr "settings.unlink-github-account" }} - - {{ else }} - - {{ .locale.Tr "settings.link-github-account" }} - - {{ end }} - {{ end }} - - {{ if .gitlabOauth }} - {{ if .userLogged.GitlabID }} - - {{ .locale.Tr "settings.unlink-gitlab-account" }} - - {{ else }} - - {{ .locale.Tr "settings.link-gitlab-account" }} - - {{ end }} - {{ end }} - - {{ if .giteaOauth }} - {{ if .userLogged.GiteaID }} - - {{ .locale.Tr "settings.unlink-gitea-account" }} - - {{ else }} - - {{ .locale.Tr "settings.link-gitea-account" }} - - {{ end }} - {{ end }} - {{ if .oidcOauth }} - {{ if .userLogged.OIDCID }} - - Unlink OpenID account - - {{ else }} - - Link OpenID account - - {{ end }} - {{ end }} -
-
-
- {{ end }} - - -
-
-

- {{ .locale.Tr "settings.delete-account" }} -

-
- - - {{ .csrfHtml }} -
-
-
+
- -{{ template "settings_footer" .}} -{{ template "footer" .}} +{{ end }} diff --git a/templates/pages/settings_authentication.html b/templates/pages/settings_authentication.html new file mode 100644 index 0000000..d8aaa37 --- /dev/null +++ b/templates/pages/settings_authentication.html @@ -0,0 +1,162 @@ +{{ define "content" }} +
+
+

{{ .locale.Tr "settings" }}

+

{{ .locale.Tr "settings.header.authentication" }}

+
+ + {{ if not .disableForm }} + +
+
+

{{ if .hasPassword }}{{ .locale.Tr "settings.change-password" }}{{ else }}{{ .locale.Tr "settings.create-password" }}{{ end }}

+

{{ if .hasPassword }}{{ .locale.Tr "settings.change-password-help" }}{{ else }}{{ .locale.Tr "settings.create-password-help" }}{{ end }}

+
+
+ {{ .csrfHtml }} + +
+ + +
+
+ +
+
+
+ {{ end }} + + +
+
+
+

+ {{ .locale.Tr "auth.totp" }} + {{ if .hasTotp }}{{ .locale.Tr "auth.totp.already-enabled" }}{{ end }} +

+

{{ .locale.Tr "auth.totp.help" }}

+
+
+ {{ if .hasTotp }} +
+ {{ .csrfHtml }} + + +
+
+ {{ .csrfHtml }} + +
+ {{ else }} + {{ .locale.Tr "auth.totp.use" }} + {{ end }} +
+
+
+ + +
+
+

{{ .locale.Tr "auth.mfa.passkeys" }}

+

{{ .locale.Tr "auth.mfa.passkeys-help" }}

+
+
+ {{ .csrfHtml }} +
+ + +
+
+ + +
+
+ + {{ if .passkeys }} +
    + {{ range $passkey := .passkeys }} +
  • + +
    +

    {{ .Name }}

    +

    {{ $.locale.Tr "auth.mfa.passkey-added-at" }} {{ .CreatedAt | humanDate }}

    + {{ if eq .LastUsedAt 0 }} +

    {{ $.locale.Tr "auth.mfa.passkey-never-used" }}

    + {{ else }} +

    {{ $.locale.Tr "auth.mfa.passkey-last-used" }} {{ .LastUsedAt | humanTimeDiff }}

    + {{ end }} +
    +
    + {{ $.csrfHtml }} + + +
    +
  • + {{ end }} +
+ {{ end }} +
+ + {{ if or .githubOauth .gitlabOauth .giteaOauth .oidcOauth }} + +
+
+

{{ .locale.Tr "settings.link-accounts" }}

+
+ +
+ {{ end }} +
+ + +{{ end }} diff --git a/templates/pages/settings_mfa.html b/templates/pages/settings_mfa.html deleted file mode 100644 index 6897b63..0000000 --- a/templates/pages/settings_mfa.html +++ /dev/null @@ -1,91 +0,0 @@ -{{ template "header" .}} -{{ template "settings_header" .}} -
-
-
-

- {{ .locale.Tr "auth.totp" }} -

-

- {{ .locale.Tr "auth.totp.help" }} -

- {{ if .hasTotp }} -
-
- - {{ .csrfHtml }} - -
-
- {{ .csrfHtml }} - -
-
- {{ else }} - {{ .locale.Tr "auth.totp.use" }} - {{ end }} -
-
- -
-
-
-

- {{ .locale.Tr "auth.mfa.passkeys" }} -

-

- {{ .locale.Tr "auth.mfa.passkeys-help" }} -

-
-
- -
- -
-
- {{ .csrfHtml }} - -
-
- -
-
-
-
-
-
    - {{ if .passkeys }} - {{ range $passkey := .passkeys }} -
  • -
    - - - -
    -

    {{ .Name }}

    -

    {{ $.locale.Tr "auth.mfa.passkey-added-at" }} {{ .CreatedAt | humanDate }}

    - {{ if eq .LastUsedAt 0 }} -

    {{ $.locale.Tr "auth.mfa.passkey-never-used" }}

    - {{ else }} -

    {{ $.locale.Tr "auth.mfa.passkey-last-used" }} {{ .LastUsedAt | humanTimeDiff }}

    - {{ end }} -
    -
    - - {{ $.csrfHtml }} - -
    -
    -
  • - {{ end }} - {{ end }} -
-
-
-
- - -
- -{{ template "settings_footer" .}} -{{ template "footer" .}} diff --git a/templates/pages/settings_ssh.html b/templates/pages/settings_ssh.html index 2017a65..1d39eaf 100644 --- a/templates/pages/settings_ssh.html +++ b/templates/pages/settings_ssh.html @@ -1,69 +1,65 @@ -{{ template "header" .}} -{{ template "settings_header" .}} -
-
-
-
-

- {{ .locale.Tr "settings.add-ssh-key" }} -

-

- {{ .locale.Tr "settings.add-ssh-key-help" }} -

-
-
- -
- -
-
+{{ define "content" }} +
+
+

{{ .locale.Tr "settings" }}

+

{{ .locale.Tr "settings.header.ssh" }}

+
-
- -
- -
-
- - {{ .csrfHtml }} - +
+ +
+
+

{{ .locale.Tr "settings.add-ssh-key" }}

+

{{ .locale.Tr "settings.add-ssh-key-help" }}

-
-
-
-
    - {{ if .sshKeys }} - {{ range $key := .sshKeys }} -
  • -
    - - - -
    -

    {{ .Title }}

    -

    SHA256:{{.SHA}}

    -

    {{ $.locale.Tr "settings.ssh-key-added-at" }} {{ .CreatedAt | humanDate }}

    - {{ if eq .LastUsedAt 0 }} -

    {{ $.locale.Tr "settings.ssh-key-never-used" }}

    - {{ else }} -

    {{ $.locale.Tr "settings.ssh-key-last-used" }} {{ .LastUsedAt | humanTimeDiff }}

    - {{ end }} -
    -
    - - {{ $.csrfHtml }} + + {{ .csrfHtml }} +
    + + +
    +
    + + +
    +
    + +
    +
    + - - -
    -
  • - {{ end }} - {{ end }} -
+ +
+ {{ if .sshKeys }} + {{ range $key := .sshKeys }} +
+
+ +
+

{{ .Title }}

+

SHA256:{{ .SHA }}

+

{{ $.locale.Tr "settings.ssh-key-added-at" }} {{ .CreatedAt | humanDate }}

+ {{ if eq .LastUsedAt 0 }} +

{{ $.locale.Tr "settings.ssh-key-never-used" }}

+ {{ else }} +

{{ $.locale.Tr "settings.ssh-key-last-used" }} {{ .LastUsedAt | humanTimeDiff }}

+ {{ end }} +
+
+ {{ $.csrfHtml }} + + +
+
+
+ {{ end }} + {{ else }} +
+ {{ .locale.Tr "settings.add-ssh-key-help" }}
+ {{ end }}
- -{{ template "settings_footer" .}} -{{ template "footer" .}} +{{ end }} diff --git a/templates/pages/settings_style.html b/templates/pages/settings_style.html index f4a445d..a29210e 100644 --- a/templates/pages/settings_style.html +++ b/templates/pages/settings_style.html @@ -1,154 +1,150 @@ -{{ template "header" .}} -{{ template "settings_header" .}} -
-
-
-

- {{ .locale.Tr "settings.style.theme" }} -

-
-
-
- - -
-
- - -
-
- - -
-
+{{ define "content" }} +
+
+

{{ .locale.Tr "settings" }}

+

{{ .locale.Tr "settings.header.style" }}

+
-

- {{ .locale.Tr "settings.style.gist-code" }} -

-
-
-
+ {{/* hx-boost="false": theme + diff colors are applied via the (theme + stylesheet, the `dark` class on , and the :root color vars). A boosted + submit only swaps the , leaving those stale until a hard refresh, so we + let this form do a full navigation — the redirect re-renders the whole head. */}} + + {{ .csrfHtml }} - - - - - file.txt - - - - - - - + +
+
+

{{ .locale.Tr "settings.style.theme" }}

+
+
+
+
+ + +
+
+ +
+
+
+ {{ range $c := .themeColors }} + + {{ end }} +
+
-
-
+
+
+ + +
+
+

{{ .locale.Tr "settings.style.gist-code" }}

+
+
+
+
+ + file.txt + · 95 B · Text +
+
- - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + +
1This is a string
2This is a really really really really really really really really long string
3
4- code removed
5- another pretty pretty pretty pretty pretty pretty long code removed
6+ code added
7+ added a line which help to demonstrate the difference between enabling and disabling soft wrap
8
1This is a string
2This is a really really really really really really really long string
3
4- code removed
5- another pretty pretty pretty pretty pretty pretty long code removed
6+ code added
7+ added a line which help to demonstrate the difference between enabling and disabling soft wrap
8
+ +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+
-
-
- - -
-
- - -
+ +
+
+

{{ .locale.Tr "settings.style.default-sort" }}

+
+
+
+
+ +
-
-
- - -
-
- - -
-
- - -
-
-

- {{ .locale.Tr "settings.style.default-sort" }} -

-
-
- - -
-
- - -
+
+

{{ .locale.Tr "settings.style.default-order" }}

+
+
+ +
+
+
+
-

- {{ .locale.Tr "settings.style.default-order" }} -

-
-
- - -
-
- - -
-
- - {{ .csrfHtml }} - - +
+
-
+
- - - -{{ template "settings_footer" .}} -{{ template "footer" .}} +{{ end }} diff --git a/templates/pages/settings_tokens.html b/templates/pages/settings_tokens.html index 3aee6a1..f540835 100644 --- a/templates/pages/settings_tokens.html +++ b/templates/pages/settings_tokens.html @@ -1,125 +1,102 @@ -{{ template "header" .}} -{{ template "settings_header" .}} -
+{{ define "content" }} +
+
+

{{ .locale.Tr "settings" }}

+

{{ .locale.Tr "settings.header.tokens" }}

+
+ {{ if not .apiEnabled }} -
- - - -
+ + {{ end }} + +
+ +
+
+

{{ .locale.Tr "settings.create-token" }}

+

{{ .locale.Tr "settings.create-token-help" }}

+
+
+ {{ .csrfHtml }} +
+ + +
+
+ + +
+
+ + +
+
+ +

{{ .locale.Tr "settings.token-expiration-help" }}

+ +
+
+ +
+
+
+ + +
+ {{ if .accessTokens }} + {{ range $token := .accessTokens }} +
+
+ +
+

{{ .Name }}

+
+ {{ $.locale.Tr "settings.token-gist-permission" }}: {{ if eq .ScopeGist 0 }}{{ $.locale.Tr "settings.token-permission-none" }}{{ else if eq .ScopeGist 1 }}{{ $.locale.Tr "settings.token-permission-read" }}{{ else }}{{ $.locale.Tr "settings.token-permission-read-write" }}{{ end }} + {{ $.locale.Tr "settings.token-user-permission" }}: {{ if eq .ScopeUser 0 }}{{ $.locale.Tr "settings.token-permission-none" }}{{ else if eq .ScopeUser 1 }}{{ $.locale.Tr "settings.token-permission-read" }}{{ else }}{{ $.locale.Tr "settings.token-permission-read-write" }}{{ end }} +
+

{{ $.locale.Tr "settings.token-created-at" }} {{ .CreatedAt | humanDate }}

+ {{ if eq .ExpiresAt 0 }} +

{{ $.locale.Tr "settings.token-no-expiration" }}

+ {{ else }} +

{{ $.locale.Tr "settings.token-expires-at" }} {{ .ExpiresAt | humanDateOnly }}{{ if .IsExpired }} ({{ $.locale.Tr "settings.token-expired" }}){{ end }}

+ {{ end }} + {{ if eq .LastUsedAt 0 }} +

{{ $.locale.Tr "settings.token-never-used" }}

+ {{ else }} +

{{ $.locale.Tr "settings.token-last-used" }} {{ .LastUsedAt | humanTimeDiff }}

+ {{ end }} +
+
+ {{ $.csrfHtml }} + + +
+
+
+ {{ end }} + {{ else }} +
+ {{ .locale.Tr "settings.create-token-help" }} +
{{ end }}
- {{ end }} -
-
-
-

- {{ .locale.Tr "settings.create-token" }} -

-

- {{ .locale.Tr "settings.create-token-help" }} -

-
-
- -
- -
-
- -
- -
- - -
-
- -
- -
- -
-
- -
- -

- {{ .locale.Tr "settings.token-expiration-help" }} -

-
- -
-
- - - {{ .csrfHtml }} -
-
-
-
-
-
    - {{ if .accessTokens }} - {{ range $token := .accessTokens }} -
  • -
    - - - -
    -

    {{ .Name }}

    -

    - {{ $.locale.Tr "settings.token-gist-permission" }}: - {{ if eq .ScopeGist 0 }}{{ $.locale.Tr "settings.token-permission-none" }}{{ end }} - {{ if eq .ScopeGist 1 }}{{ $.locale.Tr "settings.token-permission-read" }}{{ end }} - {{ if eq .ScopeGist 2 }}{{ $.locale.Tr "settings.token-permission-read-write" }}{{ end }} -

    -

    - {{ $.locale.Tr "settings.token-user-permission" }}: - {{ if eq .ScopeUser 0 }}{{ $.locale.Tr "settings.token-permission-none" }}{{ end }} - {{ if eq .ScopeUser 1 }}{{ $.locale.Tr "settings.token-permission-read" }}{{ end }} - {{ if eq .ScopeUser 2 }}{{ $.locale.Tr "settings.token-permission-read-write" }}{{ end }} -

    -

    {{ $.locale.Tr "settings.token-created-at" }} {{ .CreatedAt | humanDate }}

    - {{ if eq .ExpiresAt 0 }} -

    {{ $.locale.Tr "settings.token-no-expiration" }}

    - {{ else }} -

    {{ $.locale.Tr "settings.token-expires-at" }} {{ .ExpiresAt | humanDateOnly }}{{ if .IsExpired }} ({{ $.locale.Tr "settings.token-expired" }}){{ end }}

    - {{ end }} - {{ if eq .LastUsedAt 0 }} -

    {{ $.locale.Tr "settings.token-never-used" }}

    - {{ else }} -

    {{ $.locale.Tr "settings.token-last-used" }} {{ .LastUsedAt | humanTimeDiff }}

    - {{ end }} -
    -
    - - {{ $.csrfHtml }} - - -
    -
    -
  • - {{ end }} - {{ end }} -
-
-
-
- -{{ template "settings_footer" .}} -{{ template "footer" .}} +{{ end }} diff --git a/templates/pages/topics.html b/templates/pages/topics.html new file mode 100644 index 0000000..bbd6fdc --- /dev/null +++ b/templates/pages/topics.html @@ -0,0 +1,39 @@ +{{ define "content" }} +
+
+

{{ .locale.Tr "gist.list.topics" }}

+
+ + {{ if ne (len .topics) 0 }} +
+ + + + + + + + + {{ range .topics }} + + + + + {{ end }} + +
{{ .locale.Tr "gist.list.topic" }}{{ .locale.Tr "gist.list.gists" }}
+ + {{ slice .Topic 0 1 }} + {{ .Topic }} + + {{ .Count }}
+
+ {{ template "pagination" . }} + {{ else }} +
+ +

{{ .locale.Tr "gist.list.no-topics" }}

+
+ {{ end }} +
+{{ end }} diff --git a/templates/pages/totp.html b/templates/pages/totp.html index a27b0aa..403ee06 100644 --- a/templates/pages/totp.html +++ b/templates/pages/totp.html @@ -1,54 +1,43 @@ -{{ template "header" .}} - -
-
-
-

{{ .locale.Tr "auth.totp" }}

+{{ define "content" }} +
+
+
+
-
-
- {{ if .recoveryCodes }} -

{{ .locale.Tr "auth.totp.save-recovery-codes" }}

+

{{ .locale.Tr "auth.totp" }}

+
-
-
-
    - - {{ range .recoveryCodes }} -
  • {{ . }}
  • - {{ end }} -
    -
+ {{ if .recoveryCodes }} +
+

{{ .locale.Tr "auth.totp.save-recovery-codes" }}

+
    + {{ range .recoveryCodes }} +
  • {{ . }}
  • + {{ end }} +
+ {{ .locale.Tr "auth.totp.proceed" }} +
+ + {{ else }} +
+

{{ .locale.Tr "auth.totp.scan-qr-code" }}

+ +
+ TOTP QR code + {{ .totpSecret }} +
+ + + +
+ {{ .csrfHtml }} +
+ +
- -
- - - {{ else }} -

{{ .locale.Tr "auth.totp.scan-qr-code" }}

- -
-
-

{{.totpSecret}}

- {{.totpSecret}} -
-
- - {{ .csrfHtml }} - -
- -
- - -
-
- {{ end }} - + + +
+ {{ end }}
- - - -{{ template "footer" .}} +{{ end }} diff --git a/templates/partials/_editor.html b/templates/partials/_editor.html deleted file mode 100644 index ed0dbca..0000000 --- a/templates/partials/_editor.html +++ /dev/null @@ -1,46 +0,0 @@ -{{ define "_editor" }} -
-
-

- - -

- - {{ if not .Binary }} - - {{ end }} -
- {{ if not .Binary }} - - - {{ else }} -
- {{ $.locale.Tr "gist.file-binary-edit" }} -
- {{ end }} -
-{{ end }} diff --git a/templates/partials/_gist_preview.html b/templates/partials/_gist_preview.html deleted file mode 100644 index 3352926..0000000 --- a/templates/partials/_gist_preview.html +++ /dev/null @@ -1,124 +0,0 @@ -{{ define "_gist_preview" }} - - -
-
- -
-
-

- {{ .gist.User.Username }} / {{ .gist.Title }} -

-
-
- - - - {{ .gist.NbLikes }} {{ .locale.Tr "gist.list.likes" }} -
-
- - - - {{ .gist.NbForks }} {{ .locale.Tr "gist.list.forks" }} -
-
- - - - {{ .gist.NbFiles }} {{ .locale.Tr "gist.list.files" }} -
-
- -
-
{{ .locale.Tr "gist.list.last-active" }} {{ .gist.UpdatedAt | humanTimeDiff }} - {{ if .gist.Forked }} • {{ .locale.Tr "gist.list.forked-from" }} {{ .gist.Forked.User.Username }}/{{ .gist.Forked.Title }} {{ end }} - {{ if .gist.Private }} • {{ visibilityStr .gist.Private false }} {{ end }} - {{ if .gist.Archived }} • {{ .locale.Tr "gist.header.archived" }} {{ end }}
-
- {{ if len .gist.Description }} -
{{ .gist.Description }}
- {{ end }} - {{ if len .gist.TopicsSlice }} -
- {{ range .gist.TopicsSlice }} - {{ . }} - {{ end }} -
- {{ end }} -
-
-
- -
-
- {{ if .gist.PreviewFilename }} - {{ if .gist.PreviewMimeType }} - {{ if .gist.PreviewMimeType.IsSVG }} -
- {{ .gist.PreviewFilename }} -
- {{ else if .gist.PreviewMimeType.IsImage }} -
- {{ .gist.PreviewFilename }} -
- {{ else if .gist.PreviewMimeType.IsAudio }} -
- -
- {{ else if .gist.PreviewMimeType.IsVideo }} -
- -
- {{ else if .gist.PreviewMimeType.IsPDF }} -
- {{ else }} -

{{ .locale.Tr "gist.preview-non-available" }}

- {{ end }} - {{ else if .gist.Preview }} - {{ if isMarkdown .gist.PreviewFilename }} -
{{ .gist.HTML | safe }}
- {{ else if isMermaid .gist.PreviewFilename }} -
{{ .gist.HTML | safe }}
- {{ else }} - - - {{ $ii := "1" }} - {{ $i := toInt $ii }} - {{ range $line := .gist.Lines }} - - - - - - {{ $i = inc $i }} - {{ end }} - -
{{$i}}{{ $line | safe }}
- {{ end }} - {{ else }} -

{{ .locale.Tr "gist.preview-non-available" }}

- {{ end }} - {{ else }} -

{{ .locale.Tr "gist.no-content" }}

- {{ end }} -
-
-
-
- - -{{ end }} - diff --git a/templates/partials/_pagination.html b/templates/partials/_pagination.html deleted file mode 100644 index 7fdca96..0000000 --- a/templates/partials/_pagination.html +++ /dev/null @@ -1,31 +0,0 @@ -{{ define "_pagination" }} -
- {{ if .pagination.HasPrevious }} - - - - - - {{ .prevLabel }} - {{ else }} - - - - - {{ .prevLabel }} - {{ end }} - {{ if .pagination.HasNext }} - {{ .nextLabel }} - - - - - {{ else }} - {{ .nextLabel }} - - - - - {{ end }} -
-{{ end }} diff --git a/templates/partials/editor.html b/templates/partials/editor.html new file mode 100644 index 0000000..5335700 --- /dev/null +++ b/templates/partials/editor.html @@ -0,0 +1,54 @@ +{{ define "_editor" }} +
+
+
+ + +
+ + + + {{ if not .Binary }} + + {{ end }} +
+ + {{ if not .Binary }} + +
+ + + + +
+ + {{ else }} +

{{ $.locale.Tr "gist.file-binary-edit" }}

+ {{ end }} +
+{{ end }} diff --git a/templates/partials/gist.html b/templates/partials/gist.html new file mode 100644 index 0000000..713c926 --- /dev/null +++ b/templates/partials/gist.html @@ -0,0 +1,97 @@ +{{ define "gist" }} +
+
+ + {{ if not (shouldGenerateAvatar .gist.User .DisableGravatar) }} + {{ .gist.User.Username }} + {{ else }} + + {{ end }} + + +
+
+

+ {{ .gist.User.Username }} + / + {{ .gist.Title }} +

+ {{ if .gist.Archived }}{{ .locale.Tr "gist.header.archived" }}{{ end }} + {{ if .gist.Private }}{{ visibilityStr .gist.Private false }}{{ end }} +
+ +
+ + {{ .locale.Tr "gist.list.last-active" }} {{ .gist.UpdatedAt | humanTimeDiff }} + {{ if .gist.Forked }} • {{ .locale.Tr "gist.list.forked-from" }} + {{ .gist.Forked.User.Username }}/{{ .gist.Forked.Title }} + {{ end }} + + {{ range .gist.TopicsSlice }} + {{ . }} + {{ end }} +
+ + {{ if .gist.Description }}

{{ .gist.Description }}

{{ end }} +
+ +
+ + + {{ .gist.NbLikes }} + + + + {{ .gist.NbForks }} + + + + {{ .gist.NbFiles }} + +
+
+ + +
+ {{ if .gist.PreviewFilename }} + {{ if .gist.PreviewMimeType }} + {{ if or .gist.PreviewMimeType.IsSVG .gist.PreviewMimeType.IsImage }} +
{{ .gist.PreviewFilename }}
+ {{ else if .gist.PreviewMimeType.IsAudio }} +
+ {{ else if .gist.PreviewMimeType.IsVideo }} +
+ {{ else if .gist.PreviewMimeType.IsPDF }} +
+ {{ else }} +

{{ .locale.Tr "gist.preview-non-available" }}

+ {{ end }} + {{ else if .gist.Preview }} + {{ if or (isMarkdown .gist.PreviewFilename) (isMermaid .gist.PreviewFilename) }} +
{{ .gist.HTML | safe }}
+ {{ else }} +
+ + + {{ $i := 1 }} + {{ range $line := .gist.Lines }} + + + + + {{ $i = inc $i }} + {{ end }} + +
{{ $i }}{{ $line | safe }}
+
+ {{ end }} + {{ else }} +

{{ .locale.Tr "gist.preview-non-available" }}

+ {{ end }} + {{ else }} +

{{ .locale.Tr "gist.no-content" }}

+ {{ end }} +
+
+
+{{ end }} diff --git a/templates/pages/gist_embed.html b/templates/partials/gist_embed.html similarity index 98% rename from templates/pages/gist_embed.html rename to templates/partials/gist_embed.html index 8eb3156..5b4d853 100644 --- a/templates/pages/gist_embed.html +++ b/templates/partials/gist_embed.html @@ -1,5 +1,5 @@
-
+
{{ range $file := .files }}
diff --git a/templates/partials/gist_header.html b/templates/partials/gist_header.html new file mode 100644 index 0000000..c9a7811 --- /dev/null +++ b/templates/partials/gist_header.html @@ -0,0 +1,178 @@ +{{ define "gist_header" }} +
+
+
+ + {{ if not (shouldGenerateAvatar .gist.User .DisableGravatar) }} + {{ .gist.User.Username }} + {{ else }} + + {{ end }} + +
+

+ {{ .gist.User.Username }} + / + {{ .gist.Title }} +

+

{{ .locale.Tr "gist.header.last-active" }} {{ .gist.UpdatedAt | humanTimeDiff }}

+
+
+ + +
+ {{ if .userLogged }} +
+ {{ .csrfHtml }} + + {{ .gist.NbLikes }} +
+ + {{ if ne .userLogged.ID .gist.User.ID }} +
+ {{ .csrfHtml }} + + {{ .gist.NbForks }} +
+ {{ end }} + + {{ else }} + + + {{ .locale.Tr "gist.header.like" }} {{ .gist.NbLikes }} + + {{ end }} + + + + + {{ if and .userLogged (eq .gist.User.Username .userLogged.Username) }} + + + {{ end }} +
+
+ + +
+ {{ if .gist.Forked }}{{ .locale.Tr "gist.header.forked-from" }} {{ .gist.Forked.User.Username }}/{{ .gist.Forked.Title }}{{ end }} + {{ if .gist.Private }}{{ visibilityStr .gist.Private false }}{{ end }} + {{ if .gist.Archived }}{{ .locale.Tr "gist.header.archived" }}{{ end }} + {{ if .gist.ExpiresAt }}{{ .locale.Tr "gist.header.expires" }} {{ .gist.ExpiresAt | humanTimeDiff }}{{ end }} +
+ + {{ if .gist.Description }}

{{ .gist.Description }}

{{ end }} + + {{ if .gist.Topics }} +
+ {{ range .gist.Topics }} + {{ .Topic }} + {{ end }} +
+ {{ end }} +
+{{ end }} diff --git a/templates/partials/pagination.html b/templates/partials/pagination.html new file mode 100644 index 0000000..11a91de --- /dev/null +++ b/templates/partials/pagination.html @@ -0,0 +1,28 @@ +{{ define "pagination" }} +{{ if or .pagination.HasPrevious .pagination.HasNext }} + +{{ end }} +{{ end }} diff --git a/templates/base/seo_meta.html b/templates/partials/seo_meta.html similarity index 100% rename from templates/base/seo_meta.html rename to templates/partials/seo_meta.html diff --git a/templates/partials/sidebar.html b/templates/partials/sidebar.html new file mode 100644 index 0000000..ec722ed --- /dev/null +++ b/templates/partials/sidebar.html @@ -0,0 +1,261 @@ +{{ define "sidebar" }} + +{{ end }}