diff --git a/cwd-api/src/api/admin/likeStats.ts b/cwd-api/src/api/admin/likeStats.ts new file mode 100644 index 0000000..c34c613 --- /dev/null +++ b/cwd-api/src/api/admin/likeStats.ts @@ -0,0 +1,37 @@ +import type { Context } from 'hono'; +import type { Bindings } from '../../bindings'; + +type LikeStatsItem = { + page_slug: string; + page_title: string | null; + page_url: string | null; + likes: number; +}; + +export const getLikeStats = async (c: Context<{ Bindings: Bindings }>) => { + try { + await c.env.CWD_DB.prepare( + 'CREATE TABLE IF NOT EXISTS Likes (id INTEGER PRIMARY KEY AUTOINCREMENT, page_slug TEXT NOT NULL, user_id TEXT NOT NULL, created_at INTEGER NOT NULL, UNIQUE(page_slug, user_id))' + ).run(); + + await c.env.CWD_DB.prepare( + 'CREATE TABLE IF NOT EXISTS page_stats (id INTEGER PRIMARY KEY AUTOINCREMENT, post_slug TEXT UNIQUE NOT NULL, post_title TEXT, post_url TEXT, pv INTEGER NOT NULL DEFAULT 0, last_visit_at INTEGER, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL)' + ).run(); + + const { results } = await c.env.CWD_DB.prepare( + 'SELECT l.page_slug, COALESCE(p.post_title, NULL) AS page_title, COALESCE(p.post_url, NULL) AS page_url, COUNT(*) AS likes FROM Likes l LEFT JOIN page_stats p ON p.post_slug = l.page_slug GROUP BY l.page_slug, p.post_title, p.post_url ORDER BY likes DESC LIMIT 50' + ).all(); + + const items = results.map((row) => ({ + pageSlug: row.page_slug, + pageTitle: row.page_title, + pageUrl: row.page_url, + likes: row.likes + })); + + return c.json({ items }); + } catch (e: any) { + return c.json({ message: e.message || '获取点赞统计失败' }, 500); + } +}; + diff --git a/cwd-api/src/api/admin/listLikes.ts b/cwd-api/src/api/admin/listLikes.ts new file mode 100644 index 0000000..8b792ed --- /dev/null +++ b/cwd-api/src/api/admin/listLikes.ts @@ -0,0 +1,95 @@ +import type { Context } from 'hono'; +import type { Bindings } from '../../bindings'; + +type LikeItem = { + id: number; + page_slug: string; + user_id: string; + created_at: number; +}; + +export const listLikes = async (c: Context<{ Bindings: Bindings }>) => { + try { + const page = parseInt(c.req.query('page') || '1', 10) || 1; + const limit = 20; + const offset = (page - 1) * limit; + + const rawPageSlug = c.req.query('page_slug') || c.req.query('pageSlug') || ''; + const pageSlug = rawPageSlug.trim(); + + const rawUserId = c.req.query('user_id') || c.req.query('userId') || ''; + const userId = rawUserId.trim(); + + const rawStart = c.req.query('start') || ''; + const rawEnd = c.req.query('end') || ''; + + const whereSql: string[] = []; + const params: (string | number)[] = []; + + if (pageSlug) { + whereSql.push('page_slug = ?'); + params.push(pageSlug); + } + + if (userId) { + whereSql.push('user_id = ?'); + params.push(userId); + } + + if (rawStart) { + const startTs = Number(rawStart); + if (Number.isFinite(startTs)) { + whereSql.push('created_at >= ?'); + params.push(startTs); + } + } + + if (rawEnd) { + const endTs = Number(rawEnd); + if (Number.isFinite(endTs)) { + whereSql.push('created_at <= ?'); + params.push(endTs); + } + } + + const whereClause = whereSql.length ? `WHERE ${whereSql.join(' AND ')}` : ''; + + await c.env.CWD_DB.prepare( + 'CREATE TABLE IF NOT EXISTS Likes (id INTEGER PRIMARY KEY AUTOINCREMENT, page_slug TEXT NOT NULL, user_id TEXT NOT NULL, created_at INTEGER NOT NULL, UNIQUE(page_slug, user_id))' + ).run(); + + const totalRow = await c.env.CWD_DB.prepare( + `SELECT COUNT(*) AS count FROM Likes ${whereClause}` + ) + .bind(...params) + .first<{ count: number }>(); + + const { results } = await c.env.CWD_DB.prepare( + `SELECT id, page_slug, user_id, created_at FROM Likes ${whereClause} ORDER BY created_at DESC LIMIT ? OFFSET ?` + ) + .bind(...params, limit, offset) + .all(); + + const data = results.map((row) => ({ + id: row.id, + pageSlug: row.page_slug, + userId: row.user_id, + createdAt: row.created_at + })); + + const totalCount = totalRow?.count || 0; + const totalPages = Math.max(1, Math.ceil(totalCount / limit)); + + return c.json({ + data, + pagination: { + page, + limit, + total: totalPages + } + }); + } catch (e: any) { + return c.json({ message: e.message || '获取点赞记录失败' }, 500); + } +}; + diff --git a/cwd-api/src/api/public/like.ts b/cwd-api/src/api/public/like.ts new file mode 100644 index 0000000..899d83f --- /dev/null +++ b/cwd-api/src/api/public/like.ts @@ -0,0 +1,196 @@ +import type { Context } from 'hono'; +import type { Bindings } from '../../bindings'; + +type LikeStatusResponse = { + liked: boolean; + alreadyLiked: boolean; + totalLikes: number; +}; + +type LikeRequestBody = { + postSlug?: string; + postTitle?: string; + postUrl?: string; +}; + +function getUserIdFromRequest(c: Context<{ Bindings: Bindings }>): string { + const header = + c.req.header('X-CWD-Like-User') || + c.req.header('x-cwd-like-user') || + ''; + const fromHeader = header.trim(); + if (fromHeader) { + return fromHeader; + } + const ip = c.req.header('cf-connecting-ip') || ''; + const trimmedIp = ip.trim(); + if (trimmedIp) { + return `ip:${trimmedIp}`; + } + return 'anonymous'; +} + +async function ensureLikesTable(env: Bindings) { + await env.CWD_DB.prepare( + 'CREATE TABLE IF NOT EXISTS Likes (id INTEGER PRIMARY KEY AUTOINCREMENT, page_slug TEXT NOT NULL, user_id TEXT NOT NULL, created_at INTEGER NOT NULL, UNIQUE(page_slug, user_id))' + ).run(); +} + +async function ensurePageStatsTable(env: Bindings) { + await env.CWD_DB.prepare( + 'CREATE TABLE IF NOT EXISTS page_stats (id INTEGER PRIMARY KEY AUTOINCREMENT, post_slug TEXT UNIQUE NOT NULL, post_title TEXT, post_url TEXT, pv INTEGER NOT NULL DEFAULT 0, last_visit_at INTEGER, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL)' + ).run(); +} + +export const getLikeStatus = async ( + c: Context<{ Bindings: Bindings }> +): Promise => { + try { + const rawPostSlug = c.req.query('post_slug') || ''; + const postSlug = rawPostSlug.trim(); + + if (!postSlug) { + return c.json({ message: 'post_slug is required' }, 400); + } + + const userIdHeader = + c.req.header('X-CWD-Like-User') || + c.req.header('x-cwd-like-user') || + ''; + const userId = userIdHeader.trim(); + + await ensureLikesTable(c.env); + + const totalRow = await c.env.CWD_DB.prepare( + 'SELECT COUNT(*) AS count FROM Likes WHERE page_slug = ?' + ) + .bind(postSlug) + .first<{ count: number }>(); + + let liked = false; + if (userId) { + const row = await c.env.CWD_DB.prepare( + 'SELECT id FROM Likes WHERE page_slug = ? AND user_id = ?' + ) + .bind(postSlug, userId) + .first<{ id: number }>(); + liked = !!row; + } + + const totalLikes = totalRow?.count || 0; + + const payload: LikeStatusResponse = { + liked, + alreadyLiked: false, + totalLikes + }; + + return c.json(payload); + } catch (e: any) { + return c.json( + { message: e?.message || '获取点赞状态失败' }, + 500 + ); + } +}; + +export const likePage = async ( + c: Context<{ Bindings: Bindings }> +): Promise => { + try { + const body = ((await c.req + .json() + .catch(() => ({}))) || {}) as LikeRequestBody; + + const rawPostSlug = + typeof body.postSlug === 'string' ? body.postSlug.trim() : ''; + const rawPostTitle = + typeof body.postTitle === 'string' ? body.postTitle.trim() : ''; + const rawPostUrl = + typeof body.postUrl === 'string' ? body.postUrl.trim() : ''; + + if (!rawPostSlug) { + return c.json({ message: 'postSlug is required' }, 400); + } + + const userId = getUserIdFromRequest(c); + + await ensureLikesTable(c.env); + await ensurePageStatsTable(c.env); + + const now = Date.now(); + + const existingLike = await c.env.CWD_DB.prepare( + 'SELECT id FROM Likes WHERE page_slug = ? AND user_id = ?' + ) + .bind(rawPostSlug, userId) + .first<{ id: number }>(); + + let alreadyLiked = false; + + if (!existingLike) { + await c.env.CWD_DB.prepare( + 'INSERT INTO Likes (page_slug, user_id, created_at) VALUES (?, ?, ?)' + ) + .bind(rawPostSlug, userId, now) + .run(); + } else { + alreadyLiked = true; + } + + const pageStatsRow = await c.env.CWD_DB.prepare( + 'SELECT id FROM page_stats WHERE post_slug = ?' + ) + .bind(rawPostSlug) + .first<{ id: number }>(); + + if (!pageStatsRow) { + await c.env.CWD_DB.prepare( + 'INSERT INTO page_stats (post_slug, post_title, post_url, pv, last_visit_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)' + ) + .bind( + rawPostSlug, + rawPostTitle || null, + rawPostUrl || null, + 0, + now, + now, + now + ) + .run(); + } else if (rawPostTitle || rawPostUrl) { + await c.env.CWD_DB.prepare( + 'UPDATE page_stats SET post_title = COALESCE(?, post_title), post_url = COALESCE(?, post_url), updated_at = ? WHERE id = ?' + ) + .bind( + rawPostTitle || null, + rawPostUrl || null, + now, + pageStatsRow.id + ) + .run(); + } + + const totalRow = await c.env.CWD_DB.prepare( + 'SELECT COUNT(*) AS count FROM Likes WHERE page_slug = ?' + ) + .bind(rawPostSlug) + .first<{ count: number }>(); + + const totalLikes = totalRow?.count || 0; + + const payload: LikeStatusResponse = { + liked: true, + alreadyLiked, + totalLikes + }; + + return c.json(payload); + } catch (e: any) { + return c.json( + { message: e?.message || '点赞失败' }, + 500 + ); + } +}; + diff --git a/cwd-api/src/index.ts b/cwd-api/src/index.ts index 7af7dec..1c1bcc7 100644 --- a/cwd-api/src/index.ts +++ b/cwd-api/src/index.ts @@ -26,6 +26,9 @@ import { getStats } from './api/admin/getStats'; import { getDomains } from './api/admin/getDomains'; import { trackVisit } from './api/public/trackVisit'; import { getVisitOverview, getVisitPages } from './api/admin/visitAnalytics'; +import { getLikeStatus, likePage } from './api/public/like'; +import { listLikes } from './api/admin/listLikes'; +import { getLikeStats } from './api/admin/likeStats'; const app = new Hono<{ Bindings: Bindings }>(); const VERSION = `v${packageJson.version}`; @@ -218,6 +221,8 @@ app.get('/api/comments', getComments); app.post('/api/comments', postComment); app.post('/api/verify-admin', verifyAdminKey); app.post('/api/analytics/visit', trackVisit); +app.get('/api/like', getLikeStatus); +app.post('/api/like', likePage); app.get('/api/config/comments', async (c) => { try { const settings = await loadCommentSettings(c.env); @@ -247,6 +252,8 @@ app.get('/admin/stats/comments', getStats); app.get('/admin/stats/domains', getDomains); app.get('/admin/analytics/overview', getVisitOverview); app.get('/admin/analytics/pages', getVisitPages); +app.get('/admin/likes/list', listLikes); +app.get('/admin/likes/stats', getLikeStats); app.get('/admin/settings/email', getAdminEmail); app.put('/admin/settings/email', setAdminEmail); app.get('/admin/settings/email-notify', async (c) => { diff --git a/cwd-api/src/utils/cors.ts b/cwd-api/src/utils/cors.ts index d2c2f6b..850a87b 100644 --- a/cwd-api/src/utils/cors.ts +++ b/cwd-api/src/utils/cors.ts @@ -4,7 +4,7 @@ export const customCors = () => { return cors({ origin: '*', allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], - allowHeaders: ['Content-Type', 'Authorization'], + allowHeaders: ['Content-Type', 'Authorization', 'X-CWD-Like-User'], exposeHeaders: ['Content-Length'], maxAge: 600, credentials: false, diff --git a/docs/widget/src/core/CWDComments.js b/docs/widget/src/core/CWDComments.js index 44b3ab9..35e0e92 100644 --- a/docs/widget/src/core/CWDComments.js +++ b/docs/widget/src/core/CWDComments.js @@ -42,10 +42,17 @@ export class CWDComments { this.mountPoint = null; this.commentForm = null; this.commentList = null; + this.formContainer = null; this.store = null; this.unsubscribe = null; + this.likeState = { + count: 0, + liked: false, + loading: false + }; + this._likeButtonEl = null; + this._likeCountEl = null; - // 初始加载标志 this._mounted = false; } @@ -181,6 +188,24 @@ export class CWDComments { if (this.api && typeof this.api.trackVisit === 'function') { this.api.trackVisit(); } + + if (this.api && typeof this.api.getLikeStatus === 'function') { + try { + const likeResult = await this.api.getLikeStatus(); + const count = + likeResult && typeof likeResult.totalLikes === 'number' + ? likeResult.totalLikes + : 0; + const liked = !!(likeResult && likeResult.liked); + this.likeState.count = count; + this.likeState.liked = liked; + if (this.store && typeof this.store.setLikeState === 'function') { + this.store.setLikeState(count, liked); + } + this._updateLikeButton(); + } catch (e) { + } + } })(); this._mounted = true; @@ -235,21 +260,7 @@ export class CWDComments { } const state = this.store.store.getState(); - - // 创建评论表单 - if (!this.commentForm) { - this.commentForm = new CommentForm(this.mountPoint, { - form: state.form, - formErrors: state.formErrors, - submitting: state.submitting, - onSubmit: () => this._handleSubmit(), - onFieldChange: (field, value) => this.store.updateFormField(field, value), - adminEmail: this.config.adminEmail, - onVerifyAdmin: (key) => this.api.verifyAdminKey(key) - }); - this.commentForm.render(); - } - + // 创建错误提示 const existingError = this.mountPoint.querySelector('.cwd-error-inline'); if (state.error) { @@ -301,6 +312,16 @@ export class CWDComments {

0 条评论

+
+ +
`; this.mountPoint.appendChild(header); } @@ -309,6 +330,27 @@ export class CWDComments { countEl.textContent = state.pagination.totalCount; } + this._initLikeButton(header); + + if (!this.formContainer) { + this.formContainer = document.createElement('div'); + this.mountPoint.appendChild(this.formContainer); + } + + // 创建评论表单(放在点赞区域下方,使用单独容器以保证顺序) + if (!this.commentForm) { + this.commentForm = new CommentForm(this.formContainer, { + form: state.form, + formErrors: state.formErrors, + submitting: state.submitting, + onSubmit: () => this._handleSubmit(), + onFieldChange: (field, value) => this.store.updateFormField(field, value), + adminEmail: this.config.adminEmail, + onVerifyAdmin: (key) => this.api.verifyAdminKey(key) + }); + this.commentForm.render(); + } + // 创建评论列表 if (!this.commentList) { const listContainer = document.createElement('div'); @@ -418,6 +460,21 @@ export class CWDComments { countEl.textContent = state.pagination.totalCount; } + if (typeof state.likeCount === 'number' || typeof state.liked === 'boolean') { + if (typeof state.likeCount === 'number') { + this.likeState.count = state.likeCount; + } + if (typeof state.liked === 'boolean') { + this.likeState.liked = state.liked; + } + if (header) { + if (!this._likeButtonEl || !this._likeCountEl) { + this._initLikeButton(header); + } + this._updateLikeButton(); + } + } + // 更新评论列表 if (this.commentList) { this.commentList.setProps({ @@ -494,6 +551,111 @@ export class CWDComments { } } + _initLikeButton(header) { + if (!header) { + return; + } + if (!this._likeButtonEl) { + this._likeButtonEl = header.querySelector('.cwd-like-button'); + if (this._likeButtonEl) { + this._likeButtonEl.addEventListener('click', () => { + this._handleLikeClick(); + }); + } + } + if (!this._likeCountEl) { + this._likeCountEl = header.querySelector('.cwd-like-count'); + } + this._updateLikeButton(); + } + + _updateLikeButton(animate = false) { + if (!this._likeButtonEl) { + if (!this.mountPoint) { + return; + } + const header = this.mountPoint.querySelector('.cwd-comments-header'); + if (!header) { + return; + } + this._initLikeButton(header); + } + if (!this._likeButtonEl) { + return; + } + const state = this.store?.store?.getState(); + const liked = state ? !!state.liked : this.likeState.liked; + const count = + state && typeof state.likeCount === 'number' + ? state.likeCount + : this.likeState.count; + this.likeState.count = count; + this.likeState.liked = liked; + this._likeButtonEl.dataset.liked = liked ? 'true' : 'false'; + this._likeButtonEl.dataset.loading = this.likeState.loading ? 'true' : 'false'; + if (this._likeCountEl) { + this._likeCountEl.textContent = String(count); + } + if (animate && this._likeButtonEl) { + this._likeButtonEl.classList.remove('cwd-like-animate'); + void this._likeButtonEl.offsetWidth; + this._likeButtonEl.classList.add('cwd-like-animate'); + } + } + + _handleLikeClick() { + if (!this.api || typeof this.api.likePage !== 'function') { + return; + } + if (this.likeState.loading) { + return; + } + const currentState = this.store?.store?.getState(); + const currentCount = + currentState && typeof currentState.likeCount === 'number' + ? currentState.likeCount + : this.likeState.count; + const wasLiked = currentState ? !!currentState.liked : this.likeState.liked; + if (wasLiked) { + return; + } + const nextCount = currentCount + 1; + this.likeState.loading = true; + this.likeState.count = nextCount; + this.likeState.liked = true; + if (this.store && typeof this.store.setLikeState === 'function') { + this.store.setLikeState(nextCount, true); + } + this._updateLikeButton(true); + this.api + .likePage() + .then((result) => { + const total = + result && typeof result.totalLikes === 'number' + ? result.totalLikes + : nextCount; + const liked = !!(result && result.liked); + this.likeState.count = total; + this.likeState.liked = liked; + if (this.store && typeof this.store.setLikeState === 'function') { + this.store.setLikeState(total, liked); + } + this._updateLikeButton(); + }) + .catch(() => { + this.likeState.count = currentCount; + this.likeState.liked = wasLiked; + if (this.store && typeof this.store.setLikeState === 'function') { + this.store.setLikeState(currentCount, wasLiked); + } + this._updateLikeButton(); + }) + .finally(() => { + this.likeState.loading = false; + this._updateLikeButton(); + }); + } + /** * 获取当前配置 * @returns {Object} diff --git a/docs/widget/src/core/api.js b/docs/widget/src/core/api.js index 94c426a..d43378e 100644 --- a/docs/widget/src/core/api.js +++ b/docs/widget/src/core/api.js @@ -11,9 +11,27 @@ * @param {string} config.postUrl - 文章 URL(可选) * @returns {Object} */ - export function createApiClient(config) { +export function createApiClient(config) { const baseUrl = config.apiBaseUrl.replace(/\/$/, ''); + function getLikeUserId() { + try { + const storageKey = 'cwd_like_uid'; + let token = localStorage.getItem(storageKey); + if (!token) { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + token = crypto.randomUUID(); + } else { + token = `${Date.now()}_${Math.random().toString(16).slice(2)}`; + } + localStorage.setItem(storageKey, token); + } + return token; + } catch (e) { + return 'anonymous'; + } + } + /** * 获取评论列表 * @param {number} page - 页码 @@ -114,10 +132,58 @@ } } + async function getLikeStatus() { + const params = new URLSearchParams({ + post_slug: config.postSlug + }); + const headers = { + 'X-CWD-Like-User': getLikeUserId() + }; + const response = await fetch(`${baseUrl}/api/like?${params.toString()}`, { + method: 'GET', + headers + }); + if (!response.ok) { + return { + liked: false, + alreadyLiked: false, + totalLikes: 0 + }; + } + return response.json(); + } + + async function likePage() { + const headers = { + 'Content-Type': 'application/json', + 'X-CWD-Like-User': getLikeUserId() + }; + const response = await fetch(`${baseUrl}/api/like`, { + method: 'POST', + headers, + body: JSON.stringify({ + postSlug: config.postSlug, + postTitle: config.postTitle, + postUrl: config.postUrl + }) + }); + if (!response.ok) { + let msg = response.statusText; + try { + const json = await response.json(); + if (json.message) msg = json.message; + } catch (e) {} + throw new Error(msg); + } + return response.json(); + } + return { fetchComments, submitComment, verifyAdminKey, - trackVisit + trackVisit, + getLikeStatus, + likePage }; } diff --git a/docs/widget/src/core/store.js b/docs/widget/src/core/store.js index dfe390b..b50c6ea 100644 --- a/docs/widget/src/core/store.js +++ b/docs/widget/src/core/store.js @@ -126,6 +126,8 @@ export function createCommentStore(config, fetchComments, submitComment) { replyingTo: null, replyContent: '', replyError: null, + likeCount: 0, + liked: false, }); // 监听用户信息变化,自动保存到 localStorage @@ -165,6 +167,14 @@ export function createCommentStore(config, fetchComments, submitComment) { } } + function setLikeState(likeCount, liked) { + const safeCount = typeof likeCount === 'number' && Number.isFinite(likeCount) && likeCount >= 0 ? likeCount : 0; + store.setState({ + likeCount: safeCount, + liked: !!liked, + }); + } + /** * 提交评论 */ @@ -362,7 +372,7 @@ export function createCommentStore(config, fetchComments, submitComment) { } } - return { + return { // Store 实例 store, @@ -384,5 +394,6 @@ export function createCommentStore(config, fetchComments, submitComment) { clearError, clearSuccess, goToPage, + setLikeState, }; } diff --git a/docs/widget/src/styles/main.css b/docs/widget/src/styles/main.css index 9112241..9097426 100644 --- a/docs/widget/src/styles/main.css +++ b/docs/widget/src/styles/main.css @@ -39,6 +39,21 @@ color: var(--cwd-text); } +.cwd-comments-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 0; + margin-bottom: 16px; + border-bottom: 1px solid var(--cwd-border); +} + +.cwd-comments-count { + font-size: 16px; + font-weight: 600; + color: var(--cwd-text); +} + /* ========== Loading 组件 ========== */ .cwd-loading { display: flex; @@ -499,6 +514,72 @@ height: auto; } +.cwd-like { + display: flex; + align-items: center; + gap: 8px; +} + +.cwd-like-button { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: 999px; + border: 1px solid var(--cwd-border-light, #eaeef2); + background: var(--cwd-bg-secondary, #f6f8fa); + color: var(--cwd-text-secondary, #6e7781); + cursor: pointer; + font-size: 13px; + font-family: inherit; + transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease, transform 0.15s ease; +} + +.cwd-like-button[data-liked='true'] { + background: rgba(9, 105, 218, 0.08); + border-color: var(--cwd-primary, #0969da); + color: var(--cwd-primary, #0969da); +} + +.cwd-like-button[data-loading='true'] { + opacity: 0.6; + cursor: default; + pointer-events: none; +} + +.cwd-like-icon-wrapper { + display: inline-flex; + align-items: center; + justify-content: center; +} + +.cwd-like-icon { + width: 18px; + height: 18px; + fill: currentColor; +} + +.cwd-like-count { + min-width: 1.5em; + text-align: right; +} + +.cwd-like-animate .cwd-like-icon { + animation: cwd-like-bounce 0.3s ease; +} + +@keyframes cwd-like-bounce { + 0% { + transform: scale(1); + } + 50% { + transform: scale(1.2); + } + 100% { + transform: scale(1.05); + } +} + .cwd-comment-actions { display: flex; align-items: center;