feat: SSR 抽奖帖发帖与开奖

compose 支持 lottery;详情抽奖卡可开奖并展示中奖名单。同步勾选已落地的 health/robots/sitemap/uploads/限流清单项。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-30 03:30:57 +08:00
parent b5aee606fb
commit e06c54870c
9 changed files with 176 additions and 13 deletions

View File

@@ -71,7 +71,7 @@
- [x] `question` 问答:可标记已解决 / 未解决 — SSR compose + 详情徽章与切换 - [x] `question` 问答:可标记已解决 / 未解决 — SSR compose + 详情徽章与切换
- [x] `poll` 投票210 选项;单选/多选;可选截止时间;投票;作者可结束 — SSR compose + 详情卡 - [x] `poll` 投票210 选项;单选/多选;可选截止时间;投票;作者可结束 — SSR compose + 详情卡
- [x] `bounty` 悬赏:发帖托管积分;采纳评论发奖;可退款(规则见 05— SSR compose + 详情条 + award/refund - [x] `bounty` 悬赏:发帖托管积分;采纳评论发奖;可退款(规则见 05— SSR compose + 详情条 + award/refund
- [ ] `lottery` 抽奖帖:设定中奖人数;从评论参与者开奖 - [x] `lottery` 抽奖帖:设定中奖人数;从评论参与者开奖 — SSR compose + 详情卡 + draw
### D.2 运营标记(管理员) ### D.2 运营标记(管理员)
@@ -196,10 +196,10 @@
## O. 基础设施 ## O. 基础设施
- [ ] `GET /health` - [x] `GET /health``routers/setup.go`
- [ ] `robots.txt` / `sitemap.xml` - [x] `robots.txt` / `sitemap.xml` — SSR 同源路径
- [ ] 静态上传文件可达 - [x] 静态上传文件可达`Static /uploads` → data/uploads
- [ ] 限流:发帖、评论、注册、登录、举报、私信、友链 - [x] 限流:发帖、注册、登录、举报、私信、友链 — SSR 写路径已挂 `RateLimiter`评论动作键服务层有、web 发评尚未挂,可后补)
--- ---

View File

@@ -28,7 +28,7 @@
| `/board/:id` | 板块 Feed | 已迁 | | `/board/:id` | 板块 Feed | 已迁 |
| `/boards` | 板块索引 | 已迁 | | `/boards` | 板块索引 | 已迁 |
| `/post/:id` | 帖详情 + 评论(回复/赞/私密)/赞/藏 | 已迁 | | `/post/:id` | 帖详情 + 评论(回复/赞/私密)/赞/藏 | 已迁 |
| `/compose` | 发帖normal / question / poll / bountytextarea + 图片 + 门控插入) | 已迁 | | `/compose` | 发帖normal / question / poll / bounty / lotterytextarea + 图片 + 门控插入) | 已迁 |
| `/post/:id/edit` | 编辑帖 | 已迁 | | `/post/:id/edit` | 编辑帖 | 已迁 |
| `/profile` | 个人中心(资料/密码/头像/积分钱包) | 已迁 | | `/profile` | 个人中心(资料/密码/头像/积分钱包) | 已迁 |
| `/user/:id` | 公开用户页 | 已迁 | | `/user/:id` | 公开用户页 | 已迁 |
@@ -118,7 +118,7 @@
| 问答 | 无额外字段(解决状态在详情)— SSR compose 可选;详情切换已迁 | | 问答 | 无额外字段(解决状态在详情)— SSR compose 可选;详情切换已迁 |
| 投票 | 选项列表、多选开关、最多可选、截止时间或无截止 | | 投票 | 选项列表、多选开关、最多可选、截止时间或无截止 |
| 悬赏 | 积分输入(显示余额)— SSR compose发帖托管 | | 悬赏 | 积分输入(显示余额)— SSR compose发帖托管 |
| 抽奖 | 中奖人数 120 | | 抽奖 | 中奖人数 120 — SSR compose |
### 3.2 编辑器能力(应对齐) ### 3.2 编辑器能力(应对齐)
@@ -147,7 +147,7 @@
| 问答状态 | 已解决/未解决徽章;作者或管理员 `POST /post/:id/question/resolve` 切换 — SSR | | 问答状态 | 已解决/未解决徽章;作者或管理员 `POST /post/:id/question/resolve` 切换 — SSR |
| 投票卡 | 选选项提交;显示百分比;作者可结束 — SSR `/post/:id/poll/vote|close` | | 投票卡 | 选选项提交;显示百分比;作者可结束 — SSR `/post/:id/poll/vote|close` |
| 悬赏条 | 显示积分与状态;采纳按钮在他人评论上;退款按钮按规则禁用并提示 — SSR `/post/:id/bounty/award|refund` | | 悬赏条 | 显示积分与状态;采纳按钮在他人评论上;退款按钮按规则禁用并提示 — SSR `/post/:id/bounty/award|refund` |
| 抽奖卡 | 显示参与人数;开奖;中奖名单 | | 抽奖卡 | 显示参与人数;开奖;中奖名单 — SSR `/post/:id/lottery/draw` |
| 评论 | 楼层列表、`reply_to` 引用、私密开关、点赞、举报、编辑(时限)、删除、回复/@ 通知 — SSR嵌套树 UI 未做 | | 评论 | 楼层列表、`reply_to` 引用、私密开关、点赞、举报、编辑(时限)、删除、回复/@ 通知 — SSR嵌套树 UI 未做 |
| 修订 | 列表 + 单条快照 — SSR `/post/:id/revisions`(作者/管理员;无 diff | | 修订 | 列表 + 单条快照 — SSR `/post/:id/revisions`(作者/管理员;无 diff |
| 图片 | Lightbox 查看 | | 图片 | Lightbox 查看 |

View File

@@ -512,6 +512,27 @@ body.j13-body {
background: var(--j13-surface, #fff); background: var(--j13-surface, #fff);
} }
.j13-bounty__title { margin: 0 0 0.35rem; font-size: 1.1rem; } .j13-bounty__title { margin: 0 0 0.35rem; font-size: 1.1rem; }
.j13-lottery-fields {
margin: 0.75rem 0 1rem;
padding: 0.75rem 1rem;
border: 1px solid var(--j13-border);
border-radius: var(--j13-radius);
}
.j13-lottery-fields legend { padding: 0 0.35rem; font-weight: 600; }
.j13-lottery {
margin: 1rem 0 1.25rem;
padding: 1rem;
border: 1px solid var(--j13-border);
border-radius: var(--j13-radius);
background: var(--j13-surface, #fff);
}
.j13-lottery__title { margin: 0 0 0.35rem; font-size: 1.1rem; }
.j13-lottery__winners {
list-style: none;
margin: 0.75rem 0 0;
padding: 0;
}
.j13-lottery__winners li { margin: 0.35rem 0; }
.j13-poll { .j13-poll {
margin: 1rem 0 1.25rem; margin: 1rem 0 1.25rem;
padding: 1rem; padding: 1rem;

View File

@@ -27,6 +27,7 @@ type composeData struct {
PollEndsAt string PollEndsAt string
PollOptions string PollOptions string
BountyPoints int BountyPoints int
LotteryWinners int
Boards []BoardView Boards []BoardView
TitleMax int TitleMax int
TagsMax int TagsMax int
@@ -46,6 +47,8 @@ func (d Deps) ComposeGet(c *gin.Context) {
BoardID: uint(boardID), BoardID: uint(boardID),
PostType: models.PostTypeNormal, PostType: models.PostTypeNormal,
PollMaxChoices: 1, PollMaxChoices: 1,
BountyPoints: 1,
LotteryWinners: 1,
}, false, 0) }, false, 0)
} }
@@ -73,9 +76,10 @@ func (d Deps) ComposePost(c *gin.Context) {
d.renderCompose(ctx, err.Error(), form, false, 0) d.renderCompose(ctx, err.Error(), form, false, 0)
return return
} }
if post.PostType == models.PostTypePoll || post.PostType == models.PostTypeBounty { if post.PostType == models.PostTypePoll || post.PostType == models.PostTypeBounty || post.PostType == models.PostTypeLottery {
var extras services.PostCreateExtras var extras services.PostCreateExtras
if post.PostType == models.PostTypePoll { switch post.PostType {
case models.PostTypePoll:
pollJSON, err := services.BuildPollOptionsJSON(form.PollMulti, form.PollMaxChoices, form.PollEndsAt, form.PollOptions) pollJSON, err := services.BuildPollOptionsJSON(form.PollMulti, form.PollMaxChoices, form.PollEndsAt, form.PollOptions)
if err != nil { if err != nil {
_ = d.Post.Delete(ctx.UserID(), post.ID, true) _ = d.Post.Delete(ctx.UserID(), post.ID, true)
@@ -83,8 +87,10 @@ func (d Deps) ComposePost(c *gin.Context) {
return return
} }
extras = services.ParsePostExtrasFromForm(pollJSON, "", "") extras = services.ParsePostExtrasFromForm(pollJSON, "", "")
} else { case models.PostTypeBounty:
extras = services.ParsePostExtrasFromForm("", strconv.Itoa(form.BountyPoints), "") extras = services.ParsePostExtrasFromForm("", strconv.Itoa(form.BountyPoints), "")
case models.PostTypeLottery:
extras = services.ParsePostExtrasFromForm("", "", strconv.Itoa(form.LotteryWinners))
} }
if err := services.FinalizeSpecialPostCreate(post, ctx.UserID(), extras); err != nil { if err := services.FinalizeSpecialPostCreate(post, ctx.UserID(), extras); err != nil {
_ = d.Post.Delete(ctx.UserID(), post.ID, true) _ = d.Post.Delete(ctx.UserID(), post.ID, true)
@@ -192,11 +198,12 @@ type composeForm struct {
PollEndsAt string PollEndsAt string
PollOptions string PollOptions string
BountyPoints int BountyPoints int
LotteryWinners int
} }
func normalizeComposePostType(t string) string { func normalizeComposePostType(t string) string {
switch t { switch t {
case models.PostTypePoll, models.PostTypeQuestion, models.PostTypeBounty: case models.PostTypePoll, models.PostTypeQuestion, models.PostTypeBounty, models.PostTypeLottery:
return t return t
default: default:
return models.PostTypeNormal return models.PostTypeNormal
@@ -210,6 +217,10 @@ func composeFormFrom(c *gin.Context) composeForm {
maxChoices = 1 maxChoices = 1
} }
bountyPoints, _ := strconv.Atoi(c.PostForm("bounty_points")) bountyPoints, _ := strconv.Atoi(c.PostForm("bounty_points"))
lotteryWinners, _ := strconv.Atoi(c.PostForm("lottery_winner_count"))
if lotteryWinners < 1 {
lotteryWinners = 1
}
return composeForm{ return composeForm{
BoardID: uint(bid), BoardID: uint(bid),
Title: strings.TrimSpace(c.PostForm("title")), Title: strings.TrimSpace(c.PostForm("title")),
@@ -221,6 +232,7 @@ func composeFormFrom(c *gin.Context) composeForm {
PollEndsAt: strings.TrimSpace(c.PostForm("poll_ends_at")), PollEndsAt: strings.TrimSpace(c.PostForm("poll_ends_at")),
PollOptions: c.PostForm("poll_options"), PollOptions: c.PostForm("poll_options"),
BountyPoints: bountyPoints, BountyPoints: bountyPoints,
LotteryWinners: lotteryWinners,
} }
} }
@@ -240,6 +252,9 @@ func (d Deps) renderCompose(ctx *webctx.Context, errMsg string, form composeForm
if form.BountyPoints < 1 { if form.BountyPoints < 1 {
form.BountyPoints = 1 form.BountyPoints = 1
} }
if form.LotteryWinners < 1 {
form.LotteryWinners = 1
}
chrome := d.chrome(ctx, title+" · "+d.Settings.SiteBranding().Name, "", "") chrome := d.chrome(ctx, title+" · "+d.Settings.SiteBranding().Name, "", "")
chrome.Error = errMsg chrome.Error = errMsg
chrome.ActiveBoard = form.BoardID chrome.ActiveBoard = form.BoardID
@@ -258,6 +273,7 @@ func (d Deps) renderCompose(ctx *webctx.Context, errMsg string, form composeForm
PollEndsAt: form.PollEndsAt, PollEndsAt: form.PollEndsAt,
PollOptions: form.PollOptions, PollOptions: form.PollOptions,
BountyPoints: form.BountyPoints, BountyPoints: form.BountyPoints,
LotteryWinners: form.LotteryWinners,
Boards: chrome.Boards, Boards: chrome.Boards,
TitleMax: d.Settings.PostTitleMax(), TitleMax: d.Settings.PostTitleMax(),
TagsMax: d.Settings.PostTagsMax(), TagsMax: d.Settings.PostTagsMax(),

View File

@@ -36,6 +36,7 @@ func Register(r *gin.Engine, deps Deps, authMW *auth.AuthMiddleware) {
g.POST("/post/:id/question/resolve", authMW.RequireAuth(), deps.PostQuestionResolvePost) g.POST("/post/:id/question/resolve", authMW.RequireAuth(), deps.PostQuestionResolvePost)
g.POST("/post/:id/bounty/award", authMW.RequireAuth(), deps.PostBountyAwardPost) g.POST("/post/:id/bounty/award", authMW.RequireAuth(), deps.PostBountyAwardPost)
g.POST("/post/:id/bounty/refund", authMW.RequireAuth(), deps.PostBountyRefundPost) g.POST("/post/:id/bounty/refund", authMW.RequireAuth(), deps.PostBountyRefundPost)
g.POST("/post/:id/lottery/draw", authMW.RequireAuth(), deps.PostLotteryDrawPost)
g.POST("/post/:id/unlock", authMW.RequireAuth(), deps.PostUnlock) g.POST("/post/:id/unlock", authMW.RequireAuth(), deps.PostUnlock)
g.POST("/post/:id/report", authMW.RequireAuth(), deps.PostReportPost) g.POST("/post/:id/report", authMW.RequireAuth(), deps.PostReportPost)
g.POST("/post/:id/comments/:cid/report", authMW.RequireAuth(), deps.CommentReportPost) g.POST("/post/:id/comments/:cid/report", authMW.RequireAuth(), deps.CommentReportPost)

View File

@@ -50,6 +50,7 @@ type PostPageData struct {
QuestionResolved bool QuestionResolved bool
CanToggleQuestion bool CanToggleQuestion bool
Bounty *postBountyView Bounty *postBountyView
Lottery *postLotteryView
Poll *postPollView Poll *postPollView
} }
@@ -62,6 +63,21 @@ type postBountyView struct {
AwardedCommentID uint AwardedCommentID uint
} }
type postLotteryWinnerView struct {
UserID uint
Name string
FloorHint string
}
type postLotteryView struct {
WinnerCount int
Status string
StatusLabel string
ParticipantCount int
CanDraw bool
Winners []postLotteryWinnerView
}
type postPollOptionView struct { type postPollOptionView struct {
ID uint ID uint
Text string Text string
@@ -219,6 +235,29 @@ func (d Deps) PostView(c *gin.Context) {
bountyView = bv bountyView = bv
} }
var lotteryView *postLotteryView
if post.PostType == models.PostTypeLottery {
if lv, err := services.GetPostLotteryView(post); err == nil && lv != nil {
isOperator := ctx.IsAdmin() || post.UserID == ctx.UserID()
winners := make([]postLotteryWinnerView, 0, len(lv.Winners))
for _, w := range lv.Winners {
name := strings.TrimSpace(w.Nickname)
if name == "" {
name = w.Username
}
winners = append(winners, postLotteryWinnerView{UserID: w.UserID, Name: name})
}
lotteryView = &postLotteryView{
WinnerCount: lv.WinnerCount,
Status: lv.Status,
StatusLabel: lotteryStatusLabel(lv.Status),
ParticipantCount: lv.ParticipantCount,
CanDraw: isOperator && lv.Status != models.PostLotteryStatusDrawn,
Winners: winners,
}
}
}
ctx.HTML(http.StatusOK, "post", PostPageData{ ctx.HTML(http.StatusOK, "post", PostPageData{
PageChrome: chrome, PostID: post.ID, PageChrome: chrome, PostID: post.ID,
PostPath: url.QueryEscape(fmt.Sprintf("/post/%d", post.ID)), PostPath: url.QueryEscape(fmt.Sprintf("/post/%d", post.ID)),
@@ -242,6 +281,7 @@ func (d Deps) PostView(c *gin.Context) {
QuestionResolved: post.QuestionResolved, QuestionResolved: post.QuestionResolved,
CanToggleQuestion: post.PostType == models.PostTypeQuestion && (ctx.IsAdmin() || post.UserID == ctx.UserID()), CanToggleQuestion: post.PostType == models.PostTypeQuestion && (ctx.IsAdmin() || post.UserID == ctx.UserID()),
Bounty: bountyView, Bounty: bountyView,
Lottery: lotteryView,
Poll: pollView, Poll: pollView,
}) })
} }
@@ -284,6 +324,17 @@ func contentStatusLabel(status string) string {
} }
} }
func lotteryStatusLabel(status string) string {
switch status {
case models.PostLotteryStatusDrawn:
return "已开奖"
case models.PostLotteryStatusOpen:
return "待开奖"
default:
return status
}
}
func bountyStatusLabel(status string) string { func bountyStatusLabel(status string) string {
switch status { switch status {
case models.BountyStatusOpen: case models.BountyStatusOpen:
@@ -596,6 +647,28 @@ func (d Deps) PostBountyRefundPost(c *gin.Context) {
ctx.Redirect(fmt.Sprintf("/post/%d", id)) ctx.Redirect(fmt.Sprintf("/post/%d", id))
} }
// PostLotteryDrawPost 抽奖帖开奖
func (d Deps) PostLotteryDrawPost(c *gin.Context) {
ctx := d.ctx(c)
id, err := parsePostID(c, d)
if err != nil || id == 0 {
d.render404(ctx)
return
}
if !ctx.CheckCSRF() {
ctx.SetFlash("无效请求,请重试")
ctx.Redirect(fmt.Sprintf("/post/%d", id))
return
}
if _, err := services.DrawPostLottery(id, ctx.UserID(), ctx.IsAdmin()); err != nil {
ctx.SetFlash(err.Error())
ctx.Redirect(fmt.Sprintf("/post/%d", id))
return
}
ctx.SetFlash("开奖完成")
ctx.Redirect(fmt.Sprintf("/post/%d", id))
}
type commentEditData struct { type commentEditData struct {
PageChrome PageChrome
PostID uint PostID uint

View File

@@ -16,7 +16,7 @@
</select> </select>
</label> </label>
{{if .IsEdit}} {{if .IsEdit}}
<p class="j13-muted">类型:{{if eq .PostType "poll"}}投票{{else if eq .PostType "question"}}问答{{else if eq .PostType "bounty"}}悬赏{{else}}普通讨论{{end}}(不可更改)</p> <p class="j13-muted">类型:{{if eq .PostType "poll"}}投票{{else if eq .PostType "question"}}问答{{else if eq .PostType "bounty"}}悬赏{{else if eq .PostType "lottery"}}抽奖{{else}}普通讨论{{end}}(不可更改)</p>
<input type="hidden" name="post_type" value="{{.PostType}}"/> <input type="hidden" name="post_type" value="{{.PostType}}"/>
{{else}} {{else}}
<label>类型 <label>类型
@@ -25,6 +25,7 @@
<option value="question"{{if eq .PostType "question"}} selected{{end}}>问答</option> <option value="question"{{if eq .PostType "question"}} selected{{end}}>问答</option>
<option value="poll"{{if eq .PostType "poll"}} selected{{end}}>投票</option> <option value="poll"{{if eq .PostType "poll"}} selected{{end}}>投票</option>
<option value="bounty"{{if eq .PostType "bounty"}} selected{{end}}>悬赏</option> <option value="bounty"{{if eq .PostType "bounty"}} selected{{end}}>悬赏</option>
<option value="lottery"{{if eq .PostType "lottery"}} selected{{end}}>抽奖</option>
</select> </select>
</label> </label>
<p class="j13-muted" id="compose-question-hint"{{if ne .PostType "question"}} hidden{{end}}>问答帖无额外字段;解决状态可在帖详情由作者或管理员切换。</p> <p class="j13-muted" id="compose-question-hint"{{if ne .PostType "question"}} hidden{{end}}>问答帖无额外字段;解决状态可在帖详情由作者或管理员切换。</p>
@@ -48,6 +49,13 @@
<input name="bounty_points" type="number" min="1" value="{{.BountyPoints}}"/> <input name="bounty_points" type="number" min="1" value="{{.BountyPoints}}"/>
</label> </label>
</fieldset> </fieldset>
<fieldset class="j13-lottery-fields" id="compose-lottery-fields"{{if ne .PostType "lottery"}} hidden{{end}}>
<legend>抽奖设置</legend>
<p class="j13-muted">参与者:已发布评论且非楼主(按用户去重)。人数足够后可由作者或管理员开奖。</p>
<label>中奖人数
<input name="lottery_winner_count" type="number" min="1" max="20" value="{{.LotteryWinners}}"/>
</label>
</fieldset>
{{end}} {{end}}
<label>标题 <label>标题
<input name="title" required maxlength="{{.TitleMax}}" value="{{.Title}}"/> <input name="title" required maxlength="{{.TitleMax}}" value="{{.Title}}"/>
@@ -79,11 +87,13 @@
var sel = document.getElementById('compose-post-type'); var sel = document.getElementById('compose-post-type');
var box = document.getElementById('compose-poll-fields'); var box = document.getElementById('compose-poll-fields');
var bounty = document.getElementById('compose-bounty-fields'); var bounty = document.getElementById('compose-bounty-fields');
var lottery = document.getElementById('compose-lottery-fields');
var qHint = document.getElementById('compose-question-hint'); var qHint = document.getElementById('compose-question-hint');
if (!sel) return; if (!sel) return;
function sync() { function sync() {
if (box) box.hidden = sel.value !== 'poll'; if (box) box.hidden = sel.value !== 'poll';
if (bounty) bounty.hidden = sel.value !== 'bounty'; if (bounty) bounty.hidden = sel.value !== 'bounty';
if (lottery) lottery.hidden = sel.value !== 'lottery';
if (qHint) qHint.hidden = sel.value !== 'question'; if (qHint) qHint.hidden = sel.value !== 'question';
} }
sel.addEventListener('change', sync); sel.addEventListener('change', sync);

View File

@@ -121,6 +121,27 @@
{{end}} {{end}}
</section> </section>
{{end}} {{end}}
{{if .Lottery}}
<section class="j13-lottery" aria-label="抽奖">
<h2 class="j13-lottery__title">抽奖</h2>
<p class="j13-muted">
中奖 {{.Lottery.WinnerCount}} 人 · 参与 {{.Lottery.ParticipantCount}} 人 · {{.Lottery.StatusLabel}}
</p>
{{if .Lottery.CanDraw}}
<form method="post" action="/post/{{.PostID}}/lottery/draw" class="j13-inline-form" onsubmit="return confirm('确认开奖?人数不足会失败。');">
<input type="hidden" name="_csrf" value="{{.CSRF}}"/>
<button type="submit">开奖</button>
</form>
{{end}}
{{if .Lottery.Winners}}
<ul class="j13-lottery__winners">
{{range .Lottery.Winners}}
<li>{{if .UserID}}<a href="/user/{{.UserID}}">{{.Name}}</a>{{else}}{{.Name}}{{end}}</li>
{{end}}
</ul>
{{end}}
</section>
{{end}}
{{if .Poll}} {{if .Poll}}
<section class="j13-poll" aria-label="投票"> <section class="j13-poll" aria-label="投票">
<h2 class="j13-poll__title">投票</h2> <h2 class="j13-poll__title">投票</h2>

View File

@@ -512,6 +512,27 @@ body.j13-body {
background: var(--j13-surface, #fff); background: var(--j13-surface, #fff);
} }
.j13-bounty__title { margin: 0 0 0.35rem; font-size: 1.1rem; } .j13-bounty__title { margin: 0 0 0.35rem; font-size: 1.1rem; }
.j13-lottery-fields {
margin: 0.75rem 0 1rem;
padding: 0.75rem 1rem;
border: 1px solid var(--j13-border);
border-radius: var(--j13-radius);
}
.j13-lottery-fields legend { padding: 0 0.35rem; font-weight: 600; }
.j13-lottery {
margin: 1rem 0 1.25rem;
padding: 1rem;
border: 1px solid var(--j13-border);
border-radius: var(--j13-radius);
background: var(--j13-surface, #fff);
}
.j13-lottery__title { margin: 0 0 0.35rem; font-size: 1.1rem; }
.j13-lottery__winners {
list-style: none;
margin: 0.75rem 0 0;
padding: 0;
}
.j13-lottery__winners li { margin: 0.35rem 0; }
.j13-poll { .j13-poll {
margin: 1rem 0 1.25rem; margin: 1rem 0 1.25rem;
padding: 1rem; padding: 1rem;