릴리스: v1.4.38 최고 관리자 보호 및 프리뷰 셸 정리

This commit is contained in:
2026-04-03 10:08:46 +09:00
parent 764e18c16b
commit 713b07a1de
14 changed files with 118 additions and 142 deletions

View File

@@ -43,7 +43,9 @@ const authReady = computed(() => auth.hydrated)
const isAdmin = computed(() => authReady.value && !!auth.user?.isAdmin)
const isPreviewMode = computed(() => route.query.preview === '1')
const isAdminRoute = computed(() => String(route.name || '').startsWith('admin'))
const usesLocalRightRail = computed(() => ['editEditor', 'newEditor'].includes(String(route.name || '')) || isAdminRoute.value)
const usesLocalRightRail = computed(
() => !isPreviewMode.value && (['editEditor', 'newEditor'].includes(String(route.name || '')) || isAdminRoute.value)
)
const isRightRailOverlay = computed(() => viewportWidth.value <= 1200)
const isMobileLayout = computed(() => viewportWidth.value <= 860)
const avatarUrl = computed(() => (auth.user?.avatarSrc ? toApiUrl(auth.user.avatarSrc) : ''))
@@ -424,7 +426,6 @@ function reloadApp() {
<div
class="appShell"
:class="{
'appShell--preview': isPreviewMode,
'appShell--leftCollapsed': leftRailCollapsed,
'appShell--rightClosed': !rightRailOpen,
'appShell--rightOverlay': isRightRailOverlay,
@@ -450,25 +451,6 @@ function reloadApp() {
</section>
</main>
</template>
<template v-else-if="isPreviewMode">
<main class="appMain appMain--preview">
<div class="previewShell">
<div class="previewShell__main">
<RouterView />
</div>
<aside class="previewShell__rail">
<div class="previewShell__railInner">
<RightRailAd class-name="previewShell__ad" />
</div>
<div class="previewShell__footer">
<span>Copyright © 2026 </span>
<a :href="RIGHT_RAIL_COPYRIGHT_URL" target="_blank" rel="noreferrer">zenn</a>
<span>. All rights reserved.</span>
</div>
</aside>
</div>
</main>
</template>
<template v-else>
<aside class="leftRail">
<div class="leftRail__top railHeader">
@@ -711,6 +693,7 @@ function reloadApp() {
}
.backendFallback {
min-width: 100dvw;
min-height: 100dvh;
display: grid;
place-items: center;
@@ -769,10 +752,6 @@ function reloadApp() {
cursor: pointer;
}
.appShell--preview {
display: block;
}
.leftRail,
.rightRail {
min-height: 100dvh;
@@ -1234,47 +1213,6 @@ function reloadApp() {
border-right: 1px solid var(--theme-border);
}
.appMain--preview {
padding: 0;
}
.previewShell {
min-height: 100dvh;
display: grid;
grid-template-columns: minmax(0, 1fr) 325px;
}
.previewShell__main {
min-width: 0;
}
.previewShell__rail {
min-width: 0;
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 20px;
padding: 16px 18px 20px;
border-left: 1px solid var(--theme-border);
background: var(--theme-rail-bg);
}
.previewShell__railInner {
display: grid;
gap: 16px;
}
.previewShell__footer {
font-size: 10px;
line-height: 1.5;
color: var(--theme-text-faint);
}
.previewShell__footer a {
color: #00ffff;
text-decoration: none;
}
.workspace {
display: grid;
grid-template-rows: 56px minmax(0, 1fr);
@@ -1886,14 +1824,6 @@ function reloadApp() {
}
@media (max-width: 1200px) {
.previewShell {
grid-template-columns: 1fr;
}
.previewShell__rail {
display: none;
}
.guideModal__dialog {
grid-template-columns: 1fr;
height: min(860px, calc(100dvh - 40px));

View File

@@ -67,6 +67,7 @@ onMounted(async () => {
.rightRailAd {
display: grid;
gap: 12px;
padding-top: 78px;
}
.rightRailAd__eyebrow {

View File

@@ -15,6 +15,10 @@ const props = defineProps({
userDisplayName: { type: Function, required: true },
userAvatarFallback: { type: Function, required: true },
removeUserAvatar: { type: Function, required: true },
canEditUserAvatar: { type: Function, required: true },
canEditUserInfo: { type: Function, required: true },
canResetUserPassword: { type: Function, required: true },
canDeleteUser: { type: Function, required: true },
roleLabelOf: { type: Function, required: true },
fmt: { type: Function, required: true },
openUserPasswordModal: { type: Function, required: true },
@@ -76,7 +80,12 @@ const userSortDirectionModel = computed({
@change="props.onUserAvatarChange(user, $event)"
/>
<div class="userAvatarWrap">
<button class="userAvatar userAvatarButton" type="button" :disabled="user.isAvatarBusy" @click="props.openUserAvatarPicker(user)">
<button
class="userAvatar userAvatarButton"
type="button"
:disabled="user.isAvatarBusy || !props.canEditUserAvatar(user)"
@click="props.openUserAvatarPicker(user)"
>
<img v-if="props.userAvatarUrl(user)" class="userAvatar__image" :src="props.userAvatarUrl(user)" :alt="props.userDisplayName(user)" />
<span v-else class="userAvatar__fallback">{{ props.userAvatarFallback(user) }}</span>
<span class="userAvatarButton__overlay">{{ user.isAvatarBusy ? '업데이트중...' : '수정' }}</span>
@@ -86,7 +95,7 @@ const userSortDirectionModel = computed({
class="userAvatarRemoveButton"
type="button"
title="회원 썸네일 삭제"
:disabled="user.isAvatarBusy"
:disabled="user.isAvatarBusy || !props.canEditUserAvatar(user)"
@click.stop="props.removeUserAvatar(user)"
>
<SvgIcon class="userAvatarRemoveIcon" :src="props.deleteIcon" :size="12" />
@@ -111,13 +120,32 @@ const userSortDirectionModel = computed({
</div>
<div class="userCard__actions userCard__actions--compact">
<button class="iconActionButton" type="button" title="비밀번호 초기화" @click="props.openUserPasswordModal(user)">
<button
class="iconActionButton"
type="button"
title="비밀번호 초기화"
:disabled="!props.canResetUserPassword(user)"
@click="props.openUserPasswordModal(user)"
>
<SvgIcon class="iconActionButton__icon" :src="props.lockResetIcon" :size="18" />
</button>
<button class="iconActionButton iconActionButton--danger" type="button" title="회원 삭제" @click="props.openUserDeleteModal(user)">
<button
class="iconActionButton iconActionButton--danger"
type="button"
title="회원 삭제"
:disabled="!props.canDeleteUser(user)"
@click="props.openUserDeleteModal(user)"
>
<SvgIcon class="iconActionButton__icon" :src="props.deleteIcon" :size="18" />
</button>
<button class="btn btn--ghost userSaveButton" type="button" @click="props.openUserEditModal(user)">회원 정보 수정</button>
<button
class="btn btn--ghost userSaveButton"
type="button"
:disabled="!props.canEditUserInfo(user)"
@click="props.openUserEditModal(user)"
>
회원 정보 수정
</button>
</div>
</article>
</div>

View File

@@ -38,6 +38,29 @@ export function useAdminUsers({
return !modalTargetUser.value.isPrimaryAdmin
})
function canManageUser(user) {
if (!user?.isPrimaryAdmin) return true
return !!auth.user?.isPrimaryAdmin
}
function canEditUserAvatar(user) {
return canManageUser(user)
}
function canEditUserInfo(user) {
return canManageUser(user)
}
function canResetUserPassword(user) {
return canManageUser(user)
}
function canDeleteUser(user) {
if (!canManageUser(user)) return false
if (user?.isAdmin && !auth.user?.isPrimaryAdmin) return false
return user?.id !== auth.user?.id
}
const isUserEditDirty = computed(() => {
if (!modalTargetUser.value) return false
return (
@@ -54,6 +77,7 @@ export function useAdminUsers({
}
function openUserAvatarPicker(user) {
if (!canEditUserAvatar(user)) return
userAvatarInputs.value[user?.id]?.click()
}
@@ -96,11 +120,13 @@ export function useAdminUsers({
}
async function removeUserAvatar(user) {
if (!canEditUserAvatar(user)) return
if (!user?.avatarSrc) return
await uploadUserAvatar(user, null, { remove: true })
}
function openUserEditModal(user) {
if (!canEditUserInfo(user)) return
resetMessages()
modalTargetUser.value = user ? { ...user } : null
modalUserDraftEmail.value = user?.email || ''
@@ -147,6 +173,7 @@ export function useAdminUsers({
}
function openUserPasswordModal(user) {
if (!canResetUserPassword(user)) return
resetMessages()
modalTargetUser.value = user ? { ...user } : null
modalPasswordDraft.value = ''
@@ -183,6 +210,7 @@ export function useAdminUsers({
}
function openUserDeleteModal(user) {
if (!canDeleteUser(user)) return
resetMessages()
modalTargetUser.value = user ? { ...user } : null
userDeleteModalOpen.value = true
@@ -242,6 +270,10 @@ export function useAdminUsers({
return {
setUserAvatarInput,
canManageModalRole,
canEditUserAvatar,
canEditUserInfo,
canResetUserPassword,
canDeleteUser,
isUserEditDirty,
roleLabelOf,
openUserAvatarPicker,

View File

@@ -1039,6 +1039,10 @@ const {
const {
setUserAvatarInput,
canManageModalRole,
canEditUserAvatar,
canEditUserInfo,
canResetUserPassword,
canDeleteUser,
isUserEditDirty,
roleLabelOf,
openUserAvatarPicker,
@@ -1795,6 +1799,10 @@ function userAvatarFallback(user) {
:user-display-name="userDisplayName"
:user-avatar-fallback="userAvatarFallback"
:remove-user-avatar="removeUserAvatar"
:can-edit-user-avatar="canEditUserAvatar"
:can-edit-user-info="canEditUserInfo"
:can-reset-user-password="canResetUserPassword"
:can-delete-user="canDeleteUser"
:role-label-of="roleLabelOf"
:fmt="fmt"
:open-user-password-modal="openUserPasswordModal"

View File

@@ -9,7 +9,7 @@ import addRowBelowIcon from '../assets/icons/add_row_below.svg'
import addPhotoAlternateIcon from '../assets/icons/add_photo_alternate.svg'
import shareIcon from '../assets/icons/share.svg'
import { api } from '../lib/api'
import { editorNewPath, editorPath, homePath, loginPath, mePath, topicPath } from '../lib/paths'
import { editorNewPath, editorPath, loginPath, mePath, topicPath } from '../lib/paths'
import { toApiUrl } from '../lib/runtime'
import { useAuthStore } from '../stores/auth'
import { useToast } from '../composables/useToast'
@@ -140,8 +140,6 @@ const shareTierListUrl = computed(() => {
if (typeof window === 'undefined') return editorPath(templateId.value, savedTierListId, { preview: true })
return new URL(editorPath(templateId.value, savedTierListId, { preview: true }), window.location.origin).toString()
})
const previewHomeUrl = computed(() => homePath())
watch(error, (message) => {
if (!message) return
toast.error(message)
@@ -973,13 +971,14 @@ onUnmounted(() => {
<template>
<section v-if="previewMode" class="previewOnly" :style="{ '--thumb-size': `${iconSize}px` }">
<header class="pageHead">
<div class="pageHead__main">
<div class="pageHead__eyebrow">Preview</div>
<h1 class="pageHead__title">{{ effectiveTitle }}</h1>
<p v-if="description" class="pageHead__desc">{{ description }}</p>
</div>
</header>
<div class="previewOnly__sheet">
<a class="previewOnly__brand" :href="previewHomeUrl">
<span class="previewOnly__brandMark">TM</span>
<span class="previewOnly__brandText">Tier Maker</span>
</a>
<div class="previewOnly__title">{{ effectiveTitle }}</div>
<div v-if="description" class="previewOnly__description">{{ description }}</div>
<div v-if="columns.length > 1" class="previewOnly__columns">
<div class="previewOnly__columnsSpacer" aria-hidden="true"></div>
<div class="previewOnly__columnsGrid" :style="{ '--column-count': columns.length }">
@@ -1475,55 +1474,18 @@ onUnmounted(() => {
cursor: pointer;
}
.previewOnly {
min-height: 100vh;
padding: 20px;
box-sizing: border-box;
background:
radial-gradient(circle at top, rgba(96, 165, 250, 0.14), transparent 38%),
var(--theme-shell-bg);
display: grid;
gap: 18px;
}
.previewOnly__sheet {
display: grid;
gap: 16px;
width: 100%;
max-width: 1280px;
margin: 0 auto;
}
.previewOnly__brand {
width: fit-content;
display: inline-flex;
align-items: center;
gap: 10px;
text-decoration: none;
color: var(--theme-text-strong);
}
.previewOnly__brandMark {
width: 34px;
height: 34px;
border-radius: 12px;
display: grid;
place-items: center;
background: var(--theme-accent-bg);
color: var(--theme-accent-text);
font-size: 13px;
font-weight: 900;
letter-spacing: -0.04em;
}
.previewOnly__brandText {
font-size: 14px;
font-weight: 900;
letter-spacing: -0.03em;
}
.previewOnly__title {
font-size: 28px;
font-weight: 900;
letter-spacing: -0.03em;
}
.previewOnly__description {
margin-top: -8px;
font-size: 14px;
line-height: 1.6;
opacity: 0.76;
padding: 18px;
border-radius: 24px;
border: 1px solid var(--theme-card-border);
background: var(--theme-card-bg);
box-shadow: inset 0 1px 0 var(--theme-card-shadow);
}
.previewOnly__columns {
display: grid;