Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e3559f4a84 |
@@ -73,6 +73,7 @@ function mapGameRow(row) {
|
|||||||
id: row.id,
|
id: row.id,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
thumbnailSrc: row.thumbnail_src || '',
|
thumbnailSrc: row.thumbnail_src || '',
|
||||||
|
isPublic: row.is_public == null ? true : !!row.is_public,
|
||||||
displayRank: row.display_rank == null ? null : Number(row.display_rank),
|
displayRank: row.display_rank == null ? null : Number(row.display_rank),
|
||||||
createdAt: Number(row.created_at),
|
createdAt: Number(row.created_at),
|
||||||
}
|
}
|
||||||
@@ -256,11 +257,18 @@ async function ensureSchema() {
|
|||||||
id VARCHAR(120) PRIMARY KEY,
|
id VARCHAR(120) PRIMARY KEY,
|
||||||
name VARCHAR(120) NOT NULL,
|
name VARCHAR(120) NOT NULL,
|
||||||
thumbnail_src VARCHAR(255) NOT NULL DEFAULT '',
|
thumbnail_src VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
is_public TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
display_rank INT NULL DEFAULT NULL,
|
display_rank INT NULL DEFAULT NULL,
|
||||||
created_at BIGINT NOT NULL
|
created_at BIGINT NOT NULL
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||||
`)
|
`)
|
||||||
|
|
||||||
|
const gameIsPublicColumns = await query("SHOW COLUMNS FROM games LIKE 'is_public'")
|
||||||
|
if (!gameIsPublicColumns.length) {
|
||||||
|
await query('ALTER TABLE games ADD COLUMN is_public TINYINT(1) NOT NULL DEFAULT 1 AFTER thumbnail_src')
|
||||||
|
await query('UPDATE games SET is_public = 1 WHERE is_public IS NULL')
|
||||||
|
}
|
||||||
|
|
||||||
const displayRankColumns = await query("SHOW COLUMNS FROM games LIKE 'display_rank'")
|
const displayRankColumns = await query("SHOW COLUMNS FROM games LIKE 'display_rank'")
|
||||||
if (!displayRankColumns.length) {
|
if (!displayRankColumns.length) {
|
||||||
await query('ALTER TABLE games ADD COLUMN display_rank INT NULL DEFAULT NULL AFTER thumbnail_src')
|
await query('ALTER TABLE games ADD COLUMN display_rank INT NULL DEFAULT NULL AFTER thumbnail_src')
|
||||||
@@ -643,12 +651,14 @@ async function adminDeleteUser(id) {
|
|||||||
await query('DELETE FROM users WHERE id = ?', [id])
|
await query('DELETE FROM users WHERE id = ?', [id])
|
||||||
}
|
}
|
||||||
|
|
||||||
async function listGames(currentUserId = '') {
|
async function listGames(currentUserId = '', options = {}) {
|
||||||
|
const includePrivate = !!options.includePrivate
|
||||||
const rows = await query(
|
const rows = await query(
|
||||||
`
|
`
|
||||||
SELECT id, name, thumbnail_src, display_rank, created_at
|
SELECT id, name, thumbnail_src, is_public, display_rank, created_at
|
||||||
FROM games
|
FROM games
|
||||||
WHERE id <> ?
|
WHERE id <> ?
|
||||||
|
${includePrivate ? '' : 'AND is_public = 1'}
|
||||||
ORDER BY
|
ORDER BY
|
||||||
CASE WHEN display_rank IS NULL THEN 1 ELSE 0 END ASC,
|
CASE WHEN display_rank IS NULL THEN 1 ELSE 0 END ASC,
|
||||||
display_rank ASC,
|
display_rank ASC,
|
||||||
@@ -669,7 +679,7 @@ async function listGames(currentUserId = '') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function findGameById(id) {
|
async function findGameById(id) {
|
||||||
const rows = await query('SELECT id, name, thumbnail_src, display_rank, created_at FROM games WHERE id = ? LIMIT 1', [id])
|
const rows = await query('SELECT id, name, thumbnail_src, is_public, display_rank, created_at FROM games WHERE id = ? LIMIT 1', [id])
|
||||||
return mapGameRow(rows[0])
|
return mapGameRow(rows[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -702,11 +712,12 @@ async function getGameDetail(gameId) {
|
|||||||
return { game, items }
|
return { game, items }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createGame({ id, name }) {
|
async function createGame({ id, name, isPublic = true }) {
|
||||||
await query('INSERT INTO games (id, name, thumbnail_src, display_rank, created_at) VALUES (?, ?, ?, ?, ?)', [
|
await query('INSERT INTO games (id, name, thumbnail_src, is_public, display_rank, created_at) VALUES (?, ?, ?, ?, ?, ?)', [
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
'',
|
'',
|
||||||
|
isPublic ? 1 : 0,
|
||||||
null,
|
null,
|
||||||
now(),
|
now(),
|
||||||
])
|
])
|
||||||
@@ -718,6 +729,11 @@ async function updateGameThumbnail(gameId, thumbnailSrc) {
|
|||||||
return findGameById(gameId)
|
return findGameById(gameId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function updateGameVisibility(gameId, isPublic) {
|
||||||
|
await query('UPDATE games SET is_public = ? WHERE id = ?', [isPublic ? 1 : 0, gameId])
|
||||||
|
return findGameById(gameId)
|
||||||
|
}
|
||||||
|
|
||||||
async function findImageAssetByHash(contentHash) {
|
async function findImageAssetByHash(contentHash) {
|
||||||
const rows = await query(
|
const rows = await query(
|
||||||
'SELECT id, content_hash, src, label_override, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets WHERE content_hash = ? LIMIT 1',
|
'SELECT id, content_hash, src, label_override, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets WHERE content_hash = ? LIMIT 1',
|
||||||
@@ -2144,6 +2160,7 @@ module.exports = {
|
|||||||
getGameDetail,
|
getGameDetail,
|
||||||
createGame,
|
createGame,
|
||||||
updateGameThumbnail,
|
updateGameThumbnail,
|
||||||
|
updateGameVisibility,
|
||||||
findImageAssetByHash,
|
findImageAssetByHash,
|
||||||
findImageAssetBySrc,
|
findImageAssetBySrc,
|
||||||
findImageAssetById,
|
findImageAssetById,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const {
|
|||||||
createGame,
|
createGame,
|
||||||
listGames,
|
listGames,
|
||||||
updateGameThumbnail,
|
updateGameThumbnail,
|
||||||
|
updateGameVisibility,
|
||||||
createGameItem,
|
createGameItem,
|
||||||
updateGameItemLabel,
|
updateGameItemLabel,
|
||||||
updateGameItemDisplayOrder,
|
updateGameItemDisplayOrder,
|
||||||
@@ -109,13 +110,14 @@ router.post('/games', requireAdmin, async (req, res) => {
|
|||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
id: z.string().min(1),
|
id: z.string().min(1),
|
||||||
name: z.string().min(1).max(60),
|
name: z.string().min(1).max(60),
|
||||||
|
isPublic: z.boolean().optional().default(false),
|
||||||
thumbnailSrc: z.string().max(255).optional().default(''),
|
thumbnailSrc: z.string().max(255).optional().default(''),
|
||||||
})
|
})
|
||||||
const parsed = schema.safeParse(req.body)
|
const parsed = schema.safeParse(req.body)
|
||||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||||
const exists = await findGameById(parsed.data.id)
|
const exists = await findGameById(parsed.data.id)
|
||||||
if (exists) return res.status(409).json({ error: 'game_id_taken' })
|
if (exists) return res.status(409).json({ error: 'game_id_taken' })
|
||||||
const game = await createGame({ id: parsed.data.id, name: parsed.data.name })
|
const game = await createGame({ id: parsed.data.id, name: parsed.data.name, isPublic: parsed.data.isPublic })
|
||||||
if (parsed.data.thumbnailSrc) {
|
if (parsed.data.thumbnailSrc) {
|
||||||
const copiedThumb = await copyUploadIntoGameAsset(parsed.data.thumbnailSrc)
|
const copiedThumb = await copyUploadIntoGameAsset(parsed.data.thumbnailSrc)
|
||||||
await updateGameThumbnail(game.id, copiedThumb)
|
await updateGameThumbnail(game.id, copiedThumb)
|
||||||
@@ -123,6 +125,20 @@ router.post('/games', requireAdmin, async (req, res) => {
|
|||||||
res.json({ game: await findGameById(game.id) })
|
res.json({ game: await findGameById(game.id) })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
router.patch('/games/:gameId', requireAdmin, async (req, res) => {
|
||||||
|
const schema = z.object({
|
||||||
|
isPublic: z.boolean(),
|
||||||
|
})
|
||||||
|
const parsed = schema.safeParse(req.body)
|
||||||
|
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||||
|
|
||||||
|
const game = await findGameById(req.params.gameId)
|
||||||
|
if (!game) return res.status(404).json({ error: 'not_found' })
|
||||||
|
|
||||||
|
const updated = await updateGameVisibility(game.id, parsed.data.isPublic)
|
||||||
|
res.json({ game: updated })
|
||||||
|
})
|
||||||
|
|
||||||
router.patch('/games/display-order', requireAdmin, async (req, res) => {
|
router.patch('/games/display-order', requireAdmin, async (req, res) => {
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
gameIds: z.array(z.string().min(1)).max(50),
|
gameIds: z.array(z.string().min(1)).max(50),
|
||||||
@@ -130,7 +146,7 @@ router.patch('/games/display-order', requireAdmin, async (req, res) => {
|
|||||||
const parsed = schema.safeParse(req.body)
|
const parsed = schema.safeParse(req.body)
|
||||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||||
|
|
||||||
const games = await listGames()
|
const games = await listGames('', { includePrivate: true })
|
||||||
const validGameIds = new Set(games.map((game) => game.id))
|
const validGameIds = new Set(games.map((game) => game.id))
|
||||||
const filteredIds = parsed.data.gameIds.filter((gameId) => validGameIds.has(gameId))
|
const filteredIds = parsed.data.gameIds.filter((gameId) => validGameIds.has(gameId))
|
||||||
const updatedGames = await updateGameDisplayOrder(filteredIds)
|
const updatedGames = await updateGameDisplayOrder(filteredIds)
|
||||||
@@ -516,7 +532,7 @@ function pickTemplateRequestItems(templateRequest, itemIds = [], itemLabels = {}
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function createGameTemplateFromTierList({ tierList, gameId, gameName }) {
|
async function createGameTemplateFromTierList({ tierList, gameId, gameName }) {
|
||||||
await createGame({ id: gameId, name: gameName })
|
await createGame({ id: gameId, name: gameName, isPublic: false })
|
||||||
if (tierList.thumbnailSrc) {
|
if (tierList.thumbnailSrc) {
|
||||||
const copiedThumb = await copyUploadIntoGameAsset(tierList.thumbnailSrc)
|
const copiedThumb = await copyUploadIntoGameAsset(tierList.thumbnailSrc)
|
||||||
await updateGameThumbnail(gameId, copiedThumb)
|
await updateGameThumbnail(gameId, copiedThumb)
|
||||||
@@ -539,7 +555,7 @@ async function createGameTemplateFromTierList({ tierList, gameId, gameName }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function createGameTemplateFromRequest({ templateRequest, gameId, gameName }) {
|
async function createGameTemplateFromRequest({ templateRequest, gameId, gameName }) {
|
||||||
await createGame({ id: gameId, name: gameName })
|
await createGame({ id: gameId, name: gameName, isPublic: false })
|
||||||
|
|
||||||
if (templateRequest.thumbnailSrc) {
|
if (templateRequest.thumbnailSrc) {
|
||||||
const copiedThumb = await copyUploadIntoGameAsset(templateRequest.thumbnailSrc)
|
const copiedThumb = await copyUploadIntoGameAsset(templateRequest.thumbnailSrc)
|
||||||
@@ -697,12 +713,19 @@ router.post('/template-requests/:requestId/approve', requireAdmin, async (req, r
|
|||||||
})
|
})
|
||||||
|
|
||||||
router.post('/template-requests/:requestId/review', requireAdmin, async (req, res) => {
|
router.post('/template-requests/:requestId/review', requireAdmin, async (req, res) => {
|
||||||
const templateRequest = await findTemplateRequestById(req.params.requestId)
|
let templateRequest = await findTemplateRequestById(req.params.requestId)
|
||||||
if (!templateRequest) return res.status(404).json({ error: 'not_found' })
|
if (!templateRequest) return res.status(404).json({ error: 'not_found' })
|
||||||
if (templateRequest.status === 'completed' || templateRequest.status === 'rejected' || templateRequest.status === 'approved') {
|
if (templateRequest.status === 'completed' || templateRequest.status === 'rejected' || templateRequest.status === 'approved') {
|
||||||
return res.status(409).json({ error: 'request_already_handled' })
|
return res.status(409).json({ error: 'request_already_handled' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (templateRequest.type === 'create' && templateRequest.targetGameId && !templateRequest.targetGameName) {
|
||||||
|
templateRequest = await updateTemplateRequestTargetGame({
|
||||||
|
id: templateRequest.id,
|
||||||
|
targetGameId: '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if (templateRequest.status === 'reviewing') {
|
if (templateRequest.status === 'reviewing') {
|
||||||
return res.json({ request: templateRequest })
|
return res.json({ request: templateRequest })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ const { requireAuth } = require('../middleware/auth')
|
|||||||
const router = express.Router()
|
const router = express.Router()
|
||||||
|
|
||||||
router.get('/', async (req, res) => {
|
router.get('/', async (req, res) => {
|
||||||
const games = await listGames(req.session?.userId || '')
|
const games = await listGames(req.session?.userId || '', { includePrivate: !!req.session?.isAdmin })
|
||||||
res.json({ games })
|
res.json({ games })
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -30,6 +30,7 @@ router.delete('/:gameId/favorite', requireAuth, async (req, res) => {
|
|||||||
router.get('/:gameId', async (req, res) => {
|
router.get('/:gameId', async (req, res) => {
|
||||||
const detail = await getGameDetail(req.params.gameId)
|
const detail = await getGameDetail(req.params.gameId)
|
||||||
if (!detail) return res.status(404).json({ error: 'not_found' })
|
if (!detail) return res.status(404).json({ error: 'not_found' })
|
||||||
|
if (!detail.game.isPublic && !req.session?.isAdmin) return res.status(404).json({ error: 'not_found' })
|
||||||
res.json({ game: detail.game, items: detail.items })
|
res.json({ game: detail.game, items: detail.items })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
# 의사결정 이력
|
# 의사결정 이력
|
||||||
|
|
||||||
|
## 2026-04-02 v1.3.60
|
||||||
|
- 관리자 접근 차단은 유지하되, 이미 로그인된 관리자가 새로고침할 때 홈으로 튕기는 체감은 권한 제어보다 더 큰 문제이므로 인증 결과가 나올 때까지 같은 세션 확인 요청을 기다리는 편이 맞다고 정리했다.
|
||||||
|
- 관리자 썸네일 드롭존과 에디터 보드 드롭존은 기능은 같아도 현재 상태가 문구와 형태로 바로 드러나야 하므로, 빈 상태와 교체 상태를 텍스트로 구분하고 점선 박스 형태를 더 적극적으로 드러내는 쪽으로 판단했다.
|
||||||
|
|
||||||
## 2026-04-02 v1.3.59
|
## 2026-04-02 v1.3.59
|
||||||
- 템플릿 요청 아이템 반영은 관리자 주의에만 맡기기보다, 서버에서 같은 게임 안의 동일 `src` 중복 생성을 막고 프런트에서도 이미 반영된 요청 아이템을 다시 드래프트에 올리지 않는 편이 운영 안정성에 더 낫다고 정리했다.
|
- 템플릿 요청 아이템 반영은 관리자 주의에만 맡기기보다, 서버에서 같은 게임 안의 동일 `src` 중복 생성을 막고 프런트에서도 이미 반영된 요청 아이템을 다시 드래프트에 올리지 않는 편이 운영 안정성에 더 낫다고 정리했다.
|
||||||
- 신규 템플릿 요청은 “완전한 임시 게임” 구조로 바로 바꾸기보다, 우선 한 번 만든 게임을 요청과 연결해 다시 `확인하기`를 눌러도 같은 게임을 재사용하게 만드는 편이 리스크 대비 효과가 크다고 판단했다.
|
- 신규 템플릿 요청은 “완전한 임시 게임” 구조로 바로 바꾸기보다, 우선 한 번 만든 게임을 요청과 연결해 다시 `확인하기`를 눌러도 같은 게임을 재사용하게 만드는 편이 리스크 대비 효과가 크다고 판단했다.
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
# 할 일 및 이슈
|
# 할 일 및 이슈
|
||||||
|
|
||||||
|
## 단기 확인
|
||||||
|
- 관리자 계정으로 `/admin/...`을 직접 새로고침했을 때 홈으로 튕기지 않고 그대로 유지되는지, 실제 세션이 살아 있는 브라우저와 만료된 브라우저 각각에서 한 번 더 QA한다.
|
||||||
|
- 티어표 만들기 화면의 보드 드롭존은 점선/높이/중앙 정렬로 존재감이 커졌으므로, 데스크톱과 모바일에서 파일 선택 버튼과 안내 문구 밀도를 한 번 더 QA한다.
|
||||||
|
|
||||||
## 중기 개선
|
## 중기 개선
|
||||||
- 신규 템플릿 요청은 현재 `첫 생성 1회 후 요청과 게임을 연결해 재사용` 단계까지 정리했으므로, 다음 단계에서는 정말로 게임을 DB에 만들기 전까지 임시 작업 상태로 유지할지 여부를 운영 흐름 기준으로 결정한다.
|
- 신규 템플릿 요청은 현재 `첫 생성 1회 후 요청과 게임을 연결해 재사용` 단계까지 정리했으므로, 다음 단계에서는 정말로 게임을 DB에 만들기 전까지 임시 작업 상태로 유지할지 여부를 운영 흐름 기준으로 결정한다.
|
||||||
- 관리자 템플릿 요청은 동일 `src` 중복 생성 방지와 `연결된 게임/이미 반영 n개` 표시까지 붙였으므로, 이후에는 요청별 반영 이력(예: 어떤 아이템을 언제 반영했는지)까지 별도로 남길지 검토한다.
|
- 관리자 템플릿 요청은 동일 `src` 중복 생성 방지와 `연결된 게임/이미 반영 n개` 표시까지 붙였으므로, 이후에는 요청별 반영 이력(예: 어떤 아이템을 언제 반영했는지)까지 별도로 남길지 검토한다.
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# 업데이트 로그
|
# 업데이트 로그
|
||||||
|
|
||||||
|
## 2026-04-02 v1.3.60
|
||||||
|
- 관리자 게임 관리의 대표 썸네일 드롭존은 이제 썸네일이 없을 때는 `클릭 & 드래그`, 이미 등록된 썸네일이 있을 때는 `썸네일 변경`으로 문구가 바뀌어 현재 동작을 더 바로 읽을 수 있게 함.
|
||||||
|
- 관리자 인증 상태는 라우터 가드와 앱 셸이 동시에 `/api/auth/me`를 호출할 때, 가드가 아직 끝나지 않은 요청을 기다리지 못해 새로고침 직후 홈으로 튕기던 흐름이 있었으므로 인증 스토어에서 진행 중인 `refresh` Promise를 재사용하도록 정리함.
|
||||||
|
- 따라서 관리자 계정으로 로그인된 상태에서는 `/admin/...` 경로를 새로고침해도 세션 확인이 끝날 때까지 같은 요청을 기다린 뒤 관리자 화면에 남도록 안정성을 보강함.
|
||||||
|
- 티어표 만들기 화면의 보드 드롭존은 점선 테두리, 더 높은 박스, 중앙 정렬된 안내 문구와 버튼을 적용해 커스텀 이미지 추가 영역임을 더 즉시 인식할 수 있게 조정함.
|
||||||
|
|
||||||
## 2026-04-02 v1.3.59
|
## 2026-04-02 v1.3.59
|
||||||
- 관리자 템플릿 요청의 `promote-items` 처리에서는 잘못된 `z.record` 스키마 때문에 500이 나던 서버 파싱 버그를 수정하고, 요청 아이템 `src`까지 함께 받아 실제 요청 데이터와 더 안정적으로 매칭하도록 보강함.
|
- 관리자 템플릿 요청의 `promote-items` 처리에서는 잘못된 `z.record` 스키마 때문에 500이 나던 서버 파싱 버그를 수정하고, 요청 아이템 `src`까지 함께 받아 실제 요청 데이터와 더 안정적으로 매칭하도록 보강함.
|
||||||
- 요청 아이템을 게임에 반영할 때는 이제 같은 게임 안에 동일한 `src`가 이미 있으면 새 기본 아이템을 다시 만들지 않도록 막고, 관리자 화면에서도 이미 반영된 요청 아이템은 드래프트에 다시 올리지 않게 정리함.
|
- 요청 아이템을 게임에 반영할 때는 이제 같은 게임 안에 동일한 `src`가 이미 있으면 새 기본 아이템을 다시 만들지 않도록 막고, 관리자 화면에서도 이미 반영된 요청 아이템은 드래프트에 다시 올리지 않게 정리함.
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export function useAdminGameManager({
|
|||||||
customItemModalTargetGameId,
|
customItemModalTargetGameId,
|
||||||
newGameId,
|
newGameId,
|
||||||
newGameName,
|
newGameName,
|
||||||
|
newGameIsPublic,
|
||||||
clearPreviewUrl,
|
clearPreviewUrl,
|
||||||
resetFileInput,
|
resetFileInput,
|
||||||
resetUploadState,
|
resetUploadState,
|
||||||
@@ -116,9 +117,10 @@ export function useAdminGameManager({
|
|||||||
uploadFiles.value = uploadItemDrafts.value.filter((draft) => draft.kind === 'file').map((draft) => draft.file).filter(Boolean)
|
uploadFiles.value = uploadItemDrafts.value.filter((draft) => draft.kind === 'file').map((draft) => draft.file).filter(Boolean)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadGame() {
|
async function loadGame(options = {}) {
|
||||||
|
const preserveUploadState = !!options.preserveUploadState
|
||||||
resetMessages()
|
resetMessages()
|
||||||
resetUploadState()
|
if (!preserveUploadState) resetUploadState()
|
||||||
|
|
||||||
if (!selectedGameId.value) {
|
if (!selectedGameId.value) {
|
||||||
selectedGame.value = null
|
selectedGame.value = null
|
||||||
@@ -140,7 +142,6 @@ export function useAdminGameManager({
|
|||||||
savedGameItemOrderIds.value = (data.items || []).map((item) => item.id)
|
savedGameItemOrderIds.value = (data.items || []).map((item) => item.id)
|
||||||
await syncGameItemSortable()
|
await syncGameItemSortable()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[AdminView] loadGame failed', selectedGameId.value, e)
|
|
||||||
selectedGame.value = null
|
selectedGame.value = null
|
||||||
error.value = '게임 정보를 불러오지 못했어요.'
|
error.value = '게임 정보를 불러오지 못했어요.'
|
||||||
} finally {
|
} finally {
|
||||||
@@ -148,7 +149,10 @@ export function useAdminGameManager({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createGame() {
|
async function createGame(options = {}) {
|
||||||
|
const nextGameId = typeof options.gameId === 'string' ? options.gameId.trim() : newGameId.value.trim()
|
||||||
|
const nextGameName = typeof options.gameName === 'string' ? options.gameName.trim() : newGameName.value.trim()
|
||||||
|
const preserveUploadState = !!options.preserveUploadState
|
||||||
resetMessages()
|
resetMessages()
|
||||||
try {
|
try {
|
||||||
const res = await fetch(toApiUrl('/api/admin/games'), {
|
const res = await fetch(toApiUrl('/api/admin/games'), {
|
||||||
@@ -156,8 +160,9 @@ export function useAdminGameManager({
|
|||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
id: newGameId.value.trim(),
|
id: nextGameId,
|
||||||
name: newGameName.value.trim(),
|
name: nextGameName,
|
||||||
|
isPublic: !!newGameIsPublic.value,
|
||||||
thumbnailSrc: activeTemplateRequest.value?.type === 'create' ? (activeTemplateRequest.value?.thumbnailSrc || '') : '',
|
thumbnailSrc: activeTemplateRequest.value?.type === 'create' ? (activeTemplateRequest.value?.thumbnailSrc || '') : '',
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
@@ -171,14 +176,14 @@ export function useAdminGameManager({
|
|||||||
activeTemplateRequest.value = {
|
activeTemplateRequest.value = {
|
||||||
...activeTemplateRequest.value,
|
...activeTemplateRequest.value,
|
||||||
targetGameId: linkData.request?.targetGameId || data.game.id,
|
targetGameId: linkData.request?.targetGameId || data.game.id,
|
||||||
targetGameName: linkData.request?.targetGameName || data.game.name || newGameName.value.trim(),
|
targetGameName: linkData.request?.targetGameName || data.game.name || nextGameName,
|
||||||
}
|
}
|
||||||
const requestIndex = templateRequests.value.findIndex((entry) => entry.id === activeTemplateRequest.value.id)
|
const requestIndex = templateRequests.value.findIndex((entry) => entry.id === activeTemplateRequest.value.id)
|
||||||
if (requestIndex >= 0) {
|
if (requestIndex >= 0) {
|
||||||
templateRequests.value.splice(requestIndex, 1, {
|
templateRequests.value.splice(requestIndex, 1, {
|
||||||
...templateRequests.value[requestIndex],
|
...templateRequests.value[requestIndex],
|
||||||
targetGameId: linkData.request?.targetGameId || data.game.id,
|
targetGameId: linkData.request?.targetGameId || data.game.id,
|
||||||
targetGameName: linkData.request?.targetGameName || data.game.name || newGameName.value.trim(),
|
targetGameName: linkData.request?.targetGameName || data.game.name || nextGameName,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -186,8 +191,8 @@ export function useAdminGameManager({
|
|||||||
selectedGameId.value = data.game.id
|
selectedGameId.value = data.game.id
|
||||||
if (customItemModalOpen.value) customItemModalTargetGameId.value = data.game.id
|
if (customItemModalOpen.value) customItemModalTargetGameId.value = data.game.id
|
||||||
closeGameCreateModal()
|
closeGameCreateModal()
|
||||||
await loadGame()
|
await loadGame({ preserveUploadState })
|
||||||
if (activeTemplateRequest.value?.id) {
|
if (!preserveUploadState && activeTemplateRequest.value?.id) {
|
||||||
const sourceRequest = templateRequests.value.find((entry) => entry.id === activeTemplateRequest.value.id) || activeTemplateRequest.value
|
const sourceRequest = templateRequests.value.find((entry) => entry.id === activeTemplateRequest.value.id) || activeTemplateRequest.value
|
||||||
mergeRequestItemsIntoDrafts(sourceRequest)
|
mergeRequestItemsIntoDrafts(sourceRequest)
|
||||||
}
|
}
|
||||||
@@ -243,12 +248,31 @@ export function useAdminGameManager({
|
|||||||
|
|
||||||
async function uploadItem() {
|
async function uploadItem() {
|
||||||
resetMessages()
|
resetMessages()
|
||||||
if (!uploadItemDrafts.value.length || !selectedGameId.value) {
|
if (!uploadItemDrafts.value.length) {
|
||||||
error.value = '아이템 파일을 선택해주세요.'
|
error.value = '아이템 파일을 선택해주세요.'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (!selectedGameId.value && activeTemplateRequest.value?.type === 'create' && !activeTemplateRequest.value?.targetGameId) {
|
||||||
|
const draftGameId = (activeTemplateRequest.value?.draftGameId || '').trim()
|
||||||
|
const draftGameName = (activeTemplateRequest.value?.draftGameName || '').trim()
|
||||||
|
if (!draftGameId || !draftGameName) {
|
||||||
|
error.value = '먼저 신규 템플릿의 게임 이름과 게임 ID를 저장해주세요.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await createGame({
|
||||||
|
gameId: draftGameId,
|
||||||
|
gameName: draftGameName,
|
||||||
|
preserveUploadState: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectedGameId.value) {
|
||||||
|
error.value = '게임을 먼저 선택해주세요.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const fileDrafts = uploadItemDrafts.value.filter((entry) => entry.kind === 'file')
|
const fileDrafts = uploadItemDrafts.value.filter((entry) => entry.kind === 'file')
|
||||||
const requestDrafts = uploadItemDrafts.value.filter((entry) => entry.kind === 'request')
|
const requestDrafts = uploadItemDrafts.value.filter((entry) => entry.kind === 'request')
|
||||||
let uploadCount = 0
|
let uploadCount = 0
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export function useAdminTemplateRequests({
|
|||||||
thumbnailSrc: request.thumbnailSrc || '',
|
thumbnailSrc: request.thumbnailSrc || '',
|
||||||
draftGameId: request.draftGameId || '',
|
draftGameId: request.draftGameId || '',
|
||||||
draftGameName: request.draftGameName || '',
|
draftGameName: request.draftGameName || '',
|
||||||
|
draftGameIsPublic: !!request.draftGameIsPublic,
|
||||||
sourceTierListId: request.sourceTierListId || '',
|
sourceTierListId: request.sourceTierListId || '',
|
||||||
sourceGameId: request.sourceGameId || '',
|
sourceGameId: request.sourceGameId || '',
|
||||||
sourceTierListTitle: request.sourceTierListTitle || '',
|
sourceTierListTitle: request.sourceTierListTitle || '',
|
||||||
@@ -49,24 +50,32 @@ export function useAdminTemplateRequests({
|
|||||||
try {
|
try {
|
||||||
request.isHandling = true
|
request.isHandling = true
|
||||||
const data = await api.startAdminTemplateRequestReview(request.id)
|
const data = await api.startAdminTemplateRequestReview(request.id)
|
||||||
request.status = data.request?.status || 'reviewing'
|
const syncedRequest = {
|
||||||
updateActiveTemplateRequest(request)
|
...request,
|
||||||
|
...(data.request || {}),
|
||||||
|
draftGameId: request.draftGameId || '',
|
||||||
|
draftGameName: request.draftGameName || '',
|
||||||
|
draftGameIsPublic: !!request.draftGameIsPublic,
|
||||||
|
}
|
||||||
|
Object.assign(request, syncedRequest)
|
||||||
|
request.status = syncedRequest.status || 'reviewing'
|
||||||
|
updateActiveTemplateRequest(syncedRequest)
|
||||||
setTab('game-admin')
|
setTab('game-admin')
|
||||||
|
|
||||||
if (request.type === 'create') {
|
if (request.type === 'create') {
|
||||||
const linkedGameId = request.targetGameId || ''
|
const linkedGameId = syncedRequest.targetGameId || ''
|
||||||
if (linkedGameId) {
|
if (linkedGameId) {
|
||||||
await selectAdminGame(linkedGameId)
|
await selectAdminGame(linkedGameId)
|
||||||
} else {
|
} else {
|
||||||
openGameCreateModal()
|
openGameCreateModal()
|
||||||
newGameId.value = (request.draftGameId || '').trim()
|
newGameId.value = (syncedRequest.draftGameId || '').trim()
|
||||||
newGameName.value = (request.draftGameName || '').trim()
|
newGameName.value = (syncedRequest.draftGameName || '').trim()
|
||||||
}
|
}
|
||||||
mergeRequestItemsIntoDrafts(request)
|
mergeRequestItemsIntoDrafts(syncedRequest)
|
||||||
} else {
|
} else {
|
||||||
const nextGameId = request.targetGameId || request.sourceGameId || ''
|
const nextGameId = syncedRequest.targetGameId || syncedRequest.sourceGameId || ''
|
||||||
if (nextGameId) await selectAdminGame(nextGameId)
|
if (nextGameId) await selectAdminGame(nextGameId)
|
||||||
mergeRequestItemsIntoDrafts(request)
|
mergeRequestItemsIntoDrafts(syncedRequest)
|
||||||
}
|
}
|
||||||
success.value = '요청 아이템을 게임 관리 화면으로 가져왔어요. 필요한 항목만 골라서 추가해 주세요.'
|
success.value = '요청 아이템을 게임 관리 화면으로 가져왔어요. 필요한 항목만 골라서 추가해 주세요.'
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ export const api = {
|
|||||||
updateAdminGameDisplayOrder: (payload) => request('/api/admin/games/display-order', { method: 'PATCH', body: payload }),
|
updateAdminGameDisplayOrder: (payload) => request('/api/admin/games/display-order', { method: 'PATCH', body: payload }),
|
||||||
updateAdminGameItemDisplayOrder: (gameId, payload) =>
|
updateAdminGameItemDisplayOrder: (gameId, payload) =>
|
||||||
request(`/api/admin/games/${encodeURIComponent(gameId)}/items/display-order`, { method: 'PATCH', body: payload }),
|
request(`/api/admin/games/${encodeURIComponent(gameId)}/items/display-order`, { method: 'PATCH', body: payload }),
|
||||||
|
updateAdminGame: (gameId, payload) =>
|
||||||
|
request(`/api/admin/games/${encodeURIComponent(gameId)}`, { method: 'PATCH', body: payload }),
|
||||||
updateAdminGameItem: (gameId, itemId, payload) =>
|
updateAdminGameItem: (gameId, itemId, payload) =>
|
||||||
request(`/api/admin/games/${encodeURIComponent(gameId)}/items/${encodeURIComponent(itemId)}`, { method: 'PATCH', body: payload }),
|
request(`/api/admin/games/${encodeURIComponent(gameId)}/items/${encodeURIComponent(itemId)}`, { method: 'PATCH', body: payload }),
|
||||||
listAdminCustomItems: ({ q = '', page = 1, limit = 50, orphanOnly = false } = {}) =>
|
listAdminCustomItems: ({ q = '', page = 1, limit = 50, orphanOnly = false } = {}) =>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { api } from '../lib/api'
|
import { api } from '../lib/api'
|
||||||
|
|
||||||
|
let refreshPromise = null
|
||||||
|
|
||||||
export const useAuthStore = defineStore('auth', {
|
export const useAuthStore = defineStore('auth', {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
user: null,
|
user: null,
|
||||||
@@ -9,19 +11,23 @@ export const useAuthStore = defineStore('auth', {
|
|||||||
}),
|
}),
|
||||||
actions: {
|
actions: {
|
||||||
async refresh() {
|
async refresh() {
|
||||||
if (this.status === 'loading') return this.user
|
if (refreshPromise) return refreshPromise
|
||||||
this.status = 'loading'
|
this.status = 'loading'
|
||||||
try {
|
refreshPromise = (async () => {
|
||||||
const data = await api.me()
|
try {
|
||||||
this.user = data.user
|
const data = await api.me()
|
||||||
return this.user
|
this.user = data.user
|
||||||
} catch (error) {
|
return this.user
|
||||||
this.user = null
|
} catch (error) {
|
||||||
return null
|
this.user = null
|
||||||
} finally {
|
return null
|
||||||
this.status = 'idle'
|
} finally {
|
||||||
this.hydrated = true
|
this.status = 'idle'
|
||||||
}
|
this.hydrated = true
|
||||||
|
refreshPromise = null
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return refreshPromise
|
||||||
},
|
},
|
||||||
async signup(email, password) {
|
async signup(email, password) {
|
||||||
const user = await api.signup({ email, password })
|
const user = await api.signup({ email, password })
|
||||||
@@ -42,4 +48,3 @@ export const useAuthStore = defineStore('auth', {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -96,6 +96,8 @@ const success = ref('')
|
|||||||
|
|
||||||
const newGameId = ref('')
|
const newGameId = ref('')
|
||||||
const newGameName = ref('')
|
const newGameName = ref('')
|
||||||
|
const newGameIsPublic = ref(false)
|
||||||
|
const gameVisibilitySaving = ref(false)
|
||||||
|
|
||||||
const uploadFiles = ref([])
|
const uploadFiles = ref([])
|
||||||
const uploadItemDrafts = ref([])
|
const uploadItemDrafts = ref([])
|
||||||
@@ -662,8 +664,15 @@ function setTierlistsMode(mode) {
|
|||||||
|
|
||||||
function openGameCreateModal() {
|
function openGameCreateModal() {
|
||||||
resetMessages()
|
resetMessages()
|
||||||
newGameId.value = ''
|
if (activeTemplateRequest.value?.type === 'create' && !activeTemplateRequest.value?.targetGameId) {
|
||||||
newGameName.value = ''
|
newGameId.value = activeTemplateRequest.value?.draftGameId || ''
|
||||||
|
newGameName.value = activeTemplateRequest.value?.draftGameName || ''
|
||||||
|
newGameIsPublic.value = !!activeTemplateRequest.value?.draftGameIsPublic
|
||||||
|
} else {
|
||||||
|
newGameId.value = ''
|
||||||
|
newGameName.value = ''
|
||||||
|
newGameIsPublic.value = false
|
||||||
|
}
|
||||||
gameCreateModalOpen.value = true
|
gameCreateModalOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -745,6 +754,7 @@ async function refreshTemplateRequests() {
|
|||||||
request.type === 'create'
|
request.type === 'create'
|
||||||
? `${request.sourceTierListTitle || request.sourceGameName || '새 템플릿'}`
|
? `${request.sourceTierListTitle || request.sourceGameName || '새 템플릿'}`
|
||||||
: request.targetGameName || request.sourceGameName || '',
|
: request.targetGameName || request.sourceGameName || '',
|
||||||
|
draftGameIsPublic: false,
|
||||||
}))
|
}))
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = '템플릿 요청 목록을 불러오지 못했어요.'
|
error.value = '템플릿 요청 목록을 불러오지 못했어요.'
|
||||||
@@ -825,6 +835,7 @@ const {
|
|||||||
customItemModalTargetGameId,
|
customItemModalTargetGameId,
|
||||||
newGameId,
|
newGameId,
|
||||||
newGameName,
|
newGameName,
|
||||||
|
newGameIsPublic,
|
||||||
clearPreviewUrl,
|
clearPreviewUrl,
|
||||||
resetFileInput,
|
resetFileInput,
|
||||||
resetUploadState,
|
resetUploadState,
|
||||||
@@ -1029,6 +1040,53 @@ async function uploadThumbnail() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function saveGameVisibility() {
|
||||||
|
if (!selectedGame.value?.game?.id) return
|
||||||
|
try {
|
||||||
|
gameVisibilitySaving.value = true
|
||||||
|
const data = await api.updateAdminGame(selectedGame.value.game.id, {
|
||||||
|
isPublic: !!selectedGame.value.game.isPublic,
|
||||||
|
})
|
||||||
|
selectedGame.value = {
|
||||||
|
...selectedGame.value,
|
||||||
|
game: {
|
||||||
|
...selectedGame.value.game,
|
||||||
|
...data.game,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
await refreshGames()
|
||||||
|
success.value = data.game?.isPublic ? '게임을 공개 상태로 전환했어요.' : '게임을 비공개 상태로 전환했어요.'
|
||||||
|
return true
|
||||||
|
} catch (e) {
|
||||||
|
error.value = '게임 공개 상태를 저장하지 못했어요.'
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
gameVisibilitySaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleSelectedGameVisibility(nextValue) {
|
||||||
|
if (!selectedGame.value?.game?.id || gameVisibilitySaving.value) return
|
||||||
|
const previous = !!selectedGame.value.game.isPublic
|
||||||
|
selectedGame.value = {
|
||||||
|
...selectedGame.value,
|
||||||
|
game: {
|
||||||
|
...selectedGame.value.game,
|
||||||
|
isPublic: !!nextValue,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const saved = await saveGameVisibility()
|
||||||
|
if (!saved) {
|
||||||
|
selectedGame.value = {
|
||||||
|
...selectedGame.value,
|
||||||
|
game: {
|
||||||
|
...selectedGame.value.game,
|
||||||
|
isPublic: previous,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function removeGameItem(itemId) {
|
async function removeGameItem(itemId) {
|
||||||
resetMessages()
|
resetMessages()
|
||||||
try {
|
try {
|
||||||
@@ -1496,6 +1554,11 @@ function userAvatarFallback(user) {
|
|||||||
/>
|
/>
|
||||||
<span class="field__hint">영문, 숫자, 하이픈 조합 권장 · {{ newGameId.length }}/120자</span>
|
<span class="field__hint">영문, 숫자, 하이픈 조합 권장 · {{ newGameId.length }}/120자</span>
|
||||||
</label>
|
</label>
|
||||||
|
<label class="toggleSwitch">
|
||||||
|
<input v-model="newGameIsPublic" type="checkbox" />
|
||||||
|
<span class="toggleSwitch__label">{{ newGameIsPublic ? '템플릿 공개중' : '비공개 상태' }}</span>
|
||||||
|
<span class="toggleSwitch__track"><span class="toggleSwitch__thumb"></span></span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="modalCard__actions">
|
<div class="modalCard__actions">
|
||||||
<button class="btn btn--ghost" @click="closeGameCreateModal">취소</button>
|
<button class="btn btn--ghost" @click="closeGameCreateModal">취소</button>
|
||||||
@@ -1853,6 +1916,11 @@ function userAvatarFallback(user) {
|
|||||||
<div v-if="hasSelectedGame" class="adminSidebar__group">
|
<div v-if="hasSelectedGame" class="adminSidebar__group">
|
||||||
<div class="selectedGameSidebar__name">{{ selectedGame.game.name }}</div>
|
<div class="selectedGameSidebar__name">{{ selectedGame.game.name }}</div>
|
||||||
<div class="selectedGameSidebar__id">{{ selectedGame.game.id }}</div>
|
<div class="selectedGameSidebar__id">{{ selectedGame.game.id }}</div>
|
||||||
|
<label class="toggleSwitch" :class="{ 'toggleSwitch--disabled': gameVisibilitySaving }">
|
||||||
|
<input :checked="!!selectedGame.game.isPublic" type="checkbox" @change="toggleSelectedGameVisibility($event.target.checked)" />
|
||||||
|
<span class="toggleSwitch__label">{{ selectedGame.game.isPublic ? '템플릿 공개중' : '비공개 상태' }}</span>
|
||||||
|
<span class="toggleSwitch__track"><span class="toggleSwitch__thumb"></span></span>
|
||||||
|
</label>
|
||||||
<input ref="thumbFileInput" type="file" accept="image/*" class="srOnlyInput" @change="onThumb" />
|
<input ref="thumbFileInput" type="file" accept="image/*" class="srOnlyInput" @change="onThumb" />
|
||||||
<button
|
<button
|
||||||
class="thumbDropZone"
|
class="thumbDropZone"
|
||||||
@@ -1867,7 +1935,7 @@ function userAvatarFallback(user) {
|
|||||||
<img v-if="displayThumbnailUrl" class="selectedThumb selectedThumb--sidebar" :src="displayThumbnailUrl" :alt="selectedGame.game.name" />
|
<img v-if="displayThumbnailUrl" class="selectedThumb selectedThumb--sidebar" :src="displayThumbnailUrl" :alt="selectedGame.game.name" />
|
||||||
<div v-else class="selectedThumb selectedThumb--empty selectedThumb--sidebar">대표 썸네일</div>
|
<div v-else class="selectedThumb selectedThumb--empty selectedThumb--sidebar">대표 썸네일</div>
|
||||||
<div class="thumbDropZone__copy">
|
<div class="thumbDropZone__copy">
|
||||||
<div class="thumbDropZone__title">클릭 or 드래그</div>
|
<div class="thumbDropZone__title">{{ displayThumbnailUrl ? '썸네일 변경' : '클릭 & 드래그' }}</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<div class="adminSidebar__actions adminSidebar__actions--stack">
|
<div class="adminSidebar__actions adminSidebar__actions--stack">
|
||||||
@@ -3872,6 +3940,59 @@ function userAvatarFallback(user) {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
opacity: 0.88;
|
opacity: 0.88;
|
||||||
}
|
}
|
||||||
|
.adminUiScope .toggleSwitch {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid var(--theme-border);
|
||||||
|
background: var(--theme-pill-bg);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.adminUiScope .toggleSwitch input {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.adminUiScope .toggleSwitch__track {
|
||||||
|
position: relative;
|
||||||
|
width: 42px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--theme-surface-soft-3);
|
||||||
|
border: 1px solid var(--theme-border-strong);
|
||||||
|
transition: background 180ms ease, border-color 180ms ease;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.adminUiScope .toggleSwitch__thumb {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--theme-text-strong);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.24);
|
||||||
|
transition: transform 180ms ease;
|
||||||
|
}
|
||||||
|
.adminUiScope .toggleSwitch__label {
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--theme-text);
|
||||||
|
}
|
||||||
|
.adminUiScope .toggleSwitch input:checked ~ .toggleSwitch__track {
|
||||||
|
background: rgba(96, 165, 250, 0.34);
|
||||||
|
border-color: rgba(96, 165, 250, 0.42);
|
||||||
|
}
|
||||||
|
.adminUiScope .toggleSwitch input:checked ~ .toggleSwitch__track .toggleSwitch__thumb {
|
||||||
|
transform: translateX(18px);
|
||||||
|
}
|
||||||
|
.adminUiScope .toggleSwitch--disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
.adminUiScope .checkRow--compact {
|
.adminUiScope .checkRow--compact {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2073,9 +2073,25 @@ onUnmounted(() => {
|
|||||||
.dropzone--board {
|
.dropzone--board {
|
||||||
margin-top: 18px;
|
margin-top: 18px;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: center;
|
||||||
gap: 18px;
|
gap: 18px;
|
||||||
|
min-height: 176px;
|
||||||
|
padding: 28px 24px;
|
||||||
|
border: 2px dashed color-mix(in srgb, var(--theme-accent) 48%, var(--theme-border));
|
||||||
|
border-radius: 22px;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, color-mix(in srgb, var(--theme-card-bg) 82%, transparent), color-mix(in srgb, var(--theme-card-bg-hover) 74%, transparent)),
|
||||||
|
radial-gradient(circle at top, color-mix(in srgb, var(--theme-accent) 12%, transparent), transparent 58%);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropzone--board.dropzone--active {
|
||||||
|
border-color: color-mix(in srgb, var(--theme-accent) 78%, white);
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, color-mix(in srgb, var(--theme-card-bg-hover) 88%, transparent), color-mix(in srgb, var(--theme-card-bg) 82%, transparent)),
|
||||||
|
radial-gradient(circle at top, color-mix(in srgb, var(--theme-accent) 20%, transparent), transparent 58%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropzone__actions {
|
.dropzone__actions {
|
||||||
@@ -2083,11 +2099,23 @@ onUnmounted(() => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropzone__button {
|
.dropzone__button {
|
||||||
min-width: 148px;
|
min-width: 148px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dropzone--board .dropzone__title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropzone--board .dropzone__desc {
|
||||||
|
max-width: 520px;
|
||||||
|
color: var(--theme-text-soft);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
.editorSidebar__section {
|
.editorSidebar__section {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
|||||||
Reference in New Issue
Block a user