v1.4.3: 관리자 UI·홈·미디어 개선
- 관리자 라이트 테마 격리, 대시보드 활성 링크, 로그인 우측 정렬 - 대시보드 통계 추이 차트·툴팁, 홈 Latest/Featured 보정 - 미디어 종류·미사용 필터, 비디오 프레임 썸네일 - NAS 운영 업데이트 절차 문서 추가
This commit is contained in:
@@ -111,7 +111,22 @@ const formatTrendValue = (key, value) => {
|
||||
return formatEngagedDuration(value)
|
||||
}
|
||||
|
||||
return `${Number(value || 0)}`
|
||||
if (key === 'visitors') {
|
||||
return `${Number(value || 0)}명`
|
||||
}
|
||||
|
||||
return `${Number(value || 0)}회`
|
||||
}
|
||||
|
||||
/**
|
||||
* 추세 막대 툴팁 문구를 반환한다.
|
||||
* @param {{ key: 'visitors' | 'avgEngagedSeconds' | 'scroll50Reach', title: string }} metric - 지표 정보
|
||||
* @param {Object} row - 추세 행
|
||||
* @returns {string} 툴팁 문구
|
||||
*/
|
||||
const getTrendTooltipLabel = (metric, row) => {
|
||||
const dayLabel = row.label || formatTrendDayLabel(row.day)
|
||||
return `${dayLabel} · ${metric.title} ${formatTrendValue(metric.key, row[metric.key])}`
|
||||
}
|
||||
|
||||
const chartMetrics = computed(() => [
|
||||
@@ -139,7 +154,7 @@ const chartMetrics = computed(() => [
|
||||
* @returns {number} 높이 %
|
||||
*/
|
||||
const getTrendBarHeight = (key, row) => {
|
||||
const rows = trendRows.value
|
||||
const rows = chartTrendRows.value
|
||||
const maxValue = Math.max(...rows.map((item) => Number(item[key] || 0)), 0)
|
||||
|
||||
if (maxValue <= 0) {
|
||||
@@ -164,6 +179,67 @@ const formatTrendDayLabel = (day) => {
|
||||
return `${month}.${date}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 선택 기간에 맞는 차트 표시 집계 단위를 반환한다.
|
||||
* @param {number} days - 선택 기간
|
||||
* @returns {number} 묶을 일수
|
||||
*/
|
||||
const getTrendChartBucketSize = (days) => {
|
||||
if (days <= 14) {
|
||||
return 1
|
||||
}
|
||||
|
||||
if (days <= 60) {
|
||||
return 7
|
||||
}
|
||||
|
||||
if (days <= 180) {
|
||||
return 14
|
||||
}
|
||||
|
||||
return 30
|
||||
}
|
||||
|
||||
/**
|
||||
* 차트 표시용 추세 묶음 행을 만든다.
|
||||
* @param {Array<Object>} rows - 일자별 추세 행
|
||||
* @param {number} bucketSize - 묶을 일수
|
||||
* @returns {Array<Object>} 차트 표시 행
|
||||
*/
|
||||
const buildTrendChartRows = (rows, bucketSize) => {
|
||||
if (bucketSize <= 1) {
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
label: formatTrendDayLabel(row.day)
|
||||
}))
|
||||
}
|
||||
|
||||
const buckets = []
|
||||
for (let index = 0; index < rows.length; index += bucketSize) {
|
||||
const bucketRows = rows.slice(index, index + bucketSize)
|
||||
const firstRow = bucketRows[0]
|
||||
const lastRow = bucketRows[bucketRows.length - 1]
|
||||
const engagedRows = bucketRows.filter((row) => Number(row.avgEngagedSeconds || 0) > 0)
|
||||
const totalEngagedSeconds = engagedRows.reduce((sum, row) => sum + Number(row.avgEngagedSeconds || 0), 0)
|
||||
const startLabel = formatTrendDayLabel(firstRow?.day)
|
||||
const endLabel = formatTrendDayLabel(lastRow?.day)
|
||||
|
||||
buckets.push({
|
||||
day: firstRow?.day || '',
|
||||
label: startLabel === endLabel ? startLabel : `${startLabel}-${endLabel}`,
|
||||
visitors: bucketRows.reduce((sum, row) => sum + Number(row.visitors || 0), 0),
|
||||
avgEngagedSeconds: engagedRows.length ? Math.round(totalEngagedSeconds / engagedRows.length) : 0,
|
||||
scroll50Reach: bucketRows.reduce((sum, row) => sum + Number(row.scroll50Reach || 0), 0)
|
||||
})
|
||||
}
|
||||
|
||||
return buckets
|
||||
}
|
||||
|
||||
const chartTrendRows = computed(() => {
|
||||
return buildTrendChartRows(trendRows.value, getTrendChartBucketSize(selectedAnalyticsDays.value))
|
||||
})
|
||||
|
||||
/**
|
||||
* 현재 접속자가 보고 있는 화면명을 반환한다.
|
||||
* @param {Object} session - 접속 세션
|
||||
@@ -268,19 +344,35 @@ watch(selectedAnalyticsDays, () => {
|
||||
{{ metric.label }}
|
||||
</strong>
|
||||
</div>
|
||||
<div class="admin-dashboard__chart-bars mt-4 flex h-32 items-end gap-1 border-b border-line">
|
||||
<div
|
||||
class="admin-dashboard__chart-bars mt-4 flex h-32 items-end gap-1 border-b border-line"
|
||||
role="img"
|
||||
:aria-label="`${metric.title} ${analyticsRangeLabel} 추이`"
|
||||
>
|
||||
<div
|
||||
v-for="row in trendRows"
|
||||
v-for="row in chartTrendRows"
|
||||
:key="`${metric.key}-${row.day}`"
|
||||
class="admin-dashboard__chart-bar-wrap flex min-w-[3px] flex-1 items-end"
|
||||
:title="`${row.day} · ${formatTrendValue(metric.key, row[metric.key])}`"
|
||||
class="admin-dashboard__chart-bar-wrap group relative flex h-full min-w-[3px] flex-1 items-end"
|
||||
tabindex="0"
|
||||
:aria-label="getTrendTooltipLabel(metric, row)"
|
||||
>
|
||||
<div
|
||||
class="admin-dashboard__chart-bar w-full bg-[#15171a]/80"
|
||||
class="admin-dashboard__chart-tooltip pointer-events-none absolute bottom-full left-1/2 z-10 mb-2 hidden -translate-x-1/2 whitespace-nowrap rounded bg-[#15171a] px-2 py-1 text-[11px] font-medium text-white shadow-lg group-hover:block group-focus-visible:block"
|
||||
>
|
||||
{{ getTrendTooltipLabel(metric, row) }}
|
||||
</div>
|
||||
<div
|
||||
class="admin-dashboard__chart-bar w-full rounded-t-sm bg-[#15171a] transition-colors group-hover:bg-[#2f6feb] group-focus-visible:bg-[#2f6feb]"
|
||||
:style="{ height: `${getTrendBarHeight(metric.key, row)}%` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
v-if="!chartTrendRows.length"
|
||||
class="admin-dashboard__chart-empty mt-4 text-center text-xs text-muted"
|
||||
>
|
||||
선택한 기간에 표시할 추이 데이터가 없습니다.
|
||||
</p>
|
||||
<div class="mt-2 flex justify-between text-[11px] text-muted">
|
||||
<span>{{ formatTrendDayLabel(trendStartDay) }}</span>
|
||||
<span>{{ analyticsRangeLabel }}</span>
|
||||
|
||||
@@ -10,6 +10,7 @@ const form = reactive({
|
||||
|
||||
const pending = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const showPassword = ref(false)
|
||||
const emailInput = ref(null)
|
||||
const passwordInput = ref(null)
|
||||
|
||||
@@ -20,6 +21,12 @@ const { data: bootstrapStatus } = await useFetch('/api/auth/bootstrap-status', {
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* 관리자 로그인 제출 가능 여부
|
||||
* @returns {boolean} 제출 가능 여부
|
||||
*/
|
||||
const canSubmitAdminLogin = computed(() => Boolean(form.email.trim()) && Boolean(form.password))
|
||||
|
||||
/**
|
||||
* 브라우저/비밀번호 관리자 자동완성 값을 로그인 폼 상태에 반영한다.
|
||||
* @returns {void}
|
||||
@@ -85,64 +92,76 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="admin-login flex min-h-screen items-center justify-center bg-[#f5f5f2] px-5 text-ink">
|
||||
<section class="admin-login__panel w-full max-w-sm border border-line bg-paper p-8">
|
||||
<p class="admin-login__eyebrow text-xs font-semibold uppercase text-muted">
|
||||
Admin
|
||||
</p>
|
||||
<h1 class="admin-login__title mt-2 text-3xl font-semibold">
|
||||
로그인
|
||||
</h1>
|
||||
<p
|
||||
v-if="bootstrapStatus?.needsAdminSetup"
|
||||
class="mt-3 rounded border border-[#ff4f2e]/30 bg-[#ff4f2e]/10 px-3 py-2 text-xs text-[#b63a23]"
|
||||
>
|
||||
등록된 관리자가 없습니다.
|
||||
<NuxtLink class="font-semibold underline-offset-2 hover:underline" to="/signup">
|
||||
관리자 등록으로 이동
|
||||
</NuxtLink>
|
||||
</p>
|
||||
|
||||
<form class="admin-login__form mt-8 grid gap-4" @submit.prevent="submitLogin">
|
||||
<label class="admin-login__field grid gap-2 text-sm">
|
||||
<span class="admin-login__label font-medium">이메일</span>
|
||||
<input
|
||||
ref="emailInput"
|
||||
v-model="form.email"
|
||||
class="admin-login__input rounded border border-line bg-white px-3 py-2"
|
||||
type="email"
|
||||
autocomplete="username"
|
||||
required
|
||||
@change="syncAdminLoginAutofill"
|
||||
@focus="syncAdminLoginAutofill"
|
||||
@input="syncAdminLoginAutofill"
|
||||
>
|
||||
</label>
|
||||
<label class="admin-login__field grid gap-2 text-sm">
|
||||
<span class="admin-login__label font-medium">비밀번호</span>
|
||||
<input
|
||||
ref="passwordInput"
|
||||
v-model="form.password"
|
||||
class="admin-login__input rounded border border-line bg-white px-3 py-2"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
@change="syncAdminLoginAutofill"
|
||||
@focus="syncAdminLoginAutofill"
|
||||
@input="syncAdminLoginAutofill"
|
||||
>
|
||||
</label>
|
||||
<p v-if="errorMessage" class="admin-login__error text-sm text-red-600">
|
||||
{{ errorMessage }}
|
||||
<main class="admin-login min-h-screen bg-[#0a0b0d] text-[#f5f7fa] [color-scheme:dark]">
|
||||
<div class="mx-auto flex min-h-screen w-full max-w-[1280px] items-center justify-end px-5 py-12 sm:px-10 lg:px-16">
|
||||
<section class="admin-login__panel w-full max-w-[430px] p-5 sm:p-8">
|
||||
<p class="admin-login__eyebrow text-right text-xs font-semibold uppercase text-[#9ba3af]">
|
||||
Admin
|
||||
</p>
|
||||
<button
|
||||
class="admin-login__button rounded bg-[#15171a] px-4 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-50"
|
||||
type="submit"
|
||||
:disabled="pending"
|
||||
<h1 class="admin-login__title text-right mt-2 text-2xl font-semibold leading-tight">
|
||||
관리자 로그인
|
||||
</h1>
|
||||
<p class="mt-2 text-right text-sm text-[#9ba3af]">
|
||||
관리자 계정으로 로그인해 콘텐츠와 사이트 설정을 관리하세요.
|
||||
</p>
|
||||
<p
|
||||
v-if="bootstrapStatus?.needsAdminSetup"
|
||||
class="mt-5 rounded-[10px] border border-[#b03b43]/50 bg-[#b03b43]/10 px-3 py-2 text-right text-xs text-[#e5acb1]"
|
||||
>
|
||||
{{ pending ? '확인 중' : '로그인' }}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
등록된 관리자가 없습니다.
|
||||
<NuxtLink class="font-semibold text-[#7eb8ff] underline-offset-2 hover:underline" to="/signup">
|
||||
관리자 등록으로 이동
|
||||
</NuxtLink>
|
||||
</p>
|
||||
|
||||
<form class="admin-login__form mt-8 space-y-5" @submit.prevent="submitLogin">
|
||||
<label class="admin-login__field block space-y-1.5">
|
||||
<span class="admin-login__label text-xs text-[#d8dee6]">이메일</span>
|
||||
<input
|
||||
ref="emailInput"
|
||||
v-model="form.email"
|
||||
class="auth-form-input admin-login__input h-10 w-full rounded-[8px] border border-[#1a212a] bg-transparent px-3 text-sm outline-none transition-colors focus:border-[#2f6feb]"
|
||||
type="email"
|
||||
autocomplete="username"
|
||||
required
|
||||
@change="syncAdminLoginAutofill"
|
||||
@focus="syncAdminLoginAutofill"
|
||||
@input="syncAdminLoginAutofill"
|
||||
>
|
||||
</label>
|
||||
<label class="admin-login__field block space-y-1.5">
|
||||
<span class="admin-login__label text-xs text-[#d8dee6]">비밀번호</span>
|
||||
<span class="flex items-center rounded-[8px] border border-[#1a212a] transition-colors focus-within:border-[#2f6feb]">
|
||||
<input
|
||||
ref="passwordInput"
|
||||
v-model="form.password"
|
||||
class="auth-form-input admin-login__input h-10 min-w-0 flex-1 bg-transparent px-3 text-sm outline-none"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
@change="syncAdminLoginAutofill"
|
||||
@focus="syncAdminLoginAutofill"
|
||||
@input="syncAdminLoginAutofill"
|
||||
>
|
||||
<AuthPasswordVisibilityToggle v-model="showPassword" />
|
||||
</span>
|
||||
</label>
|
||||
<p v-if="errorMessage" class="admin-login__error text-right text-xs text-[#e5acb1]" aria-live="polite">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
<button
|
||||
class="admin-login__button ml-auto block h-9 rounded-[8px] bg-[#2f6feb] px-8 text-xs font-medium text-white transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
type="submit"
|
||||
:disabled="pending || !canSubmitAdminLogin"
|
||||
>
|
||||
{{ pending ? '확인 중' : '로그인' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<NuxtLink class="mt-6 flex justify-end text-xs text-[#9ba3af] hover:opacity-80" to="/">
|
||||
사이트로 돌아가기
|
||||
</NuxtLink>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
@@ -16,6 +16,8 @@ const isThumbnailFolderPath = (folder) => folder === MEDIA_THUMBNAIL_ROOT || Str
|
||||
const activeTab = ref('library')
|
||||
const searchText = ref('')
|
||||
const activeFolder = ref('')
|
||||
const activeMediaKind = ref('all')
|
||||
const showUnusedOnly = ref(false)
|
||||
const isCreateFolderModalOpen = ref(false)
|
||||
const createFolderModalName = ref('')
|
||||
const deletingFolder = ref('')
|
||||
@@ -70,6 +72,38 @@ const thumbnailMediaItems = computed(() => (mediaItems.value || []).filter((item
|
||||
|
||||
const scopeItems = computed(() => (activeTab.value === 'thumbnails' ? thumbnailMediaItems.value : libraryMediaItems.value))
|
||||
|
||||
const mediaKindFilterOptions = computed(() => {
|
||||
const baseItems = scopeItems.value.filter((item) => {
|
||||
const folder = activeFolder.value
|
||||
|
||||
return activeTab.value === 'thumbnails'
|
||||
? true
|
||||
: (!folder || item.category === folder || item.category?.startsWith(`${folder}/`))
|
||||
})
|
||||
const countByKind = baseItems.reduce((counts, item) => {
|
||||
const kind = getMediaItemKind(item)
|
||||
counts[kind] = (counts[kind] || 0) + 1
|
||||
return counts
|
||||
}, {})
|
||||
|
||||
return [
|
||||
{ id: 'all', label: '전체', count: baseItems.length },
|
||||
{ id: 'image', label: '이미지', count: countByKind.image || 0 },
|
||||
{ id: 'video', label: '영상', count: countByKind.video || 0 },
|
||||
{ id: 'audio', label: '음악', count: countByKind.audio || 0 },
|
||||
{ id: 'file', label: '파일', count: countByKind.file || 0 }
|
||||
]
|
||||
})
|
||||
|
||||
const unusedMediaCount = computed(() => scopeItems.value.filter((item) => {
|
||||
const folder = activeFolder.value
|
||||
const matchesFolder = activeTab.value === 'thumbnails'
|
||||
? true
|
||||
: (!folder || item.category === folder || item.category?.startsWith(`${folder}/`))
|
||||
|
||||
return matchesFolder && !isMediaItemLocked(item)
|
||||
}).length)
|
||||
|
||||
/**
|
||||
* 상단 탭 전환 시 목록 상태를 초기화한다.
|
||||
* @param {'library' | 'thumbnails'} tab - 선택 탭
|
||||
@@ -83,10 +117,31 @@ const setActiveTab = (tab) => {
|
||||
activeTab.value = tab
|
||||
activeFolder.value = ''
|
||||
searchText.value = ''
|
||||
activeMediaKind.value = 'all'
|
||||
showUnusedOnly.value = false
|
||||
clearMediaSelection()
|
||||
closeMediaDetail()
|
||||
}
|
||||
|
||||
/**
|
||||
* 미디어 종류 필터를 선택한다.
|
||||
* @param {'all'|'image'|'video'|'audio'|'file'} kind - 선택할 미디어 종류
|
||||
* @returns {void}
|
||||
*/
|
||||
const setMediaKindFilter = (kind) => {
|
||||
activeMediaKind.value = kind
|
||||
clearMediaSelection()
|
||||
}
|
||||
|
||||
/**
|
||||
* 미사용 미디어만 보기 필터를 토글한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const toggleUnusedMediaFilter = () => {
|
||||
showUnusedOnly.value = !showUnusedOnly.value
|
||||
clearMediaSelection()
|
||||
}
|
||||
|
||||
/**
|
||||
* ISO 시각을 짧은 로캘 문자열로 표시한다.
|
||||
* @param {string | null} iso - ISO 시각
|
||||
@@ -164,6 +219,8 @@ const folderMediaCounts = computed(() => normalizedFolders.value.reduce((counts,
|
||||
const filteredMediaItems = computed(() => {
|
||||
const query = searchText.value.trim().toLowerCase()
|
||||
const folder = activeFolder.value
|
||||
const mediaKind = activeMediaKind.value
|
||||
const unusedOnly = showUnusedOnly.value
|
||||
const base = scopeItems.value
|
||||
|
||||
return base.filter((item) => {
|
||||
@@ -175,8 +232,10 @@ const filteredMediaItems = computed(() => {
|
||||
item.name,
|
||||
...usageTitles
|
||||
].some((value) => String(value || '').toLowerCase().includes(query))
|
||||
const matchesKind = mediaKind === 'all' || getMediaItemKind(item) === mediaKind
|
||||
const matchesUsage = !unusedOnly || !isMediaItemLocked(item)
|
||||
|
||||
return matchesFolder && matchesQuery
|
||||
return matchesFolder && matchesQuery && matchesKind && matchesUsage
|
||||
})
|
||||
})
|
||||
|
||||
@@ -225,6 +284,8 @@ const isMediaSelected = (item) => selectedMediaUrls.value.includes(item.url)
|
||||
*/
|
||||
const selectFolder = (folder) => {
|
||||
activeFolder.value = folder
|
||||
activeMediaKind.value = 'all'
|
||||
showUnusedOnly.value = false
|
||||
selectedMediaUrls.value = []
|
||||
lastSelectedIndex.value = -1
|
||||
}
|
||||
@@ -583,6 +644,32 @@ const deleteMedia = async (item) => {
|
||||
:placeholder="activeTab === 'thumbnails' ? '파일명, 게시물 제목(사용처) 검색' : '파일명, 게시물 제목(사용처) 검색'"
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
v-if="activeTab === 'library'"
|
||||
class="admin-media__filters flex flex-wrap gap-1.5"
|
||||
>
|
||||
<button
|
||||
v-for="option in mediaKindFilterOptions"
|
||||
:key="option.id"
|
||||
class="admin-media__kind-filter rounded-full border px-3 py-1.5 text-xs font-semibold transition"
|
||||
:class="activeMediaKind === option.id ? 'border-[#15171a] bg-[#15171a] text-white' : 'border-line bg-white text-muted hover:text-ink'"
|
||||
type="button"
|
||||
@click="setMediaKindFilter(option.id)"
|
||||
>
|
||||
{{ option.label }}
|
||||
<span class="ml-1 opacity-70">{{ option.count }}</span>
|
||||
</button>
|
||||
<button
|
||||
class="admin-media__unused-filter rounded-full border px-3 py-1.5 text-xs font-semibold transition"
|
||||
:class="showUnusedOnly ? 'border-[#15171a] bg-[#15171a] text-white' : 'border-line bg-white text-muted hover:text-ink'"
|
||||
type="button"
|
||||
:aria-pressed="showUnusedOnly"
|
||||
@click="toggleUnusedMediaFilter"
|
||||
>
|
||||
미사용
|
||||
<span class="ml-1 opacity-70">{{ unusedMediaCount }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-media__layout mt-8 grid gap-5 lg:grid-cols-[16rem_minmax(0,1fr)]">
|
||||
@@ -733,6 +820,11 @@ const deleteMedia = async (item) => {
|
||||
:src="item.url"
|
||||
:alt="item.title"
|
||||
>
|
||||
<AdminMediaVideoThumbnail
|
||||
v-else-if="getMediaItemKind(item) === 'video'"
|
||||
:src="item.url"
|
||||
:alt="item.title"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="admin-media__image flex aspect-square w-full items-center justify-center bg-surface text-xs font-bold uppercase tracking-[0.18em] text-muted"
|
||||
|
||||
@@ -49,7 +49,7 @@ const setPostFeedStyle = (value) => {
|
||||
const isPostFeedCards = computed(() => postFeedStyle.value === 'cards')
|
||||
|
||||
/** @type {import('vue').ComputedRef<boolean>} */
|
||||
const showPostFeedMedia = computed(() => postFeedStyle.value === 'list' || postFeedStyle.value === 'cards')
|
||||
const showPostFeedMedia = computed(() => postFeedStyle.value === 'compact' || postFeedStyle.value === 'cards')
|
||||
|
||||
/**
|
||||
* Latest 피드 컨테이너 클래스
|
||||
@@ -335,9 +335,12 @@ const scrollFeatured = (direction) => {
|
||||
>
|
||||
<div
|
||||
v-else
|
||||
class="h-full w-full bg-[linear-gradient(135deg,#071b22,#5f6f85)]"
|
||||
/>
|
||||
<h3 class="absolute right-0 bottom-2.5 left-0 px-3 text-sm font-medium leading-tight text-white line-clamp-2">
|
||||
class="post-card-media__placeholder flex h-full w-full items-center justify-center bg-[#F7F4EF] p-5 text-center text-sm font-medium leading-snug text-[var(--site-muted)] transition-all duration-200 group-hover:opacity-90"
|
||||
:aria-label="post.title"
|
||||
>
|
||||
<span class="post-card-media__placeholder-text line-clamp-4">{{ post.title }}</span>
|
||||
</div>
|
||||
<h3 v-if="post.featuredImage" class="absolute right-0 bottom-2.5 left-0 px-3 text-sm font-medium leading-tight text-white line-clamp-2">
|
||||
{{ post.title }}
|
||||
</h3>
|
||||
</NuxtLink>
|
||||
@@ -345,7 +348,7 @@ const scrollFeatured = (direction) => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="py-4 px-6">
|
||||
<section class="latest-posts-section min-h-[360px] py-4 px-6">
|
||||
<div class="mx-auto max-w-[720px]">
|
||||
<div class="flex items-end justify-between gap-2 border-b border-[var(--site-line)] pb-2">
|
||||
<h2 class="text-sm font-medium uppercase site-muted">Latest</h2>
|
||||
|
||||
@@ -247,7 +247,7 @@ const goPreviousStep = () => {
|
||||
<template>
|
||||
<section class="auth-signup min-h-screen bg-[#0a0b0d] text-[#f5f7fa] [color-scheme:dark]">
|
||||
<div class="mx-auto flex min-h-screen w-full max-w-[1280px] items-start px-5 py-12 sm:px-10 sm:py-16 lg:px-16 lg:py-24">
|
||||
<div class="auth-signup__panel flex min-h-[calc(100vh-6rem)] w-full max-w-[430px] flex-col rounded-2xl border border-[#1a212a] bg-[#0d1116] p-5 sm:min-h-[calc(100vh-8rem)] sm:p-8 lg:min-h-[calc(100vh-12rem)]">
|
||||
<div class="auth-signup__panel flex min-h-[calc(100vh-6rem)] w-full max-w-[430px] flex-col rounded-2xl p-5 sm:min-h-[calc(100vh-8rem)] sm:p-8 lg:min-h-[calc(100vh-12rem)]">
|
||||
<div>
|
||||
<template v-if="currentStep === 1">
|
||||
<p class="text-[32px] font-semibold leading-tight sm:text-[40px]">
|
||||
|
||||
Reference in New Issue
Block a user