Release v0.2.0: Task comments, recurring calendar, system tray, app branding
New features: - Task comments with date-stamped entries and last-comment summary - Recurring tasks expanded on calendar (daily/weekly/monthly/yearly) - System tray mode replacing CMD window (Windows/macOS/Linux) - Ironpad logo as exe icon, tray icon, favicon, and header logo Technical changes: - Backend restructured for dual-mode: dev (API-only) / prod (tray + server) - tray-item crate for cross-platform tray, winresource for icon embedding - Calendar view refactored with CalendarEntry interface for recurring merging - Added CHANGELOG.md, build-local.ps1, version bumped to 0.2.0 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -15,7 +15,8 @@ import type {
|
||||
CommitDetail,
|
||||
DiffInfo,
|
||||
RemoteInfo,
|
||||
DailyNote
|
||||
DailyNote,
|
||||
Comment
|
||||
} from '../types'
|
||||
|
||||
const API_BASE = '/api'
|
||||
@@ -150,6 +151,20 @@ export const tasksApi = {
|
||||
delete: (projectId: string, taskId: string) =>
|
||||
request<void>(`/projects/${encodeURIComponent(projectId)}/tasks/${encodeURIComponent(taskId)}`, {
|
||||
method: 'DELETE'
|
||||
}),
|
||||
|
||||
// Add a comment to a task
|
||||
addComment: (projectId: string, taskId: string, text: string) =>
|
||||
request<TaskWithContent>(`/projects/${encodeURIComponent(projectId)}/tasks/${encodeURIComponent(taskId)}/comments`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text })
|
||||
}),
|
||||
|
||||
// Delete a comment from a task by index
|
||||
deleteComment: (projectId: string, taskId: string, commentIndex: number) =>
|
||||
request<TaskWithContent>(`/projects/${encodeURIComponent(projectId)}/tasks/${encodeURIComponent(taskId)}/comments/${commentIndex}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -65,7 +65,10 @@ function goHome() {
|
||||
<template>
|
||||
<header class="topbar">
|
||||
<div class="topbar-left">
|
||||
<h1 class="app-title" @click="goHome" style="cursor: pointer" title="Dashboard">Ironpad</h1>
|
||||
<h1 class="app-title" @click="goHome" style="cursor: pointer" title="Dashboard">
|
||||
<img src="/logo-32.png" alt="" class="app-logo" />
|
||||
Ironpad
|
||||
</h1>
|
||||
|
||||
<div class="project-selector">
|
||||
<button class="project-button" @click="toggleDropdown">
|
||||
@@ -175,6 +178,15 @@ function goHome() {
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: var(--color-text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.app-logo {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.project-selector {
|
||||
|
||||
@@ -181,6 +181,42 @@ export const useTasksStore = defineStore('tasks', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function addComment(projectId: string, taskId: string, text: string) {
|
||||
try {
|
||||
error.value = null
|
||||
const task = await tasksApi.addComment(projectId, taskId, text)
|
||||
selectedTask.value = task
|
||||
|
||||
// Refresh task list so last_comment updates
|
||||
if (currentProjectId.value === projectId) {
|
||||
await loadProjectTasks(projectId)
|
||||
}
|
||||
|
||||
return task
|
||||
} catch (err) {
|
||||
error.value = `Failed to add comment: ${err}`
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteComment(projectId: string, taskId: string, commentIndex: number) {
|
||||
try {
|
||||
error.value = null
|
||||
const task = await tasksApi.deleteComment(projectId, taskId, commentIndex)
|
||||
selectedTask.value = task
|
||||
|
||||
// Refresh task list so last_comment updates
|
||||
if (currentProjectId.value === projectId) {
|
||||
await loadProjectTasks(projectId)
|
||||
}
|
||||
|
||||
return task
|
||||
} catch (err) {
|
||||
error.value = `Failed to delete comment: ${err}`
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
function selectTask(task: Task | null) {
|
||||
if (task && currentProjectId.value) {
|
||||
loadTask(currentProjectId.value, task.id)
|
||||
@@ -227,6 +263,8 @@ export const useTasksStore = defineStore('tasks', () => {
|
||||
toggleTask,
|
||||
updateTaskMeta,
|
||||
deleteTask,
|
||||
addComment,
|
||||
deleteComment,
|
||||
selectTask,
|
||||
clearSelectedTask,
|
||||
clearProjectTasks,
|
||||
|
||||
@@ -40,6 +40,11 @@ export interface ProjectNoteWithContent extends ProjectNote {
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface Comment {
|
||||
date: string
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface Task {
|
||||
id: string
|
||||
title: string
|
||||
@@ -56,10 +61,12 @@ export interface Task {
|
||||
path: string
|
||||
created: string
|
||||
updated: string
|
||||
last_comment?: string
|
||||
}
|
||||
|
||||
export interface TaskWithContent extends Task {
|
||||
content: string
|
||||
comments: Comment[]
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
|
||||
@@ -93,21 +93,144 @@ function formatDate(y: number, m: number, d: number): string {
|
||||
return `${y}-${String(m + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
// Tasks grouped by due date
|
||||
// Calendar entry: a task plus whether it's a recurring occurrence (vs a real due_date)
|
||||
interface CalendarEntry {
|
||||
task: Task
|
||||
isRecurring: boolean
|
||||
}
|
||||
|
||||
// Tasks grouped by due date (regular, non-recurring placements)
|
||||
const tasksByDate = computed(() => {
|
||||
const map = new Map<string, Task[]>()
|
||||
const map = new Map<string, CalendarEntry[]>()
|
||||
for (const task of tasksStore.allTasks) {
|
||||
if (task.due_date && !task.completed) {
|
||||
const existing = map.get(task.due_date) || []
|
||||
existing.push(task)
|
||||
existing.push({ task, isRecurring: false })
|
||||
map.set(task.due_date, existing)
|
||||
}
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
function getTasksForDate(dateStr: string): Task[] {
|
||||
return tasksByDate.value.get(dateStr) || []
|
||||
// Expand recurring tasks into the visible calendar range
|
||||
const recurringByDate = computed(() => {
|
||||
const map = new Map<string, CalendarEntry[]>()
|
||||
const days = calendarDays.value
|
||||
if (days.length === 0) return map
|
||||
|
||||
const rangeStart = days[0].date
|
||||
const rangeEnd = days[days.length - 1].date
|
||||
|
||||
for (const task of tasksStore.allTasks) {
|
||||
if (task.completed || !task.recurrence) continue
|
||||
|
||||
const interval = task.recurrence_interval || 1
|
||||
const anchorStr = task.due_date || task.created?.split('T')[0]
|
||||
if (!anchorStr) continue
|
||||
|
||||
const anchor = new Date(anchorStr + 'T00:00:00')
|
||||
const start = new Date(rangeStart + 'T00:00:00')
|
||||
const end = new Date(rangeEnd + 'T00:00:00')
|
||||
const occurrences: string[] = []
|
||||
|
||||
switch (task.recurrence) {
|
||||
case 'daily': {
|
||||
let cur = new Date(anchor)
|
||||
// Advance to range start (skip past occurrences efficiently)
|
||||
if (cur < start) {
|
||||
const daysBehind = Math.floor((start.getTime() - cur.getTime()) / 86400000)
|
||||
const skipCycles = Math.floor(daysBehind / interval) * interval
|
||||
cur.setDate(cur.getDate() + skipCycles)
|
||||
}
|
||||
while (cur <= end && occurrences.length < 60) {
|
||||
if (cur >= start) {
|
||||
occurrences.push(dateFromObj(cur))
|
||||
}
|
||||
cur.setDate(cur.getDate() + interval)
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'weekly': {
|
||||
const targetDow = anchor.getDay()
|
||||
let cur = new Date(start)
|
||||
// Find first matching weekday in range
|
||||
while (cur.getDay() !== targetDow && cur <= end) {
|
||||
cur.setDate(cur.getDate() + 1)
|
||||
}
|
||||
while (cur <= end && occurrences.length < 10) {
|
||||
// Check interval alignment (weeks since anchor)
|
||||
const msDiff = cur.getTime() - anchor.getTime()
|
||||
const weeksDiff = Math.round(msDiff / (7 * 86400000))
|
||||
if (weeksDiff >= 0 && weeksDiff % interval === 0) {
|
||||
occurrences.push(dateFromObj(cur))
|
||||
}
|
||||
cur.setDate(cur.getDate() + 7)
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'monthly': {
|
||||
const targetDay = anchor.getDate()
|
||||
// Check months visible in the calendar range (usually 3: prev, current, next)
|
||||
for (let mOffset = -1; mOffset <= 1; mOffset++) {
|
||||
let y = currentYear.value
|
||||
let m = currentMonth.value + mOffset
|
||||
if (m < 0) { m = 11; y-- }
|
||||
if (m > 11) { m = 0; y++ }
|
||||
|
||||
const daysInM = new Date(y, m + 1, 0).getDate()
|
||||
const day = Math.min(targetDay, daysInM)
|
||||
const dateStr = formatDate(y, m, day)
|
||||
|
||||
if (dateStr >= rangeStart && dateStr <= rangeEnd) {
|
||||
const monthsDiff = (y - anchor.getFullYear()) * 12 + (m - anchor.getMonth())
|
||||
if (monthsDiff >= 0 && monthsDiff % interval === 0) {
|
||||
occurrences.push(dateStr)
|
||||
}
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'yearly': {
|
||||
const tgtMonth = anchor.getMonth()
|
||||
const tgtDay = anchor.getDate()
|
||||
const y = currentYear.value
|
||||
const dateStr = formatDate(y, tgtMonth, tgtDay)
|
||||
if (dateStr >= rangeStart && dateStr <= rangeEnd) {
|
||||
const yearsDiff = y - anchor.getFullYear()
|
||||
if (yearsDiff >= 0 && yearsDiff % interval === 0) {
|
||||
occurrences.push(dateStr)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
for (const dateStr of occurrences) {
|
||||
// Skip if the task already appears on this date via its due_date
|
||||
if (task.due_date === dateStr) continue
|
||||
const existing = map.get(dateStr) || []
|
||||
existing.push({ task, isRecurring: true })
|
||||
map.set(dateStr, existing)
|
||||
}
|
||||
}
|
||||
|
||||
return map
|
||||
})
|
||||
|
||||
function dateFromObj(d: Date): string {
|
||||
return formatDate(d.getFullYear(), d.getMonth(), d.getDate())
|
||||
}
|
||||
|
||||
// Merge regular due-date tasks and recurring occurrences for a given date
|
||||
function getEntriesForDate(dateStr: string): CalendarEntry[] {
|
||||
const regular = tasksByDate.value.get(dateStr) || []
|
||||
const recurring = recurringByDate.value.get(dateStr) || []
|
||||
return [...regular, ...recurring]
|
||||
}
|
||||
|
||||
// Convenience: check if a date has any tasks (for cell styling)
|
||||
function hasTasksOnDate(dateStr: string): boolean {
|
||||
return getEntriesForDate(dateStr).length > 0
|
||||
}
|
||||
|
||||
function hasDailyNote(dateStr: string): boolean {
|
||||
@@ -211,7 +334,7 @@ onMounted(async () => {
|
||||
:class="['calendar-cell', {
|
||||
'other-month': !day.isCurrentMonth,
|
||||
'is-today': day.isToday,
|
||||
'has-tasks': getTasksForDate(day.date).length > 0,
|
||||
'has-tasks': hasTasksOnDate(day.date),
|
||||
}]"
|
||||
>
|
||||
<div class="cell-header" @click="clickDate(day.date)">
|
||||
@@ -220,20 +343,21 @@ onMounted(async () => {
|
||||
</div>
|
||||
<div class="cell-tasks">
|
||||
<div
|
||||
v-for="task in getTasksForDate(day.date).slice(0, 3)"
|
||||
:key="task.id"
|
||||
:class="['cell-task', formatDueClass(day.date)]"
|
||||
@click.stop="clickTask(task)"
|
||||
:title="`${projectName(task.project_id)}: ${task.title}`"
|
||||
v-for="entry in getEntriesForDate(day.date).slice(0, 3)"
|
||||
:key="`${entry.task.id}-${entry.isRecurring ? 'r' : 'd'}`"
|
||||
:class="['cell-task', formatDueClass(day.date), { recurring: entry.isRecurring }]"
|
||||
@click.stop="clickTask(entry.task)"
|
||||
:title="`${projectName(entry.task.project_id)}: ${entry.task.title}${entry.isRecurring ? ' (recurring)' : ''}`"
|
||||
>
|
||||
{{ task.title }}
|
||||
<span v-if="entry.isRecurring" class="recurring-icon" title="Recurring">↻</span>
|
||||
{{ entry.task.title }}
|
||||
</div>
|
||||
<div
|
||||
v-if="getTasksForDate(day.date).length > 3"
|
||||
v-if="getEntriesForDate(day.date).length > 3"
|
||||
class="cell-more"
|
||||
@click="clickDate(day.date)"
|
||||
>
|
||||
+{{ getTasksForDate(day.date).length - 3 }} more
|
||||
+{{ getEntriesForDate(day.date).length - 3 }} more
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -413,6 +537,17 @@ onMounted(async () => {
|
||||
border-left-color: var(--color-warning);
|
||||
}
|
||||
|
||||
.cell-task.recurring {
|
||||
border-left-style: dashed;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.recurring-icon {
|
||||
font-size: 10px;
|
||||
margin-right: 2px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.cell-more {
|
||||
font-size: 10px;
|
||||
color: var(--color-text-secondary);
|
||||
|
||||
@@ -140,17 +140,24 @@ onMounted(async () => {
|
||||
@click="goToTask(project.id, task)"
|
||||
>
|
||||
<span class="task-checkbox">☐</span>
|
||||
<span class="task-title">{{ task.title }}</span>
|
||||
<div class="task-meta">
|
||||
<span
|
||||
v-for="tag in task.tags?.slice(0, 2)"
|
||||
:key="tag"
|
||||
class="task-tag"
|
||||
>{{ tag }}</span>
|
||||
<span
|
||||
v-if="task.due_date && formatDueDate(task.due_date)"
|
||||
:class="['task-due', formatDueDate(task.due_date)?.class]"
|
||||
>{{ formatDueDate(task.due_date)?.text }}</span>
|
||||
<div class="card-task-info">
|
||||
<div class="card-task-row">
|
||||
<span class="task-title">{{ task.title }}</span>
|
||||
<div class="task-meta">
|
||||
<span
|
||||
v-for="tag in task.tags?.slice(0, 2)"
|
||||
:key="tag"
|
||||
class="task-tag"
|
||||
>{{ tag }}</span>
|
||||
<span
|
||||
v-if="task.due_date && formatDueDate(task.due_date)"
|
||||
:class="['task-due', formatDueDate(task.due_date)?.class]"
|
||||
>{{ formatDueDate(task.due_date)?.text }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="task.last_comment" class="card-task-comment">
|
||||
{{ task.last_comment }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -304,6 +311,20 @@ onMounted(async () => {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.card-task-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.card-task-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.card-task-item .task-title {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
@@ -313,6 +334,16 @@ onMounted(async () => {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.card-task-comment {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-style: italic;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.task-meta {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
|
||||
@@ -338,6 +338,59 @@ async function addSubtask() {
|
||||
}
|
||||
}
|
||||
|
||||
// ============ Comment Management ============
|
||||
|
||||
const newCommentText = ref('')
|
||||
const showCommentForm = ref(false)
|
||||
|
||||
function openCommentForm() {
|
||||
showCommentForm.value = true
|
||||
newCommentText.value = ''
|
||||
}
|
||||
|
||||
function closeCommentForm() {
|
||||
showCommentForm.value = false
|
||||
newCommentText.value = ''
|
||||
}
|
||||
|
||||
async function addComment() {
|
||||
if (!newCommentText.value.trim() || !tasksStore.selectedTask) return
|
||||
try {
|
||||
await tasksStore.addComment(projectId.value, tasksStore.selectedTask.id, newCommentText.value.trim())
|
||||
closeCommentForm()
|
||||
} catch {
|
||||
// Error handled in store
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteComment(index: number) {
|
||||
if (!tasksStore.selectedTask) return
|
||||
try {
|
||||
await tasksStore.deleteComment(projectId.value, tasksStore.selectedTask.id, index)
|
||||
} catch {
|
||||
// Error handled in store
|
||||
}
|
||||
}
|
||||
|
||||
function formatCommentDate(dateStr: string): string {
|
||||
try {
|
||||
const date = new Date(dateStr)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffMins = Math.floor(diffMs / (1000 * 60))
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
|
||||
|
||||
if (diffMins < 1) return 'Just now'
|
||||
if (diffMins < 60) return `${diffMins}m ago`
|
||||
if (diffHours < 24) return `${diffHours}h ago`
|
||||
if (diffDays < 7) return `${diffDays}d ago`
|
||||
return date.toLocaleDateString()
|
||||
} catch {
|
||||
return dateStr
|
||||
}
|
||||
}
|
||||
|
||||
// ============ Due Date Management ============
|
||||
|
||||
async function setDueDate(date: string) {
|
||||
@@ -650,6 +703,9 @@ onUnmounted(() => {
|
||||
{{ formatDueDate(task.due_date)?.text }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="task.last_comment" class="task-last-comment">
|
||||
{{ task.last_comment }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<button
|
||||
@@ -718,6 +774,9 @@ onUnmounted(() => {
|
||||
{{ formatDueDate(task.due_date)?.text }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="task.last_comment" class="task-last-comment">
|
||||
{{ task.last_comment }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<button
|
||||
@@ -921,6 +980,49 @@ onUnmounted(() => {
|
||||
<button class="tag-add-btn" @click="openSubtaskInput">+ Add subtask</button>
|
||||
</div>
|
||||
|
||||
<!-- Comments Section -->
|
||||
<div class="comments-panel">
|
||||
<div class="comments-header">
|
||||
<span class="comments-label">Comments ({{ tasksStore.selectedTask.comments?.length || 0 }})</span>
|
||||
<button v-if="!showCommentForm" class="tag-add-btn" @click="openCommentForm">+ Comment</button>
|
||||
</div>
|
||||
<!-- Add comment form -->
|
||||
<div v-if="showCommentForm" class="comment-form">
|
||||
<textarea
|
||||
v-model="newCommentText"
|
||||
class="comment-input"
|
||||
placeholder="Add a comment or status update..."
|
||||
rows="2"
|
||||
@keydown.ctrl.enter="addComment"
|
||||
@keyup.escape="closeCommentForm"
|
||||
autofocus
|
||||
></textarea>
|
||||
<div class="comment-form-actions">
|
||||
<button class="primary small" @click="addComment" :disabled="!newCommentText.trim()">Add Comment</button>
|
||||
<button class="small" @click="closeCommentForm">Cancel</button>
|
||||
<span class="comment-hint">Ctrl+Enter to submit</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Comment list (newest first) -->
|
||||
<div v-if="tasksStore.selectedTask.comments?.length > 0" class="comment-list">
|
||||
<div
|
||||
v-for="(comment, index) in [...tasksStore.selectedTask.comments].reverse()"
|
||||
:key="index"
|
||||
class="comment-item"
|
||||
>
|
||||
<div class="comment-meta">
|
||||
<span class="comment-date">{{ formatCommentDate(comment.date) }}</span>
|
||||
<button
|
||||
class="comment-delete"
|
||||
@click="deleteComment(tasksStore.selectedTask.comments.length - 1 - index)"
|
||||
title="Delete comment"
|
||||
>×</button>
|
||||
</div>
|
||||
<div class="comment-text">{{ comment.text }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="editor-content">
|
||||
<div class="editor-pane">
|
||||
<MilkdownEditor
|
||||
@@ -1507,6 +1609,22 @@ button.small {
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Last comment preview in task list */
|
||||
.task-last-comment {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
opacity: 0.75;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.task-item.selected .task-last-comment {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
/* Tag Editor Bar (detail panel) */
|
||||
.tag-editor-bar {
|
||||
display: flex;
|
||||
@@ -1601,6 +1719,118 @@ button.small {
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
/* Comments Panel */
|
||||
.comments-panel {
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
padding: 8px 16px;
|
||||
flex-shrink: 0;
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.comments-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.comments-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.comment-form {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.comment-input {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
resize: vertical;
|
||||
min-height: 48px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.comment-input:focus {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.comment-form-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.comment-hint {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-secondary);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.comment-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.comment-item {
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.comment-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.comment-date {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.comment-delete {
|
||||
padding: 0 4px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.comment-item:hover .comment-delete {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.comment-delete:hover {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.comment-text {
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Error Banner */
|
||||
.error-banner {
|
||||
position: absolute;
|
||||
|
||||
Reference in New Issue
Block a user