feat(blog): add filtering, sorting, and tags to explore page
All checks were successful
Build and Release / Create Release (push) Successful in 0s
Build and Release / Unit Tests (push) Successful in 3m20s
Build and Release / Integration Tests (PostgreSQL) (push) Successful in 5m23s
Build and Release / Lint (push) Successful in 5m40s
Build and Release / Build Binaries (amd64, linux, linux-latest) (push) Successful in 2m59s
Build and Release / Build Binaries (amd64, windows, windows-latest) (push) Successful in 8h4m50s
Build and Release / Build Binaries (amd64, darwin, macos) (push) Successful in 7m58s
Build and Release / Build Binaries (arm64, darwin, macos) (push) Successful in 8m36s
Build and Release / Build Binary (linux/arm64) (push) Successful in 10m51s
All checks were successful
Build and Release / Create Release (push) Successful in 0s
Build and Release / Unit Tests (push) Successful in 3m20s
Build and Release / Integration Tests (PostgreSQL) (push) Successful in 5m23s
Build and Release / Lint (push) Successful in 5m40s
Build and Release / Build Binaries (amd64, linux, linux-latest) (push) Successful in 2m59s
Build and Release / Build Binaries (amd64, windows, windows-latest) (push) Successful in 8h4m50s
Build and Release / Build Binaries (amd64, darwin, macos) (push) Successful in 7m58s
Build and Release / Build Binaries (arm64, darwin, macos) (push) Successful in 8m36s
Build and Release / Build Binary (linux/arm64) (push) Successful in 10m51s
Implements search by keyword (title/subtitle), tag filtering, and sort by newest/popular on explore blogs page. Adds GetExploreTopTags to show popular tags with usage counts. Enforces repository access permissions using AccessibleRepositoryCondition. Fixes secret lookup to skip scope conditions when querying by ID. Updates UI with tag cloud, search box, and sort dropdown.
This commit is contained in:
@@ -6,11 +6,16 @@ package blog
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"code.gitcaddy.com/server/v3/models/db"
|
||||
repo_model "code.gitcaddy.com/server/v3/models/repo"
|
||||
"code.gitcaddy.com/server/v3/models/unit"
|
||||
user_model "code.gitcaddy.com/server/v3/models/user"
|
||||
"code.gitcaddy.com/server/v3/modules/timeutil"
|
||||
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
// BlogPostStatus represents the publication state of a blog post.
|
||||
@@ -185,6 +190,124 @@ func GetPublishedBlogPosts(ctx context.Context, page, pageSize int) ([]*BlogPost
|
||||
return posts, count, err
|
||||
}
|
||||
|
||||
// ExploreBlogPostsOptions configures the explore blogs query.
|
||||
type ExploreBlogPostsOptions struct {
|
||||
Actor *user_model.User
|
||||
Page int
|
||||
PageSize int
|
||||
SortType string // "newest" (default) or "popular"
|
||||
Keyword string // search in title/subtitle
|
||||
Tag string // filter by tag (exact match within comma-separated tags)
|
||||
}
|
||||
|
||||
// exploreBlogBaseSess creates a base session with blog status and repo access conditions.
|
||||
func exploreBlogBaseSess(ctx context.Context, actor *user_model.User) *xorm.Session {
|
||||
repoCond := repo_model.AccessibleRepositoryCondition(actor, unit.TypeInvalid)
|
||||
return db.GetEngine(ctx).
|
||||
Table("blog_post").
|
||||
Join("INNER", "`repository`", "`blog_post`.repo_id = `repository`.id").
|
||||
Where("blog_post.status >= ?", BlogPostPublic).
|
||||
And(repoCond)
|
||||
}
|
||||
|
||||
// GetExploreBlogPosts returns published/public blog posts visible to the actor,
|
||||
// filtered by repository access permissions.
|
||||
func GetExploreBlogPosts(ctx context.Context, opts *ExploreBlogPostsOptions) ([]*BlogPost, int64, error) {
|
||||
if opts.PageSize <= 0 {
|
||||
opts.PageSize = 20
|
||||
}
|
||||
if opts.Page <= 0 {
|
||||
opts.Page = 1
|
||||
}
|
||||
|
||||
countSess := exploreBlogBaseSess(ctx, opts.Actor)
|
||||
findSess := exploreBlogBaseSess(ctx, opts.Actor)
|
||||
|
||||
if opts.Keyword != "" {
|
||||
kw := "%" + opts.Keyword + "%"
|
||||
countSess = countSess.And("(blog_post.title LIKE ? OR blog_post.subtitle LIKE ?)", kw, kw)
|
||||
findSess = findSess.And("(blog_post.title LIKE ? OR blog_post.subtitle LIKE ?)", kw, kw)
|
||||
}
|
||||
|
||||
if opts.Tag != "" {
|
||||
// Match tag within comma-separated list: exact match, or starts with, ends with, or in middle
|
||||
tagPattern := "%" + opts.Tag + "%"
|
||||
countSess = countSess.And("blog_post.tags LIKE ?", tagPattern)
|
||||
findSess = findSess.And("blog_post.tags LIKE ?", tagPattern)
|
||||
}
|
||||
|
||||
count, err := countSess.Count(new(BlogPost))
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
switch opts.SortType {
|
||||
case "popular":
|
||||
findSess = findSess.OrderBy(
|
||||
"(SELECT COUNT(*) FROM blog_reaction WHERE blog_reaction.blog_post_id = blog_post.id AND blog_reaction.is_like = ?) DESC, blog_post.published_unix DESC",
|
||||
true,
|
||||
)
|
||||
default: // "newest"
|
||||
findSess = findSess.OrderBy("blog_post.published_unix DESC")
|
||||
}
|
||||
|
||||
posts := make([]*BlogPost, 0, opts.PageSize)
|
||||
err = findSess.
|
||||
Limit(opts.PageSize, (opts.Page-1)*opts.PageSize).
|
||||
Find(&posts)
|
||||
return posts, count, err
|
||||
}
|
||||
|
||||
// TagCount represents a tag name and its usage count.
|
||||
type TagCount struct {
|
||||
Tag string
|
||||
Count int
|
||||
}
|
||||
|
||||
// GetExploreTopTags returns the top N tags across all accessible published blog posts.
|
||||
func GetExploreTopTags(ctx context.Context, actor *user_model.User, limit int) ([]*TagCount, error) {
|
||||
// Fetch all tags from accessible posts
|
||||
type tagRow struct {
|
||||
Tags string `xorm:"tags"`
|
||||
}
|
||||
var rows []tagRow
|
||||
err := exploreBlogBaseSess(ctx, actor).
|
||||
Cols("blog_post.tags").
|
||||
Where("blog_post.tags != ''").
|
||||
Find(&rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Aggregate tag counts
|
||||
counts := make(map[string]int)
|
||||
for _, r := range rows {
|
||||
for t := range strings.SplitSeq(r.Tags, ",") {
|
||||
t = strings.TrimSpace(t)
|
||||
if t != "" {
|
||||
counts[t]++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by count descending
|
||||
result := make([]*TagCount, 0, len(counts))
|
||||
for tag, c := range counts {
|
||||
result = append(result, &TagCount{Tag: tag, Count: c})
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
if result[i].Count != result[j].Count {
|
||||
return result[i].Count > result[j].Count
|
||||
}
|
||||
return result[i].Tag < result[j].Tag
|
||||
})
|
||||
|
||||
if limit > 0 && len(result) > limit {
|
||||
result = result[:limit]
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// CountPublishedBlogsByRepoID returns the count of published/public blog posts for a repo.
|
||||
func CountPublishedBlogsByRepoID(ctx context.Context, repoID int64) (int64, error) {
|
||||
return db.GetEngine(ctx).Where("repo_id = ? AND status >= ?", repoID, BlogPostPublic).Count(new(BlogPost))
|
||||
|
||||
@@ -109,6 +109,12 @@ type FindSecretsOptions struct {
|
||||
func (opts FindSecretsOptions) ToConds() builder.Cond {
|
||||
cond := builder.NewCond()
|
||||
|
||||
if opts.SecretID != 0 {
|
||||
// When looking up by ID, skip scope conditions
|
||||
cond = cond.And(builder.Eq{"id": opts.SecretID})
|
||||
return cond
|
||||
}
|
||||
|
||||
if opts.Global {
|
||||
// Global secrets have both OwnerID=0 and RepoID=0
|
||||
cond = cond.And(builder.Eq{"owner_id": 0})
|
||||
@@ -123,9 +129,6 @@ func (opts FindSecretsOptions) ToConds() builder.Cond {
|
||||
}
|
||||
}
|
||||
|
||||
if opts.SecretID != 0 {
|
||||
cond = cond.And(builder.Eq{"id": opts.SecretID})
|
||||
}
|
||||
if opts.Name != "" {
|
||||
cond = cond.And(builder.Eq{"name": strings.ToUpper(opts.Name)})
|
||||
}
|
||||
|
||||
@@ -364,6 +364,14 @@
|
||||
"explore.organizations": "Organizations",
|
||||
"explore.packages": "Packages",
|
||||
"explore.blogs": "Blogs",
|
||||
"explore.blogs.sort_newest": "Newest",
|
||||
"explore.blogs.sort_popular": "Most Popular",
|
||||
"explore.blogs.all_posts": "All Posts",
|
||||
"explore.blogs.filtered_by_tag": "Tag: %s",
|
||||
"explore.blogs.clear_filter": "Clear",
|
||||
"explore.blogs.search_results": "Results for \"%s\"",
|
||||
"explore.blogs.search_placeholder": "Search articles...",
|
||||
"explore.blogs.top_tags": "Popular Tags",
|
||||
"explore.packages.empty.description": "No public or global packages are available yet.",
|
||||
"explore.go_to": "Go to",
|
||||
"explore.code": "Code",
|
||||
|
||||
@@ -34,36 +34,60 @@ func Blogs(ctx *context.Context) {
|
||||
page := max(ctx.FormInt("page"), 1)
|
||||
pageSize := setting.UI.IssuePagingNum
|
||||
|
||||
posts, total, err := blog_model.GetPublishedBlogPosts(ctx, page, pageSize)
|
||||
sortType := ctx.FormString("sort")
|
||||
if sortType != "popular" {
|
||||
sortType = "newest"
|
||||
}
|
||||
ctx.Data["SortType"] = sortType
|
||||
|
||||
keyword := ctx.FormTrim("q")
|
||||
ctx.Data["Keyword"] = keyword
|
||||
|
||||
tag := ctx.FormTrim("tag")
|
||||
ctx.Data["Tag"] = tag
|
||||
|
||||
posts, total, err := blog_model.GetExploreBlogPosts(ctx, &blog_model.ExploreBlogPostsOptions{
|
||||
Actor: ctx.Doer,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
SortType: sortType,
|
||||
Keyword: keyword,
|
||||
Tag: tag,
|
||||
})
|
||||
if err != nil {
|
||||
ctx.ServerError("GetPublishedBlogPosts", err)
|
||||
ctx.ServerError("GetExploreBlogPosts", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Load authors, repos, and featured images
|
||||
for _, post := range posts {
|
||||
if err := post.LoadAuthor(ctx); err != nil {
|
||||
ctx.ServerError("LoadAuthor", err)
|
||||
return
|
||||
}
|
||||
if err := post.LoadRepo(ctx); err != nil {
|
||||
ctx.ServerError("LoadRepo", err)
|
||||
return
|
||||
}
|
||||
if err := post.LoadFeaturedImage(ctx); err != nil {
|
||||
ctx.ServerError("LoadFeaturedImage", err)
|
||||
return
|
||||
}
|
||||
_ = post.LoadAuthor(ctx)
|
||||
_ = post.LoadRepo(ctx)
|
||||
_ = post.LoadFeaturedImage(ctx)
|
||||
}
|
||||
|
||||
// Separate featured post (most recent) from the rest
|
||||
if len(posts) > 0 {
|
||||
// Only show featured post on page 1 with no search/tag filter
|
||||
showFeatured := page == 1 && keyword == "" && tag == ""
|
||||
if showFeatured && len(posts) > 0 {
|
||||
if counts, err := blog_model.GetBlogReactionCounts(ctx, posts[0].ID); err == nil {
|
||||
ctx.Data["FeaturedLikes"] = counts.Likes
|
||||
}
|
||||
ctx.Data["FeaturedPost"] = posts[0]
|
||||
if len(posts) > 1 {
|
||||
ctx.Data["Posts"] = posts[1:]
|
||||
}
|
||||
} else {
|
||||
ctx.Data["Posts"] = posts
|
||||
}
|
||||
|
||||
// Load top tags for sidebar
|
||||
topTags, err := blog_model.GetExploreTopTags(ctx, ctx.Doer, 10)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetExploreTopTags", err)
|
||||
return
|
||||
}
|
||||
ctx.Data["TopTags"] = topTags
|
||||
|
||||
ctx.Data["Total"] = total
|
||||
|
||||
pager := context.NewPagination(int(total), pageSize, page, 5)
|
||||
|
||||
@@ -18,64 +18,128 @@
|
||||
<div class="blog-featured-meta">
|
||||
{{if .FeaturedPost.Author}}
|
||||
<img class="blog-avatar" src="{{.FeaturedPost.Author.AvatarLink ctx}}" alt="{{.FeaturedPost.Author.Name}}">
|
||||
<span class="blog-author">{{.FeaturedPost.Author.DisplayName}}</span>
|
||||
<span class="blog-meta-sep">·</span>
|
||||
{{end}}
|
||||
{{if .FeaturedPost.Repo}}
|
||||
<a href="{{.FeaturedPost.Repo.Link}}" class="blog-repo-link">{{.FeaturedPost.Repo.FullName}}</a>
|
||||
<span class="blog-featured-repo">{{if .FeaturedPost.Repo.DisplayTitle}}{{.FeaturedPost.Repo.DisplayTitle}}{{else}}{{.FeaturedPost.Repo.Name}}{{end}}</span>
|
||||
<span class="blog-meta-sep">·</span>
|
||||
{{end}}
|
||||
{{if .FeaturedPost.Author}}
|
||||
<span>{{.FeaturedPost.Author.DisplayName}}</span>
|
||||
<span class="blog-meta-sep">·</span>
|
||||
{{end}}
|
||||
{{if .FeaturedPost.PublishedUnix}}
|
||||
<span class="blog-date">{{DateUtils.TimeSince .FeaturedPost.PublishedUnix}}</span>
|
||||
{{end}}
|
||||
{{if .FeaturedLikes}}
|
||||
<span class="blog-meta-sep">·</span>
|
||||
<span>{{svg "octicon-thumbsup" 14}} {{.FeaturedLikes}}</span>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Posts}}
|
||||
<div class="blog-grid">
|
||||
{{range .Posts}}
|
||||
<a href="{{.Repo.Link}}/blog/{{.ID}}" class="blog-tile">
|
||||
{{if .FeaturedImage}}
|
||||
<div class="blog-tile-image">
|
||||
<img src="{{.FeaturedImage.DownloadURL}}" alt="{{.Title}}" loading="lazy">
|
||||
<!-- Split pane: articles left, sidebar right -->
|
||||
<div class="blog-explore-split">
|
||||
<!-- Main content pane -->
|
||||
<div class="blog-explore-main">
|
||||
<!-- Sort controls -->
|
||||
<div class="tw-flex tw-justify-between tw-items-center tw-mb-4">
|
||||
<h3 class="tw-m-0">
|
||||
{{if .Tag}}
|
||||
{{ctx.Locale.Tr "explore.blogs.filtered_by_tag" .Tag}}
|
||||
<a href="?sort={{.SortType}}{{if .Keyword}}&q={{.Keyword}}{{end}}" class="ui small basic button tw-ml-2">{{ctx.Locale.Tr "explore.blogs.clear_filter"}}</a>
|
||||
{{else if .Keyword}}
|
||||
{{ctx.Locale.Tr "explore.blogs.search_results" .Keyword}}
|
||||
{{else}}
|
||||
{{ctx.Locale.Tr "explore.blogs.all_posts"}}
|
||||
{{end}}
|
||||
</h3>
|
||||
<div class="ui small compact menu">
|
||||
<a class="item{{if eq .SortType "newest"}} active{{end}}" href="?sort=newest{{if .Keyword}}&q={{.Keyword}}{{end}}{{if .Tag}}&tag={{.Tag}}{{end}}">
|
||||
{{svg "octicon-clock" 14}} {{ctx.Locale.Tr "explore.blogs.sort_newest"}}
|
||||
</a>
|
||||
<a class="item{{if eq .SortType "popular"}} active{{end}}" href="?sort=popular{{if .Keyword}}&q={{.Keyword}}{{end}}{{if .Tag}}&tag={{.Tag}}{{end}}">
|
||||
{{svg "octicon-thumbsup" 14}} {{ctx.Locale.Tr "explore.blogs.sort_popular"}}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .Posts}}
|
||||
<div class="blog-post-list">
|
||||
{{range .Posts}}
|
||||
<div class="blog-list-item">
|
||||
<a href="{{.Repo.Link}}/blog/{{.ID}}" class="blog-list-item-link">
|
||||
{{if .FeaturedImage}}
|
||||
<div class="blog-list-item-image">
|
||||
<img src="{{.FeaturedImage.DownloadURL}}" alt="{{.Title}}" loading="lazy">
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="blog-list-item-content">
|
||||
<h3 class="blog-list-item-title">{{.Title}}</h3>
|
||||
{{if .Subtitle}}
|
||||
<p class="blog-list-item-subtitle">{{.Subtitle}}</p>
|
||||
{{end}}
|
||||
</div>
|
||||
</a>
|
||||
<div class="blog-list-item-footer">
|
||||
{{if .Author}}
|
||||
<a href="{{.Author.HomeLink}}" class="blog-list-item-author">
|
||||
<img class="blog-avatar-sm" src="{{.Author.AvatarLink ctx}}" alt="{{.Author.Name}}">
|
||||
<span>{{.Author.DisplayName}}</span>
|
||||
</a>
|
||||
{{end}}
|
||||
{{if .Repo}}
|
||||
<span class="blog-meta-sep">·</span>
|
||||
<a href="{{.Repo.Link}}" class="blog-list-item-repo">{{if .Repo.DisplayTitle}}{{.Repo.DisplayTitle}}{{else}}{{.Repo.FullName}}{{end}}</a>
|
||||
{{end}}
|
||||
<span class="blog-meta-sep">·</span>
|
||||
{{if .PublishedUnix}}
|
||||
<span>{{DateUtils.TimeSince .PublishedUnix}}</span>
|
||||
{{else}}
|
||||
<span>{{DateUtils.TimeSince .CreatedUnix}}</span>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="blog-tile-image blog-tile-image-placeholder">
|
||||
{{svg "octicon-note" 32}}
|
||||
<div class="empty-placeholder">
|
||||
{{svg "octicon-note" 48}}
|
||||
<h2>{{ctx.Locale.Tr "repo.blog.no_posts"}}</h2>
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="blog-tile-content">
|
||||
<h3 class="blog-tile-title">{{.Title}}</h3>
|
||||
{{if .Subtitle}}
|
||||
<p class="blog-tile-subtitle">{{.Subtitle}}</p>
|
||||
{{end}}
|
||||
<div class="blog-tile-meta">
|
||||
{{if .Author}}
|
||||
<img class="blog-avatar-sm" src="{{.Author.AvatarLink ctx}}" alt="{{.Author.Name}}">
|
||||
<span>{{.Author.DisplayName}}</span>
|
||||
<span class="blog-meta-sep">·</span>
|
||||
{{end}}
|
||||
{{if .Repo}}
|
||||
<span>{{.Repo.FullName}}</span>
|
||||
<span class="blog-meta-sep">·</span>
|
||||
{{end}}
|
||||
<span>{{DateUtils.TimeSince .PublishedUnix}}</span>
|
||||
|
||||
{{template "base/paginate" .}}
|
||||
</div>
|
||||
|
||||
<!-- Right sidebar -->
|
||||
<div class="blog-explore-sidebar">
|
||||
<!-- Search box -->
|
||||
<form method="get" action="" class="blog-sidebar-search">
|
||||
<div class="ui small fluid action input">
|
||||
<input type="text" name="q" value="{{.Keyword}}" placeholder="{{ctx.Locale.Tr "explore.blogs.search_placeholder"}}">
|
||||
<input type="hidden" name="sort" value="{{.SortType}}">
|
||||
{{if .Tag}}<input type="hidden" name="tag" value="{{.Tag}}">{{end}}
|
||||
<button class="ui small icon button" type="submit">{{svg "octicon-search" 16}}</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Top tags -->
|
||||
{{if .TopTags}}
|
||||
<h4 class="blog-sidebar-heading">{{ctx.Locale.Tr "explore.blogs.top_tags"}}</h4>
|
||||
<div class="blog-tag-list">
|
||||
{{range .TopTags}}
|
||||
<a href="?tag={{.Tag}}&sort={{$.SortType}}{{if $.Keyword}}&q={{$.Keyword}}{{end}}" class="blog-tag-tile{{if eq .Tag $.Tag}} active{{end}}">
|
||||
<span class="blog-tag-name">{{.Tag}}</span>
|
||||
<span class="blog-tag-count">{{.Count}}</span>
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</a>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if and (not .FeaturedPost) (not .Posts)}}
|
||||
<div class="empty-placeholder">
|
||||
{{svg "octicon-note" 48}}
|
||||
<h2>{{ctx.Locale.Tr "repo.blog.no_posts"}}</h2>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{template "base/paginate" .}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -86,7 +150,7 @@
|
||||
.blog-featured-link {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
padding: 20px;
|
||||
padding: 24px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--color-secondary-alpha-40);
|
||||
background: var(--color-box-body);
|
||||
@@ -102,8 +166,8 @@
|
||||
}
|
||||
.blog-featured-image {
|
||||
flex-shrink: 0;
|
||||
width: 320px;
|
||||
height: 200px;
|
||||
width: 360px;
|
||||
height: 220px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: var(--color-secondary-alpha-20);
|
||||
@@ -121,18 +185,18 @@
|
||||
min-width: 0;
|
||||
}
|
||||
.blog-featured-title {
|
||||
font-size: 24px;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.blog-featured-subtitle {
|
||||
font-size: 15px;
|
||||
font-size: 16px;
|
||||
color: var(--color-text-light);
|
||||
margin: 0 0 16px;
|
||||
line-height: 1.5;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -140,9 +204,13 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
font-size: 14px;
|
||||
color: var(--color-text-light);
|
||||
}
|
||||
.blog-featured-repo {
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.blog-avatar {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
@@ -153,90 +221,182 @@
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.blog-repo-link {
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
.blog-repo-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.blog-meta-sep {
|
||||
color: var(--color-text-light-3);
|
||||
}
|
||||
.blog-date {
|
||||
color: var(--color-text-light);
|
||||
}
|
||||
.blog-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 24px;
|
||||
|
||||
/* Split pane layout */
|
||||
.blog-explore-split {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.blog-tile {
|
||||
.blog-explore-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.blog-explore-sidebar {
|
||||
width: 280px;
|
||||
flex-shrink: 0;
|
||||
position: sticky;
|
||||
top: 16px;
|
||||
}
|
||||
|
||||
/* Article list */
|
||||
.blog-post-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.blog-list-item {
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--color-secondary-alpha-40);
|
||||
background: var(--color-box-body);
|
||||
color: var(--color-text);
|
||||
text-decoration: none;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.15s, box-shadow 0.15s, transform 0.15s;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.blog-tile:hover {
|
||||
.blog-list-item:hover {
|
||||
border-color: var(--color-primary-alpha-60);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
.blog-list-item-link {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
color: var(--color-text);
|
||||
text-decoration: none;
|
||||
}
|
||||
.blog-tile-image {
|
||||
width: 100%;
|
||||
height: 180px;
|
||||
.blog-list-item-link:hover {
|
||||
color: var(--color-text);
|
||||
text-decoration: none;
|
||||
}
|
||||
.blog-list-item-image {
|
||||
flex-shrink: 0;
|
||||
width: 140px;
|
||||
height: 90px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: var(--color-secondary-alpha-20);
|
||||
}
|
||||
.blog-tile-image img {
|
||||
.blog-list-item-image img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.blog-tile-image-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-text-light-3);
|
||||
}
|
||||
.blog-tile-content {
|
||||
padding: 16px;
|
||||
.blog-list-item-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
.blog-tile-title {
|
||||
.blog-list-item-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
.blog-tile-subtitle {
|
||||
.blog-list-item-subtitle {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-light);
|
||||
margin: 0 0 12px;
|
||||
margin: 0;
|
||||
line-height: 1.4;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.blog-tile-meta {
|
||||
.blog-list-item-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-light);
|
||||
margin-top: auto;
|
||||
padding: 0 16px 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.blog-list-item-author {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--color-text-light);
|
||||
text-decoration: none;
|
||||
}
|
||||
.blog-list-item-author:hover {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.blog-list-item-repo {
|
||||
color: var(--color-text-light);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
.blog-list-item-repo:hover {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
.blog-sidebar-search {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.blog-sidebar-heading {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 10px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.blog-tag-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.blog-tag-tile {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--color-secondary-alpha-40);
|
||||
background: var(--color-box-body);
|
||||
color: var(--color-text);
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
.blog-tag-tile:hover {
|
||||
border-color: var(--color-primary-alpha-60);
|
||||
background: var(--color-primary-alpha-10);
|
||||
color: var(--color-text);
|
||||
text-decoration: none;
|
||||
}
|
||||
.blog-tag-tile.active {
|
||||
border-color: var(--color-primary);
|
||||
background: var(--color-primary-alpha-10);
|
||||
font-weight: 600;
|
||||
}
|
||||
.blog-tag-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.blog-tag-count {
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-light);
|
||||
background: var(--color-secondary-alpha-20);
|
||||
padding: 1px 8px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.empty-placeholder {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: var(--color-text-light);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.blog-featured-link {
|
||||
flex-direction: column;
|
||||
@@ -245,8 +405,16 @@
|
||||
width: 100%;
|
||||
height: 180px;
|
||||
}
|
||||
.blog-grid {
|
||||
grid-template-columns: 1fr;
|
||||
.blog-explore-split {
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
.blog-explore-sidebar {
|
||||
width: 100%;
|
||||
position: static;
|
||||
}
|
||||
.blog-list-item-image {
|
||||
width: 100px;
|
||||
height: 70px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user