chore: test deploy snapshot
This commit is contained in:
@@ -144,7 +144,7 @@ export async function registerBaseballTicketBayRoutes(app: FastifyInstance) {
|
||||
app.delete('/api/baseball-ticket-bay/logs/:id', async (request, reply) => {
|
||||
const accessContext = await resolveBaseballTicketBayAccessContext(request);
|
||||
|
||||
if (!accessContext || accessContext.scope === 'all') {
|
||||
if (!accessContext) {
|
||||
return reply.code(400).send({ message: '현재 접근 범위에서는 로그를 삭제할 수 없습니다.' });
|
||||
}
|
||||
|
||||
@@ -197,23 +197,33 @@ export async function registerBaseballTicketBayRoutes(app: FastifyInstance) {
|
||||
app.patch('/api/baseball-ticket-bay/alerts/:id', async (request, reply) => {
|
||||
const accessContext = await resolveBaseballTicketBayAccessContext(request);
|
||||
|
||||
if (!accessContext || accessContext.scope === 'all') {
|
||||
if (!accessContext) {
|
||||
return reply.code(400).send({ message: '현재 접근 범위에서는 알림을 수정할 수 없습니다.' });
|
||||
}
|
||||
|
||||
const params = z.object({ id: z.string().trim().min(1) }).parse(request.params ?? {});
|
||||
const payload = alertPayloadSchema.partial().parse(request.body ?? {});
|
||||
const item = await updateBaseballTicketBayAlert(params.id, payload, {
|
||||
clientId: accessContext.clientId,
|
||||
ownerType: accessContext.scope === 'shared-token' ? 'shared-token' : 'client',
|
||||
ownerId: accessContext.scope === 'shared-token' ? accessContext.tokenId : accessContext.clientId,
|
||||
appOrigin: readHeader(request, 'x-app-origin'),
|
||||
appDomain: readHeader(request, 'x-app-domain'),
|
||||
});
|
||||
const item = await updateBaseballTicketBayAlert(
|
||||
params.id,
|
||||
payload,
|
||||
accessContext.scope === 'all'
|
||||
? {
|
||||
scope: { kind: 'all' },
|
||||
appOrigin: readHeader(request, 'x-app-origin'),
|
||||
appDomain: readHeader(request, 'x-app-domain'),
|
||||
}
|
||||
: {
|
||||
clientId: accessContext.clientId,
|
||||
ownerType: accessContext.scope === 'shared-token' ? 'shared-token' : 'client',
|
||||
ownerId: accessContext.scope === 'shared-token' ? accessContext.tokenId : accessContext.clientId,
|
||||
appOrigin: readHeader(request, 'x-app-origin'),
|
||||
appDomain: readHeader(request, 'x-app-domain'),
|
||||
},
|
||||
);
|
||||
await createBaseballTicketBayLog({
|
||||
clientId: accessContext.clientId,
|
||||
ownerType: accessContext.scope === 'shared-token' ? 'shared-token' : 'client',
|
||||
ownerId: accessContext.scope === 'shared-token' ? accessContext.tokenId : accessContext.clientId,
|
||||
clientId: item.clientId,
|
||||
ownerType: item.ownerType,
|
||||
ownerId: item.ownerId,
|
||||
alertId: item.id,
|
||||
alertTitle: item.title,
|
||||
action: payload.active === false ? 'pause' : payload.active === true ? 'resume' : 'run',
|
||||
@@ -236,7 +246,7 @@ export async function registerBaseballTicketBayRoutes(app: FastifyInstance) {
|
||||
app.delete('/api/baseball-ticket-bay/alerts/:id', async (request, reply) => {
|
||||
const accessContext = await resolveBaseballTicketBayAccessContext(request);
|
||||
|
||||
if (!accessContext || accessContext.scope === 'all') {
|
||||
if (!accessContext) {
|
||||
return reply.code(400).send({ message: '현재 접근 범위에서는 알림을 삭제할 수 없습니다.' });
|
||||
}
|
||||
|
||||
@@ -248,9 +258,9 @@ export async function registerBaseballTicketBayRoutes(app: FastifyInstance) {
|
||||
}
|
||||
|
||||
await createBaseballTicketBayLog({
|
||||
clientId: accessContext.clientId,
|
||||
ownerType: accessContext.scope === 'shared-token' ? 'shared-token' : 'client',
|
||||
ownerId: accessContext.scope === 'shared-token' ? accessContext.tokenId : accessContext.clientId,
|
||||
clientId: item.clientId,
|
||||
ownerType: item.ownerType,
|
||||
ownerId: item.ownerId,
|
||||
alertId: item.id,
|
||||
alertTitle: item.title,
|
||||
action: 'delete',
|
||||
@@ -274,10 +284,6 @@ export async function registerBaseballTicketBayRoutes(app: FastifyInstance) {
|
||||
|
||||
const params = z.object({ id: z.string().trim().min(1) }).parse(request.params ?? {});
|
||||
|
||||
if (accessContext.scope === 'all') {
|
||||
return reply.code(403).send({ message: '전체 보기 범위에서는 즉시 실행할 수 없습니다.' });
|
||||
}
|
||||
|
||||
const result = await runBaseballTicketBayAlert(params.id, {
|
||||
ignoreTimeWindow: true,
|
||||
scope: toOwnerScope(accessContext),
|
||||
|
||||
@@ -7,6 +7,7 @@ import { env } from '../config/env.js';
|
||||
import { registerResourceManagerRoutes, resolveSingleRange } from './resource-manager.js';
|
||||
|
||||
const fallbackResourceRoot = path.resolve(process.cwd(), '../../../resource');
|
||||
const legacyPublicResourceRoot = path.resolve(process.cwd(), '../../../public/resource');
|
||||
|
||||
test('resolveSingleRange parses open-ended and suffix byte ranges', () => {
|
||||
assert.deepEqual(resolveSingleRange('bytes=5-', 20), {
|
||||
@@ -55,3 +56,61 @@ test('resource manager preview serves 206 partial content for byte ranges', asyn
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('resource manager preview falls back to public/resource legacy artifacts', async () => {
|
||||
const app = Fastify();
|
||||
await registerResourceManagerRoutes(app);
|
||||
|
||||
const relativePath = path.join('legacy-preview-test', `sample-${Date.now()}.html`);
|
||||
const absolutePath = path.join(legacyPublicResourceRoot, relativePath);
|
||||
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
|
||||
await fs.writeFile(absolutePath, '<!doctype html><html><body>legacy preview</body></html>', 'utf8');
|
||||
|
||||
try {
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/resource-manager/preview/${relativePath.split(path.sep).map((segment) => encodeURIComponent(segment)).join('/')}?token=${encodeURIComponent(env.SERVER_COMMAND_ACCESS_TOKEN)}`,
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.match(String(response.headers['content-type'] ?? ''), /text\/html/i);
|
||||
assert.match(response.body, /legacy preview/);
|
||||
} finally {
|
||||
await fs.rm(path.join(legacyPublicResourceRoot, 'legacy-preview-test'), { recursive: true, force: true });
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('resource manager preview restores encoded hash fragments in the file name', async () => {
|
||||
const app = Fastify();
|
||||
await registerResourceManagerRoutes(app);
|
||||
|
||||
const relativePath = path.join('encoded-preview-test', `sample-${Date.now()}.html`);
|
||||
const absolutePath = path.join(legacyPublicResourceRoot, relativePath);
|
||||
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
|
||||
await fs.writeFile(absolutePath, '<!doctype html><html><body>encoded hash preview</body></html>', 'utf8');
|
||||
|
||||
try {
|
||||
for (const encodedSuffix of ['%23option-a', '%2523option-a']) {
|
||||
const encodedPath = relativePath
|
||||
.split(path.sep)
|
||||
.map((segment, index, list) =>
|
||||
index === list.length - 1
|
||||
? `${encodeURIComponent(segment)}${encodedSuffix}`
|
||||
: encodeURIComponent(segment),
|
||||
)
|
||||
.join('/');
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/resource-manager/preview/${encodedPath}?token=${encodeURIComponent(env.SERVER_COMMAND_ACCESS_TOKEN)}`,
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.match(String(response.headers['content-type'] ?? ''), /text\/html/i);
|
||||
assert.match(response.body, /encoded hash preview/);
|
||||
}
|
||||
} finally {
|
||||
await fs.rm(path.join(legacyPublicResourceRoot, 'encoded-preview-test'), { recursive: true, force: true });
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Knex } from 'knex';
|
||||
import { z } from 'zod';
|
||||
import { db } from '../db/client.js';
|
||||
import { sendNotifications } from './notification-service.js';
|
||||
import { getSharedResourceTokenDetail } from './shared-resource-token-service.js';
|
||||
|
||||
const TICKET_BAY_ORIGIN = 'https://www.ticketbay.co.kr';
|
||||
const SPORTS_SEARCH_KEY = '5';
|
||||
@@ -695,6 +696,7 @@ export type BaseballTicketBayAlertItem = {
|
||||
clientId: string;
|
||||
ownerType: BaseballTicketBayOwnerType;
|
||||
ownerId: string;
|
||||
ownerLabel?: string | null;
|
||||
title: string;
|
||||
eventDate: string;
|
||||
team: string;
|
||||
@@ -722,6 +724,7 @@ export type BaseballTicketBayAlertLogItem = {
|
||||
clientId: string;
|
||||
ownerType: BaseballTicketBayOwnerType;
|
||||
ownerId: string;
|
||||
ownerLabel?: string | null;
|
||||
alertId: string | null;
|
||||
alertTitle: string;
|
||||
action: BaseballTicketBayAlertLogAction;
|
||||
@@ -902,6 +905,7 @@ function mapAlertRow(row: BaseballTicketBayAlertRow): BaseballTicketBayAlertItem
|
||||
clientId: normalizeText(row.client_id),
|
||||
ownerType: normalizeOwnerType(row.owner_type),
|
||||
ownerId: normalizeOwnerId(row),
|
||||
ownerLabel: null,
|
||||
title: normalizeText(row.title),
|
||||
eventDate: normalizeText(row.event_date),
|
||||
team: normalizeText(row.team) || '전체',
|
||||
@@ -938,6 +942,7 @@ function mapLogRow(row: BaseballTicketBayLogRow): BaseballTicketBayAlertLogItem
|
||||
clientId: normalizeText(row.client_id),
|
||||
ownerType: normalizeOwnerType(row.owner_type),
|
||||
ownerId: normalizeOwnerId(row),
|
||||
ownerLabel: null,
|
||||
alertId: row.alert_id ? normalizeText(row.alert_id) : null,
|
||||
alertTitle: normalizeText(row.alert_title),
|
||||
action: row.action,
|
||||
@@ -949,6 +954,34 @@ function mapLogRow(row: BaseballTicketBayLogRow): BaseballTicketBayAlertLogItem
|
||||
};
|
||||
}
|
||||
|
||||
async function attachOwnerLabels<T extends { ownerType: BaseballTicketBayOwnerType; ownerId: string; ownerLabel?: string | null }>(items: T[]) {
|
||||
const sharedTokenOwnerIds = Array.from(
|
||||
new Set(
|
||||
items
|
||||
.filter((item) => item.ownerType === 'shared-token')
|
||||
.map((item) => normalizeText(item.ownerId))
|
||||
.filter(Boolean),
|
||||
),
|
||||
);
|
||||
|
||||
if (!sharedTokenOwnerIds.length) {
|
||||
return items;
|
||||
}
|
||||
|
||||
const labelEntries = await Promise.all(
|
||||
sharedTokenOwnerIds.map(async (tokenId) => {
|
||||
const detail = await getSharedResourceTokenDetail(tokenId);
|
||||
return [tokenId, detail?.token.resourceLabel?.trim() || detail?.token.name?.trim() || null] as const;
|
||||
}),
|
||||
);
|
||||
const labelMap = new Map(labelEntries);
|
||||
|
||||
return items.map((item) => ({
|
||||
...item,
|
||||
ownerLabel: item.ownerType === 'shared-token' ? labelMap.get(normalizeText(item.ownerId)) ?? null : null,
|
||||
}));
|
||||
}
|
||||
|
||||
function formatTimeInKst(date: Date) {
|
||||
return new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: 'Asia/Seoul',
|
||||
@@ -1162,7 +1195,7 @@ export async function listBaseballTicketBayAlerts(scope: BaseballTicketBayOwnerS
|
||||
{ column: 'client_id', order: 'asc' },
|
||||
{ column: 'created_at', order: 'desc' },
|
||||
])) as BaseballTicketBayAlertRow[];
|
||||
return rows.map((row: BaseballTicketBayAlertRow) => mapAlertRow(row));
|
||||
return attachOwnerLabels(rows.map((row: BaseballTicketBayAlertRow) => mapAlertRow(row)));
|
||||
}
|
||||
|
||||
export async function listBaseballTicketBayLogs(
|
||||
@@ -1177,7 +1210,7 @@ export async function listBaseballTicketBayLogs(
|
||||
}
|
||||
|
||||
const rows = (await query) as BaseballTicketBayLogRow[];
|
||||
return rows.map((row: BaseballTicketBayLogRow) => mapLogRow(row));
|
||||
return attachOwnerLabels(rows.map((row: BaseballTicketBayLogRow) => mapLogRow(row)));
|
||||
}
|
||||
|
||||
export async function createBaseballTicketBayLog(args: {
|
||||
@@ -1208,7 +1241,7 @@ export async function createBaseballTicketBayLog(args: {
|
||||
payload_json: args.payload ? JSON.stringify(args.payload) : null,
|
||||
};
|
||||
await db(BASEBALL_TICKET_BAY_LOG_TABLE).insert(row);
|
||||
return mapLogRow(row);
|
||||
return attachOwnerLabels([mapLogRow(row)]).then(([item]) => item);
|
||||
}
|
||||
|
||||
export async function deleteBaseballTicketBayLog(logId: string, scope: BaseballTicketBayOwnerScope) {
|
||||
@@ -1234,7 +1267,7 @@ export async function deleteBaseballTicketBayLog(logId: string, scope: BaseballT
|
||||
scope,
|
||||
).delete();
|
||||
|
||||
return mapLogRow(existing as BaseballTicketBayLogRow);
|
||||
return attachOwnerLabels([mapLogRow(existing as BaseballTicketBayLogRow)]).then(([item]) => item);
|
||||
}
|
||||
|
||||
export async function createBaseballTicketBayAlert(
|
||||
@@ -1269,20 +1302,26 @@ export async function createBaseballTicketBayAlert(
|
||||
last_match_at: null,
|
||||
};
|
||||
await db(BASEBALL_TICKET_BAY_ALERT_TABLE).insert(row);
|
||||
return mapAlertRow(row);
|
||||
return attachOwnerLabels([mapAlertRow(row)]).then(([item]) => item);
|
||||
}
|
||||
|
||||
export async function updateBaseballTicketBayAlert(
|
||||
alertId: string,
|
||||
payload: Partial<BaseballTicketBayAlertMutation>,
|
||||
context: { clientId: string; ownerType: BaseballTicketBayOwnerType; ownerId: string; appOrigin?: string; appDomain?: string },
|
||||
context:
|
||||
| { scope: BaseballTicketBayOwnerScope; appOrigin?: string; appDomain?: string }
|
||||
| { clientId: string; ownerType: BaseballTicketBayOwnerType; ownerId: string; appOrigin?: string; appDomain?: string },
|
||||
) {
|
||||
await ensureBaseballTicketBayTables();
|
||||
const scope =
|
||||
'scope' in context
|
||||
? context.scope
|
||||
: ({ kind: 'owner', ownerType: context.ownerType, ownerId: context.ownerId } as const);
|
||||
const current = await applyOwnerScope(
|
||||
db(BASEBALL_TICKET_BAY_ALERT_TABLE)
|
||||
.select('*')
|
||||
.where({ id: alertId }),
|
||||
{ kind: 'owner', ownerType: context.ownerType, ownerId: context.ownerId },
|
||||
scope,
|
||||
).first();
|
||||
|
||||
if (!current) {
|
||||
@@ -1309,18 +1348,15 @@ export async function updateBaseballTicketBayAlert(
|
||||
if (context.appOrigin) patch.app_origin = normalizeText(context.appOrigin);
|
||||
if (context.appDomain) patch.app_domain = normalizeText(context.appDomain);
|
||||
|
||||
await applyOwnerScope(
|
||||
db(BASEBALL_TICKET_BAY_ALERT_TABLE).where({ id: alertId }),
|
||||
{ kind: 'owner', ownerType: context.ownerType, ownerId: context.ownerId },
|
||||
).update(patch);
|
||||
await applyOwnerScope(db(BASEBALL_TICKET_BAY_ALERT_TABLE).where({ id: alertId }), scope).update(patch);
|
||||
|
||||
const updated = await applyOwnerScope(
|
||||
db(BASEBALL_TICKET_BAY_ALERT_TABLE)
|
||||
.select('*')
|
||||
.where({ id: alertId }),
|
||||
{ kind: 'owner', ownerType: context.ownerType, ownerId: context.ownerId },
|
||||
scope,
|
||||
).first();
|
||||
return mapAlertRow(updated as BaseballTicketBayAlertRow);
|
||||
return attachOwnerLabels([mapAlertRow(updated as BaseballTicketBayAlertRow)]).then(([item]) => item);
|
||||
}
|
||||
|
||||
export async function deleteBaseballTicketBayAlert(alertId: string, scope: BaseballTicketBayOwnerScope) {
|
||||
@@ -1340,7 +1376,7 @@ export async function deleteBaseballTicketBayAlert(alertId: string, scope: Baseb
|
||||
db(BASEBALL_TICKET_BAY_ALERT_TABLE).where({ id: alertId }),
|
||||
scope,
|
||||
).delete();
|
||||
return mapAlertRow(row as BaseballTicketBayAlertRow);
|
||||
return attachOwnerLabels([mapAlertRow(row as BaseballTicketBayAlertRow)]).then(([item]) => item);
|
||||
}
|
||||
|
||||
async function getAlertRow(alertId: string, scope?: BaseballTicketBayOwnerScope) {
|
||||
|
||||
@@ -105,9 +105,103 @@ function unwrapMarkdownLinkTarget(value: string) {
|
||||
return matched?.[1]?.trim() ?? normalized;
|
||||
}
|
||||
|
||||
function normalizeResourceManagerPathSegment(segment: string) {
|
||||
const normalized = normalizeText(segment);
|
||||
|
||||
if (!normalized) {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
return encodeURIComponent(decodeURIComponent(normalized));
|
||||
} catch {
|
||||
return encodeURIComponent(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeUrlFragmentValue(value: string) {
|
||||
const normalized = normalizeText(value).replace(/^#+/, '');
|
||||
|
||||
if (!normalized) {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
return decodeURIComponent(normalized);
|
||||
} catch {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
function decodeRepeatedly(value: string, maxIterations = 3) {
|
||||
let current = value;
|
||||
|
||||
for (let index = 0; index < maxIterations; index += 1) {
|
||||
try {
|
||||
const decoded = decodeURIComponent(current);
|
||||
|
||||
if (!decoded || decoded === current) {
|
||||
break;
|
||||
}
|
||||
|
||||
current = decoded;
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
function normalizePreviewPathHash(value: string) {
|
||||
const normalized = normalizeText(value);
|
||||
|
||||
if (!normalized) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const isAbsoluteUrl = /^[a-z][a-z0-9+.-]*:\/\//i.test(normalized);
|
||||
const isRootRelative = normalized.startsWith('/');
|
||||
|
||||
try {
|
||||
const parsed = new URL(normalized, 'https://local.invalid');
|
||||
const segments = parsed.pathname.split('/');
|
||||
const lastSegment = segments.at(-1) ?? '';
|
||||
const decodedLastSegment = decodeRepeatedly(lastSegment);
|
||||
const hashIndex = decodedLastSegment.lastIndexOf('#');
|
||||
|
||||
if (hashIndex <= 0 || parsed.hash) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const fileName = decodedLastSegment.slice(0, hashIndex).trim();
|
||||
const fragment = normalizeUrlFragmentValue(decodedLastSegment.slice(hashIndex + 1));
|
||||
|
||||
if (!fileName || !fragment || !/\.[a-z0-9]{1,16}$/i.test(fileName)) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
segments[segments.length - 1] = encodeURIComponent(fileName);
|
||||
parsed.pathname = segments.join('/');
|
||||
parsed.hash = fragment;
|
||||
|
||||
if (isAbsoluteUrl) {
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
const pathWithQueryAndHash = `${parsed.pathname}${parsed.search}${parsed.hash}`;
|
||||
return isRootRelative ? pathWithQueryAndHash : pathWithQueryAndHash.replace(/^\/+/, '');
|
||||
} catch {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
function buildResourceManagerPreviewUrl(value: string) {
|
||||
const normalized = normalizeText(value).replace(/\\/g, '/');
|
||||
const matchedResourcePath = normalized.match(/(?:^|\/)(resource\/.+)$/i)?.[1];
|
||||
const normalized = normalizePreviewPathHash(normalizeText(value).replace(/\\/g, '/'));
|
||||
const hashIndex = normalized.indexOf('#');
|
||||
const hash = hashIndex >= 0 ? normalizeUrlFragmentValue(normalized.slice(hashIndex + 1)) : '';
|
||||
const normalizedPath = hashIndex >= 0 ? normalized.slice(0, hashIndex) : normalized;
|
||||
const matchedResourcePath = normalizedPath.match(/(?:^|\/)(resource\/.+)$/i)?.[1];
|
||||
const resourcePath = normalizeText(matchedResourcePath).replace(/^\/+/, '');
|
||||
|
||||
if (!resourcePath) {
|
||||
@@ -123,10 +217,10 @@ function buildResourceManagerPreviewUrl(value: string) {
|
||||
const encodedPath = relativePath
|
||||
.split('/')
|
||||
.filter(Boolean)
|
||||
.map((segment) => encodeURIComponent(segment))
|
||||
.map((segment) => normalizeResourceManagerPathSegment(segment))
|
||||
.join('/');
|
||||
|
||||
return encodedPath ? `${RESOURCE_MANAGER_PREVIEW_MARKER}${encodedPath}` : '';
|
||||
return encodedPath ? `${RESOURCE_MANAGER_PREVIEW_MARKER}${encodedPath}${hash ? `#${hash}` : ''}` : '';
|
||||
}
|
||||
|
||||
function normalizeUrl(value: string) {
|
||||
@@ -136,6 +230,17 @@ function normalizeUrl(value: string) {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(normalized, 'https://local.invalid');
|
||||
const pathname = `${parsed.pathname || ''}${parsed.search || ''}${parsed.hash || ''}`;
|
||||
|
||||
if (pathname.startsWith(CHAT_API_RESOURCE_MARKER) || pathname.startsWith(RESOURCE_MANAGER_PREVIEW_MARKER)) {
|
||||
return normalizePreviewPathHash(pathname);
|
||||
}
|
||||
} catch {
|
||||
// Fall through to handle relative and embedded resource paths below.
|
||||
}
|
||||
|
||||
const malformedResourceMatch = normalized.match(/^https?:\/(api\/chat\/resources\/.+)$/i);
|
||||
if (malformedResourceMatch?.[1]) {
|
||||
return `/${malformedResourceMatch[1]}`;
|
||||
|
||||
@@ -1416,6 +1416,96 @@ test('extractChatMessageParts keeps prompt preview payloads for image markdown h
|
||||
);
|
||||
});
|
||||
|
||||
test('extractChatMessageParts preserves resource preview hash fragments when converting resource paths', () => {
|
||||
assert.deepEqual(
|
||||
extractChatMessageParts(
|
||||
'[[prompt:{"title":"시안 선택","options":[{"label":"A안","value":"option-a","preview":{"type":"resource","url":"resource/Codex Live/공유채팅/채팅방 헤더 재배치 제안/20260527/docs/chat-room-header-notification-preview.html#option-a"}}]}]]',
|
||||
),
|
||||
{
|
||||
strippedText: '',
|
||||
parts: [
|
||||
{
|
||||
type: 'prompt',
|
||||
title: '시안 선택',
|
||||
description: null,
|
||||
submitLabel: null,
|
||||
mode: null,
|
||||
multiple: false,
|
||||
responseTemplate: null,
|
||||
freeTextLabel: null,
|
||||
freeTextPlaceholder: null,
|
||||
currentStepKey: null,
|
||||
readOnly: false,
|
||||
selectedValues: [],
|
||||
resolvedBy: null,
|
||||
resolvedAt: null,
|
||||
resultText: null,
|
||||
steps: undefined,
|
||||
options: [
|
||||
{
|
||||
label: 'A안',
|
||||
value: 'option-a',
|
||||
description: null,
|
||||
preview: {
|
||||
type: 'resource',
|
||||
url: '/api/resource-manager/preview/Codex%20Live/%EA%B3%B5%EC%9C%A0%EC%B1%84%ED%8C%85/%EC%B1%84%ED%8C%85%EB%B0%A9%20%ED%97%A4%EB%8D%94%20%EC%9E%AC%EB%B0%B0%EC%B9%98%20%EC%A0%9C%EC%95%88/20260527/docs/chat-room-header-notification-preview.html#option-a',
|
||||
content: null,
|
||||
alt: null,
|
||||
title: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('extractChatMessageParts restores encoded resource preview hash fragments', () => {
|
||||
assert.deepEqual(
|
||||
extractChatMessageParts(
|
||||
'[[prompt:{"title":"시안 선택","options":[{"label":"A안","value":"option-a","preview":{"type":"resource","url":"resource/Codex Live/공유채팅/채팅방 헤더 재배치 제안/20260527/docs/chat-room-header-notification-preview.html%23option-a"}}]}]]',
|
||||
),
|
||||
{
|
||||
strippedText: '',
|
||||
parts: [
|
||||
{
|
||||
type: 'prompt',
|
||||
title: '시안 선택',
|
||||
description: null,
|
||||
submitLabel: null,
|
||||
mode: null,
|
||||
multiple: false,
|
||||
responseTemplate: null,
|
||||
freeTextLabel: null,
|
||||
freeTextPlaceholder: null,
|
||||
currentStepKey: null,
|
||||
readOnly: false,
|
||||
selectedValues: [],
|
||||
resolvedBy: null,
|
||||
resolvedAt: null,
|
||||
resultText: null,
|
||||
steps: undefined,
|
||||
options: [
|
||||
{
|
||||
label: 'A안',
|
||||
value: 'option-a',
|
||||
description: null,
|
||||
preview: {
|
||||
type: 'resource',
|
||||
url: '/api/resource-manager/preview/Codex%20Live/%EA%B3%B5%EC%9C%A0%EC%B1%84%ED%8C%85/%EC%B1%84%ED%8C%85%EB%B0%A9%20%ED%97%A4%EB%8D%94%20%EC%9E%AC%EB%B0%B0%EC%B9%98%20%EC%A0%9C%EC%95%88/20260527/docs/chat-room-header-notification-preview.html#option-a',
|
||||
content: null,
|
||||
alt: null,
|
||||
title: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('extractChatMessageParts supports stepper prompt steps', () => {
|
||||
assert.deepEqual(
|
||||
extractChatMessageParts(
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
getSharedResourceTokenDetailBySharePath,
|
||||
validateSharedResourceAccessPinBySharePath,
|
||||
} from './shared-resource-token-service.js';
|
||||
import { getChatShareTokenRoomMap } from './chat-share-room-map-service.js';
|
||||
import { sendNotifications } from './notification-service.js';
|
||||
import { createNotificationMessage } from './notification-message-service.js';
|
||||
import {
|
||||
@@ -794,10 +795,18 @@ async function hasAuthorizedChatSocketAccess(request: IncomingMessage, url: URL)
|
||||
const sharedToken = sharedTokenDetail?.token ?? null;
|
||||
const resourceContext = sharedToken?.resourceContext ?? null;
|
||||
|
||||
if (!resourceContext || (requestedSessionId && resourceContext.sessionId !== requestedSessionId)) {
|
||||
if (!resourceContext) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (requestedSessionId && resourceContext.sessionId !== requestedSessionId) {
|
||||
const requestedRoom = sharedToken?.id ? await getChatShareTokenRoomMap(sharedToken.id, requestedSessionId) : null;
|
||||
|
||||
if (!requestedRoom) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!sharedToken || !sharedToken.enabled || sharedToken.sharePath !== sharePath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -84,10 +84,14 @@ type NotificationPreferenceTarget = {
|
||||
|
||||
function normalizeTargetDeviceIds(payload: {
|
||||
targetDeviceIds?: string[];
|
||||
}) {
|
||||
return [...new Set((payload.targetDeviceIds ?? []).map((value) => String(value ?? '').trim()).filter(Boolean))];
|
||||
}
|
||||
|
||||
function normalizeTargetClientIds(payload: {
|
||||
targetClientIds?: string[];
|
||||
}) {
|
||||
const targetDeviceIds = payload.targetDeviceIds ?? payload.targetClientIds;
|
||||
return [...new Set((targetDeviceIds ?? []).map((value) => String(value ?? '').trim()).filter(Boolean))];
|
||||
return [...new Set((payload.targetClientIds ?? []).map((value) => String(value ?? '').trim()).filter(Boolean))];
|
||||
}
|
||||
|
||||
function normalizeRegistrationCleanupIds(...values: Array<string | undefined>) {
|
||||
@@ -126,18 +130,21 @@ function normalizeTargetAppDomains(targetAppDomains: string[] | undefined) {
|
||||
return [...new Set((targetAppDomains ?? []).map((value) => normalizeAppDomain(value)).filter(Boolean))];
|
||||
}
|
||||
|
||||
function isAllowedTargetDeviceId(
|
||||
function isAllowedTargetRecipient(
|
||||
target: {
|
||||
deviceId?: string;
|
||||
clientId?: string;
|
||||
},
|
||||
targetDeviceIds: string[],
|
||||
targetClientIds: string[],
|
||||
) {
|
||||
if (targetDeviceIds.length === 0) {
|
||||
if (targetDeviceIds.length === 0 && targetClientIds.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const deviceId = String(target.deviceId ?? '').trim();
|
||||
return Boolean(deviceId) && targetDeviceIds.includes(deviceId);
|
||||
const clientId = String(target.clientId ?? '').trim();
|
||||
return (Boolean(deviceId) && targetDeviceIds.includes(deviceId)) || (Boolean(clientId) && targetClientIds.includes(clientId));
|
||||
}
|
||||
|
||||
function normalizeAppOrigin(value: unknown) {
|
||||
@@ -917,6 +924,7 @@ export async function sendIosNotifications(payload: IosNotificationPayload) {
|
||||
const env = getEnv();
|
||||
const provider = await getProvider();
|
||||
const targetDeviceIds = normalizeTargetDeviceIds(payload);
|
||||
const targetClientIds = normalizeTargetClientIds(payload);
|
||||
const targetAppOrigins = normalizeTargetAppOrigins(payload.targetAppOrigins);
|
||||
const targetAppDomains = normalizeTargetAppDomains(payload.targetAppDomains);
|
||||
|
||||
@@ -952,7 +960,7 @@ export async function sendIosNotifications(payload: IosNotificationPayload) {
|
||||
.filter(
|
||||
(row) =>
|
||||
row.allowed &&
|
||||
isAllowedTargetDeviceId({ deviceId: row.deviceId }, targetDeviceIds) &&
|
||||
isAllowedTargetRecipient({ deviceId: row.deviceId }, targetDeviceIds, targetClientIds) &&
|
||||
isAllowedAppTarget(row, targetAppOrigins, targetAppDomains),
|
||||
)
|
||||
.map((row) => row.token);
|
||||
@@ -1002,6 +1010,7 @@ export async function sendIosNotifications(payload: IosNotificationPayload) {
|
||||
async function sendWebPushNotifications(payload: IosNotificationPayload) {
|
||||
const env = getEnv();
|
||||
const targetDeviceIds = normalizeTargetDeviceIds(payload);
|
||||
const targetClientIds = normalizeTargetClientIds(payload);
|
||||
const targetAppOrigins = normalizeTargetAppOrigins(payload.targetAppOrigins);
|
||||
const targetAppDomains = normalizeTargetAppDomains(payload.targetAppDomains);
|
||||
if (!ensureWebPushConfigured(env)) {
|
||||
@@ -1031,7 +1040,7 @@ async function sendWebPushNotifications(payload: IosNotificationPayload) {
|
||||
).filter(
|
||||
(row) =>
|
||||
row.allowed &&
|
||||
isAllowedTargetDeviceId({ deviceId: row.deviceId }, targetDeviceIds) &&
|
||||
isAllowedTargetRecipient({ deviceId: row.deviceId, clientId: row.clientId }, targetDeviceIds, targetClientIds) &&
|
||||
isAllowedAppTarget(row, targetAppOrigins, targetAppDomains),
|
||||
);
|
||||
const matchedSubscriptions = subscriptions.map((row) => ({
|
||||
|
||||
@@ -63,6 +63,7 @@ class ResourceManagerError extends Error {
|
||||
|
||||
const RESOURCE_MANAGER_ROOT_DIR = 'resource';
|
||||
const RESOURCE_MANAGER_ROOT_LABEL = 'resource';
|
||||
const LEGACY_PUBLIC_RESOURCE_ROOT_DIR = path.join('public', 'resource');
|
||||
|
||||
const TEXT_FILE_EXTENSIONS = new Set([
|
||||
'.txt',
|
||||
@@ -199,6 +200,52 @@ function normalizeRelativeTarget(relativePath: string | null | undefined) {
|
||||
return normalized.replace(/^\/+/, '');
|
||||
}
|
||||
|
||||
function decodeRepeatedly(value: string, maxIterations = 3) {
|
||||
let current = value;
|
||||
|
||||
for (let index = 0; index < maxIterations; index += 1) {
|
||||
try {
|
||||
const decoded = decodeURIComponent(current);
|
||||
|
||||
if (!decoded || decoded === current) {
|
||||
break;
|
||||
}
|
||||
|
||||
current = decoded;
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
function normalizePreviewTargetPath(targetPath: string) {
|
||||
const normalized = normalizeRelativeTarget(targetPath);
|
||||
|
||||
if (!normalized) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const segments = normalized.split('/');
|
||||
const lastSegment = segments.at(-1) ?? '';
|
||||
const decodedLastSegment = decodeRepeatedly(lastSegment);
|
||||
const hashIndex = decodedLastSegment.lastIndexOf('#');
|
||||
|
||||
if (hashIndex <= 0) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const fileName = decodedLastSegment.slice(0, hashIndex).trim();
|
||||
|
||||
if (!fileName || !/\.[a-z0-9]{1,16}$/i.test(fileName)) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
segments[segments.length - 1] = fileName;
|
||||
return normalizeRelativeTarget(segments.join('/'));
|
||||
}
|
||||
|
||||
function resolveRepoRoot(candidateRootPath: string) {
|
||||
const candidates = [
|
||||
candidateRootPath,
|
||||
@@ -221,6 +268,30 @@ export function resolveResourceManagerRoot(repoRootPath: string) {
|
||||
return path.join(resolveRepoRoot(repoRootPath), RESOURCE_MANAGER_ROOT_DIR);
|
||||
}
|
||||
|
||||
function resolveLegacyPublicResourceRoot(repoRootPath: string) {
|
||||
return path.join(resolveRepoRoot(repoRootPath), LEGACY_PUBLIC_RESOURCE_ROOT_DIR);
|
||||
}
|
||||
|
||||
function resolveLegacyPublicResourcePreviewPath(repoRootPath: string, targetPath: string) {
|
||||
const normalizedRelativePath = normalizeRelativeTarget(targetPath);
|
||||
|
||||
if (!normalizedRelativePath) {
|
||||
throw new ResourceManagerError('리소스를 찾을 수 없습니다.', 404);
|
||||
}
|
||||
|
||||
const rootPath = resolveLegacyPublicResourceRoot(repoRootPath);
|
||||
const absolutePath = path.resolve(rootPath, normalizedRelativePath);
|
||||
|
||||
if (absolutePath !== rootPath && !absolutePath.startsWith(`${rootPath}${path.sep}`)) {
|
||||
throw new ResourceManagerError('허용되지 않은 경로입니다.', 403);
|
||||
}
|
||||
|
||||
return {
|
||||
absolutePath,
|
||||
relativePath: normalizedRelativePath,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveResourceManagerTargetPath(repoRootPath: string, relativePath: string) {
|
||||
const rootPath = resolveResourceManagerRoot(repoRootPath);
|
||||
const normalizedRelativePath = normalizeRelativeTarget(relativePath);
|
||||
@@ -604,7 +675,12 @@ export async function deleteResourceManagerItem(repoRootPath: string, targetPath
|
||||
|
||||
export async function openResourceManagerPreviewStream(repoRootPath: string, targetPath: string) {
|
||||
return withResourceManagerError(async () => {
|
||||
const { absolutePath } = resolveResourceManagerTargetPath(repoRootPath, targetPath);
|
||||
const normalizedTargetPath = normalizePreviewTargetPath(targetPath);
|
||||
const primaryTarget = resolveResourceManagerTargetPath(repoRootPath, normalizedTargetPath);
|
||||
const legacyTarget = resolveLegacyPublicResourcePreviewPath(repoRootPath, normalizedTargetPath);
|
||||
const absolutePath = existsSync(primaryTarget.absolutePath)
|
||||
? primaryTarget.absolutePath
|
||||
: legacyTarget.absolutePath;
|
||||
|
||||
if (!existsSync(absolutePath)) {
|
||||
throw new ResourceManagerError('리소스를 찾을 수 없습니다.', 404);
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# 공유채팅방 개선
|
||||
|
||||
## 변경 목적
|
||||
- stepper prompt에서 HTML preview가 객체 재생성마다 다시 fetch/reset 되며 멈춘 것처럼 보이던 흐름을 줄입니다.
|
||||
- 공유채팅방 이동 시 이미 본 방은 즉시 복원하고, 최신화는 뒤에서 다시 받아 체감 로딩을 줄입니다.
|
||||
- 재접속 시 마지막으로 사용한 공유채팅방을 다시 열 때, 마지막 방 ID뿐 아니라 해당 방 스냅샷도 세션 기준으로 복원합니다.
|
||||
|
||||
## 변경 범위
|
||||
- `src/app/main/mainChatPanel/ChatPromptCard.tsx`
|
||||
- preview fetch effect 의존성을 안정화했습니다.
|
||||
- preview 본문/`content-type`을 메모리 캐시에 저장해 같은 HTML/markdown/resource preview 재진입 시 재요청을 줄였습니다.
|
||||
- preview viewed / selection change effect에서 객체 참조 의존성을 줄여 stepper 렌더 루프 가능성을 낮췄습니다.
|
||||
- `src/app/main/pages/ChatSharePage.tsx`
|
||||
- 공유채팅방 스냅샷을 `sessionStorage`에도 저장하도록 추가했습니다.
|
||||
- 토큰별 마지막 방 복원 시 세션 캐시 스냅샷을 먼저 적용하도록 보강했습니다.
|
||||
- 방 전환 시 메모리 캐시가 없더라도 세션 캐시가 있으면 즉시 그 스냅샷으로 전환하도록 보강했습니다.
|
||||
|
||||
## 데이터 / API 영향
|
||||
- 새 저장소 키
|
||||
- `sessionStorage`: `codex-live-share-room-snapshot:<token>:<sessionId>`
|
||||
- 기존 API 계약 변경 없음
|
||||
- `/api/chat/shares/:token`
|
||||
- `/api/chat/shares/:token?sessionId=...`
|
||||
|
||||
## 확인 포인트
|
||||
- 같은 stepper prompt preview를 다시 펼쳐도 로딩 스피너가 계속 반복되지 않는지
|
||||
- 이미 열어본 공유채팅방을 다시 눌렀을 때 화면이 캐시로 먼저 복원되는지
|
||||
- 새로고침 후 마지막 사용 방 URL / 선택 상태가 유지되는지
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 303 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 303 KiB |
@@ -0,0 +1,24 @@
|
||||
# 공유채팅방 개선 검증
|
||||
|
||||
## 실행 결과
|
||||
- `npm exec tsc --noEmit`
|
||||
- 성공
|
||||
- `npm run build:test-app`
|
||||
- 성공
|
||||
- `curl http://127.0.0.1:5173/api/chat/shares/5e578dd5e91a4fa8b32cfe3c?sharePin=1459`
|
||||
- 성공
|
||||
- 응답 기준: `ok: true`, `title: 관리자`, `sessionId: chat-share-room-mpihlq67-ae86e941`, `requestCount: 5`
|
||||
|
||||
## 브라우저 확인
|
||||
- 로컬 test-app 빌드로 `/chat/share/:token` 진입 시 공유채팅 셸 자체는 열렸습니다.
|
||||
- 다만 이 격리 빌드 환경에서는 화면이 로딩 스피너 상태에 머무는 케이스가 있어, 이번 턴에서는 신뢰 가능한 기능 완료 화면 캡처까지는 확보하지 못했습니다.
|
||||
- 따라서 이번 검증 결론은 다음 범위로 한정합니다.
|
||||
- 타입 오류 없음
|
||||
- 프로덕션 테스트 빌드 성공
|
||||
- 공유채팅 스냅샷 API 정상 응답
|
||||
- 로컬 브라우저 진입 자체는 가능
|
||||
|
||||
## 판정
|
||||
- 코드 변경은 정상 반영됨
|
||||
- stepper HTML preview 안정화와 공유채팅방 캐시/복원 로직은 정적 검증 + API 검증까지 완료
|
||||
- 실서버 UI 최종 체감 확인은 공유채팅 실환경에서 한 번 더 보는 것이 안전함
|
||||
@@ -0,0 +1,27 @@
|
||||
# 공유채팅방 멈춤 완화
|
||||
|
||||
## 변경 배경
|
||||
- 1차 수정으로 `sessionStorage`에 공유방 스냅샷을 직렬화하던 경로는 제거했지만, 큰 관리형 공유채팅방에서는 여전히 서버가 최근 1000건 요청/메시지를 한 번에 내려주고 있었습니다.
|
||||
- 공유채팅 페이지도 검색 모달이 닫힌 상태에서 질문·응답·리소스·활동로그 통합검색 인덱스를 매 렌더마다 전수 계산하고 있어, 큰 방에서는 첫 진입과 갱신 시 추가 부담이 남아 있었습니다.
|
||||
|
||||
## 변경 내용
|
||||
- 기존 `sessionStorage` 제거 상태는 유지합니다.
|
||||
- `etc/servers/work-server/src/routes/chat.ts`
|
||||
- 관리형 공유채팅방(`MANAGED_CHAT_SHARE_SESSION_PREFIX`) 스냅샷은 최근 80건 요청 기준의 detail page만 내려주도록 바꿨습니다.
|
||||
- 공유채팅 초기 진입과 실시간 갱신이 더 이상 최근 1000건 전체 요청/메시지를 항상 읽지 않게 했습니다.
|
||||
- `src/app/main/pages/ChatSharePage.tsx`
|
||||
- 통합검색 결과 계산은 검색 모달이 실제로 열렸을 때만 수행하도록 바꿨습니다.
|
||||
- 방 진입 직후에는 닫혀 있는 검색 패널 때문에 질문/응답/리소스/활동로그 전체를 훑지 않습니다.
|
||||
|
||||
## 기대 효과
|
||||
- 큰 공유채팅방에서도 초기 진입과 자동 새로고침이 최근 이력 중심으로 동작해 멈춤 체감이 줄어듭니다.
|
||||
- 재접속 시 마지막 방 복원 기능은 유지하면서, 서버/프런트 양쪽의 불필요한 대량 계산을 줄입니다.
|
||||
|
||||
## 영향 범위
|
||||
- 공유채팅 페이지의 검색 계산 시점과 공유 스냅샷 응답 범위를 조정했습니다.
|
||||
- DB 스키마와 공유채팅 권한 로직은 변경하지 않았습니다.
|
||||
|
||||
## 확인 포인트
|
||||
- 관리형 공유채팅방 첫 진입 시 최근 이력 기준으로 빠르게 열리는지 확인
|
||||
- 메시지/활동로그가 많은 방에서도 방 이동·새로고침·실시간 갱신 시 멈춤 체감이 줄었는지 확인
|
||||
- 검색 모달을 열지 않았을 때는 통합검색 전수 계산이 돌지 않는지 확인
|
||||
@@ -0,0 +1,15 @@
|
||||
# 검증 요약
|
||||
|
||||
## 실행한 검증
|
||||
- `npm exec tsc --noEmit`
|
||||
- `npx tsc -p etc/servers/work-server/tsconfig.json --noEmit`
|
||||
- `npm run build:test-app`
|
||||
|
||||
## 결과
|
||||
- 프런트 타입 검사와 `work-server` 타입 검사는 모두 통과했습니다.
|
||||
- 테스트 번들은 재빌드까지 통과했습니다.
|
||||
- 이번 수정으로 관리형 공유채팅방 스냅샷은 최근 80건 detail page 기준으로 줄였고, 검색 모달이 닫혀 있을 때는 통합검색 계산을 건너뜁니다.
|
||||
|
||||
## 비고
|
||||
- 이번 수정은 UI 레이아웃 변경이 아니라 응답량·계산량 축소라서, 최종 화면 캡처 대신 타입/빌드 검증과 코드 경로 문서화를 남겼습니다.
|
||||
- `preview.sm-home.cloud` 실접속/잠금 해제 재현은 이번 턴에서 다시 수행하지 못했습니다.
|
||||
@@ -0,0 +1,20 @@
|
||||
# 공유채팅 채팅방 이동 소도 개선
|
||||
|
||||
## 변경 요약
|
||||
- 공유채팅방 마지막 선택 방 저장을 `localStorage`에서 `sessionStorage`로 변경했습니다.
|
||||
- 같은 탭 안에서는 마지막으로 보던 방을 복원하지만, 브라우저를 완전히 닫으면 기억을 남기지 않습니다.
|
||||
- 채팅방 선택 시 `roomSessionId`를 URL에 반영할 때 사용자 선택은 `pushState`, 자동 보정은 `replaceState`로 나눴습니다.
|
||||
- 브라우저 뒤로가기/앞으로가기 시 현재 URL의 `roomSessionId`를 다시 읽어 선택 방과 동기화합니다.
|
||||
|
||||
## 변경 범위
|
||||
- 공유채팅 화면의 방 선택/복원/URL 동기화 로직
|
||||
- 영구 저장 제거에 따른 탭 세션 단위 이동 상태 복원
|
||||
|
||||
## 데이터 및 API 영향
|
||||
- 서버 API 스펙 변경은 없습니다.
|
||||
- 클라이언트 저장소 사용 범위만 `localStorage` -> `sessionStorage`로 바뀝니다.
|
||||
|
||||
## 확인 포인트
|
||||
- 공유채팅에서 방을 바꾼 뒤 새로고침하면 같은 탭에서는 마지막 방이 유지되는지
|
||||
- 브라우저 뒤로가기/앞으로가기 때 이전/다음 방으로 이동되는지
|
||||
- 브라우저를 완전히 닫았다가 다시 열면 이전 방이 영구 복원되지 않는지
|
||||
@@ -0,0 +1,11 @@
|
||||
# 검증 요약
|
||||
|
||||
## 수행 내용
|
||||
- `npm exec tsc --noEmit`
|
||||
|
||||
## 결과
|
||||
- 타입체크 통과
|
||||
|
||||
## 미수행 항목
|
||||
- `https://preview.sm-home.cloud/` 브라우저 실접속 검증은 이번 턴에서 수행하지 못했습니다.
|
||||
- 시각 레이아웃 변경이 아니라서 별도 UI 스크린샷은 생성하지 않았습니다.
|
||||
@@ -0,0 +1,709 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>공유채팅 헤더 재배치 제안</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg-top: #edf3fb;
|
||||
--bg-bottom: #e4edf8;
|
||||
--surface: rgba(255, 255, 255, 0.84);
|
||||
--surface-strong: rgba(255, 255, 255, 0.94);
|
||||
--surface-soft: rgba(248, 250, 252, 0.92);
|
||||
--line: rgba(148, 163, 184, 0.24);
|
||||
--text: #0f172a;
|
||||
--muted: #64748b;
|
||||
--blue: #2563eb;
|
||||
--blue-soft: rgba(219, 234, 254, 0.96);
|
||||
--green: #166534;
|
||||
--green-soft: rgba(220, 252, 231, 0.98);
|
||||
--amber: #92400e;
|
||||
--amber-soft: rgba(254, 243, 199, 0.98);
|
||||
--red: #b91c1c;
|
||||
--red-soft: rgba(254, 226, 226, 0.98);
|
||||
--shadow-lg: 0 20px 50px rgba(15, 23, 42, 0.14);
|
||||
--shadow-md: 0 12px 28px rgba(148, 163, 184, 0.16);
|
||||
--radius-xl: 28px;
|
||||
--radius-lg: 22px;
|
||||
--radius-md: 18px;
|
||||
--radius-sm: 14px;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Pretendard", "Inter", sans-serif;
|
||||
color: var(--text);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(59, 130, 246, 0.12), transparent 28%),
|
||||
linear-gradient(180deg, var(--bg-top) 0%, var(--bg-bottom) 100%);
|
||||
}
|
||||
|
||||
.page {
|
||||
width: min(1480px, calc(100vw - 40px));
|
||||
margin: 0 auto;
|
||||
padding: 28px 0 40px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
padding: 22px 24px;
|
||||
border: 1px solid rgba(196, 210, 226, 0.88);
|
||||
border-radius: 28px;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.78), rgba(248, 250, 252, 0.86));
|
||||
box-shadow: var(--shadow-lg);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.eyebrow,
|
||||
.chip,
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
width: fit-content;
|
||||
color: var(--blue);
|
||||
background: var(--blue-soft);
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
margin: 0;
|
||||
font-size: 34px;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
.hero p {
|
||||
margin: 0;
|
||||
max-width: 920px;
|
||||
color: var(--muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.dna {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
color: #334155;
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
border: 1px solid rgba(196, 210, 226, 0.92);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
margin-top: 22px;
|
||||
}
|
||||
|
||||
.proposal {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
padding: 18px;
|
||||
border-radius: 26px;
|
||||
border: 1px solid rgba(196, 210, 226, 0.96);
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.76), rgba(244, 248, 253, 0.94));
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.proposal h2 {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.proposal p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.tag-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
padding: 6px 10px;
|
||||
color: #334155;
|
||||
background: rgba(255, 255, 255, 0.86);
|
||||
border: 1px solid rgba(196, 210, 226, 0.86);
|
||||
}
|
||||
|
||||
.phone {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
min-height: 740px;
|
||||
padding: 14px;
|
||||
border-radius: 30px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(237, 243, 251, 0.98), rgba(228, 237, 248, 0.94)),
|
||||
radial-gradient(circle at top left, rgba(59, 130, 246, 0.1), transparent 32%);
|
||||
border: 1px solid rgba(196, 210, 226, 0.98);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(255, 255, 255, 0.4),
|
||||
0 18px 38px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 2px 4px 0;
|
||||
font-size: 11px;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border-radius: 24px;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.9), rgba(248, 250, 252, 0.78));
|
||||
border: 1px solid rgba(196, 210, 226, 0.9);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.header-top,
|
||||
.header-bottom,
|
||||
.header-split {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.room-trigger,
|
||||
.action-pill,
|
||||
.filter-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 38px;
|
||||
padding: 0 14px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.22);
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(241, 245, 249, 0.92));
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(255, 255, 255, 0.42),
|
||||
0 8px 18px rgba(148, 163, 184, 0.14);
|
||||
}
|
||||
|
||||
.room-trigger {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.room-avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, rgba(37, 99, 235, 0.94), rgba(59, 130, 246, 0.72));
|
||||
color: white;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-weight: 800;
|
||||
font-size: 13px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.room-copy {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.room-copy strong {
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.room-copy span {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.icon-circle {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 999px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(219, 234, 254, 0.94);
|
||||
color: var(--blue);
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.handle {
|
||||
width: 44px;
|
||||
height: 5px;
|
||||
margin: 2px auto 0;
|
||||
border-radius: 999px;
|
||||
background: rgba(148, 163, 184, 0.52);
|
||||
}
|
||||
|
||||
.dashboard {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.metric {
|
||||
padding: 14px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(196, 210, 226, 0.92);
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.28);
|
||||
}
|
||||
|
||||
.metric strong {
|
||||
display: block;
|
||||
font-size: 24px;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
.metric span {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.metric.blue {
|
||||
background: linear-gradient(180deg, rgba(239, 246, 255, 0.98), rgba(219, 234, 254, 0.94));
|
||||
}
|
||||
|
||||
.metric.green {
|
||||
background: linear-gradient(180deg, rgba(240, 253, 244, 0.98), rgba(220, 252, 231, 0.94));
|
||||
}
|
||||
|
||||
.metric.amber {
|
||||
background: linear-gradient(180deg, rgba(255, 251, 235, 0.98), rgba(254, 243, 199, 0.94));
|
||||
}
|
||||
|
||||
.metric.red {
|
||||
background: linear-gradient(180deg, rgba(254, 242, 242, 0.98), rgba(254, 226, 226, 0.94));
|
||||
}
|
||||
|
||||
.sheet {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
border-radius: 26px;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(248, 250, 252, 0.92));
|
||||
border: 1px solid rgba(196, 210, 226, 0.9);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(255, 255, 255, 0.32),
|
||||
0 14px 28px rgba(148, 163, 184, 0.14);
|
||||
}
|
||||
|
||||
.sheet-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.sheet-title strong {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.list,
|
||||
.feed {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.list-item,
|
||||
.feed-item {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 12px 13px;
|
||||
border-radius: 18px;
|
||||
background: var(--surface-strong);
|
||||
border: 1px solid rgba(196, 210, 226, 0.86);
|
||||
}
|
||||
|
||||
.list-item-top,
|
||||
.feed-item-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.list-item strong,
|
||||
.feed-item strong {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.list-item span,
|
||||
.feed-item span,
|
||||
.feed-item p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 5px 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.badge.blue {
|
||||
color: #1d4ed8;
|
||||
background: rgba(219, 234, 254, 0.96);
|
||||
}
|
||||
|
||||
.badge.green {
|
||||
color: var(--green);
|
||||
background: var(--green-soft);
|
||||
}
|
||||
|
||||
.badge.amber {
|
||||
color: var(--amber);
|
||||
background: var(--amber-soft);
|
||||
}
|
||||
|
||||
.badge.red {
|
||||
color: var(--red);
|
||||
background: var(--red-soft);
|
||||
}
|
||||
|
||||
.message {
|
||||
flex: 1 1 auto;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
align-content: start;
|
||||
padding: 8px 2px 2px;
|
||||
}
|
||||
|
||||
.bubble {
|
||||
max-width: 86%;
|
||||
padding: 12px 14px;
|
||||
border-radius: 18px;
|
||||
line-height: 1.55;
|
||||
font-size: 13px;
|
||||
box-shadow: 0 10px 22px rgba(148, 163, 184, 0.1);
|
||||
}
|
||||
|
||||
.bubble.self {
|
||||
justify-self: end;
|
||||
color: white;
|
||||
background: linear-gradient(135deg, #2563eb, #3b82f6);
|
||||
}
|
||||
|
||||
.bubble.other {
|
||||
justify-self: start;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border: 1px solid rgba(196, 210, 226, 0.82);
|
||||
}
|
||||
|
||||
.footer-note {
|
||||
padding: 14px 16px;
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
border: 1px dashed rgba(148, 163, 184, 0.4);
|
||||
color: #475569;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.phone {
|
||||
min-height: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="page">
|
||||
<section class="hero">
|
||||
<span class="eyebrow">공유채팅 실제 테마 기반 제안</span>
|
||||
<h1>채팅방 헤더를 방 목록 + 알림센터 허브로 재구성</h1>
|
||||
<p>
|
||||
현재 공유채팅이 쓰는 옅은 블루 그라데이션, 반투명 화이트 surface, 파란 pill 액션 톤을 유지하면서
|
||||
헤더의 역할을 명확히 분리했습니다. 공통 방향은 “제목/아이콘 클릭으로 방 목록”, “중앙 손잡이 드래그로
|
||||
iOS 알림센터 스타일”, “현재 방과 다른 방 알림을 한 시트에서 함께 확인”입니다.
|
||||
</p>
|
||||
<div class="dna">
|
||||
<span class="chip">현재 테마: #edf3fb → #e4edf8</span>
|
||||
<span class="chip">액션 톤: white pill + #2563eb</span>
|
||||
<span class="chip">재질감: blur + soft shadow</span>
|
||||
<span class="chip">상태칩: blue / green / amber / red</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid">
|
||||
<article class="proposal" id="option-a">
|
||||
<div>
|
||||
<h2>A안. Capsule Rail</h2>
|
||||
<p>가장 자연스럽게 익숙한 안입니다. 제목 캡슐이 방 목록 진입점이 되고, 중앙 손잡이를 내려 전체 알림센터를 펼칩니다.</p>
|
||||
</div>
|
||||
<div class="tag-row">
|
||||
<span class="tag">추천: 모바일 우선</span>
|
||||
<span class="tag">방 목록 발견성 높음</span>
|
||||
<span class="tag">알림센터 구분 명확</span>
|
||||
</div>
|
||||
<div class="phone">
|
||||
<div class="status-bar"><span>9:41</span><span>Live 5G 92%</span></div>
|
||||
<div class="header">
|
||||
<div class="header-top">
|
||||
<div class="room-trigger">
|
||||
<div class="room-avatar">CC</div>
|
||||
<div class="room-copy">
|
||||
<strong>공유채팅 운영룸</strong>
|
||||
<span>제목/아이콘 탭: 방 목록 + 필터 열기</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="action-pill">설정</div>
|
||||
<div class="icon-circle">-</div>
|
||||
<div class="icon-circle">×</div>
|
||||
</div>
|
||||
<div class="header-bottom">
|
||||
<div class="filter-pill">진행중 6 · 다른 방 새답변 4</div>
|
||||
<div class="filter-pill">apps 2건</div>
|
||||
</div>
|
||||
<div class="handle"></div>
|
||||
</div>
|
||||
|
||||
<div class="sheet">
|
||||
<div class="sheet-title">
|
||||
<strong>알림센터</strong>
|
||||
<span class="badge blue">전체 채팅 + apps</span>
|
||||
</div>
|
||||
|
||||
<div class="dashboard">
|
||||
<div class="dashboard-grid">
|
||||
<div class="metric blue"><strong>12</strong><span>처리중 요청</span></div>
|
||||
<div class="metric green"><strong>4</strong><span>다른 방 새 답변</span></div>
|
||||
<div class="metric amber"><strong>2</strong><span>apps 경고</span></div>
|
||||
<div class="metric red"><strong>1</strong><span>확인 필요 실패</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-label">방 목록 + 필터</div>
|
||||
<div class="list">
|
||||
<div class="list-item">
|
||||
<div class="list-item-top"><strong>전체 방</strong><span class="badge blue">18</span></div>
|
||||
<span>최근답변, 처리중, 안읽음, apps 연결방 필터를 같은 레이어에서 전환</span>
|
||||
</div>
|
||||
<div class="list-item">
|
||||
<div class="list-item-top"><strong>개발 운영방</strong><span class="badge green">새 답변</span></div>
|
||||
<span>3분 전 Codex 응답 도착 · 앱 연결 2개</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-label">알림 피드</div>
|
||||
<div class="feed">
|
||||
<div class="feed-item">
|
||||
<div class="feed-item-top"><strong>자동화 공유방</strong><span class="badge blue">진행중</span></div>
|
||||
<p>배포 확인 캡처 업로드 완료. 검증 스크린샷 2장 확인 필요.</p>
|
||||
</div>
|
||||
<div class="feed-item">
|
||||
<div class="feed-item-top"><strong>Apps 알림</strong><span class="badge amber">권한</span></div>
|
||||
<p>캘린더 동기화 1건 지연. 알림센터에서 바로 상세 열기 버튼 제공.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="message">
|
||||
<div class="bubble other">헤더 제목을 누르면 바로 방 목록과 필터가 한 번에 보여야 합니다.</div>
|
||||
<div class="bubble self">A안은 그 요구를 가장 직접적으로 충족합니다.</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="proposal" id="option-b">
|
||||
<div>
|
||||
<h2>B안. Split Status Bar</h2>
|
||||
<p>좌측은 현재 방, 우측은 전체 알림과 apps 상태를 쌓아 두는 데스크톱 친화형입니다. 헤더 하단은 세그먼트 필터로 남깁니다.</p>
|
||||
</div>
|
||||
<div class="tag-row">
|
||||
<span class="tag">추천: 데스크톱 확장</span>
|
||||
<span class="tag">필터 접근 가장 빠름</span>
|
||||
<span class="tag">정보량 많음</span>
|
||||
</div>
|
||||
<div class="phone">
|
||||
<div class="status-bar"><span>9:41</span><span>Workspace online</span></div>
|
||||
<div class="header">
|
||||
<div class="header-split">
|
||||
<div class="room-trigger">
|
||||
<div class="room-avatar">PM</div>
|
||||
<div class="room-copy">
|
||||
<strong>프로젝트 메인룸</strong>
|
||||
<span>아이콘/제목 클릭: 방 전환</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="action-pill">다른 방 4</div>
|
||||
<div class="action-pill">apps 2</div>
|
||||
</div>
|
||||
<div class="header-bottom">
|
||||
<div class="filter-pill">전체</div>
|
||||
<div class="filter-pill">진행중</div>
|
||||
<div class="filter-pill">안읽음</div>
|
||||
<div class="filter-pill">apps</div>
|
||||
</div>
|
||||
<div class="handle"></div>
|
||||
</div>
|
||||
|
||||
<div class="sheet">
|
||||
<div class="sheet-title">
|
||||
<strong>Notification Center Dashboard</strong>
|
||||
<span class="badge green">실시간 집계</span>
|
||||
</div>
|
||||
<div class="dashboard-grid">
|
||||
<div class="metric blue"><strong>08</strong><span>현재 방 처리 흐름</span></div>
|
||||
<div class="metric green"><strong>03</strong><span>다른 방 확인대기</span></div>
|
||||
<div class="metric amber"><strong>05</strong><span>apps 작업 알림</span></div>
|
||||
<div class="metric red"><strong>02</strong><span>실패/재시도</span></div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-label">요약 콘텐츠</div>
|
||||
<div class="feed">
|
||||
<div class="feed-item">
|
||||
<div class="feed-item-top"><strong>현재 방</strong><span class="badge blue">3건</span></div>
|
||||
<p>작업중인 요청, 마지막 응답 시간, 첨부 리소스 생성 수를 카드형으로 고정 배치</p>
|
||||
</div>
|
||||
<div class="feed-item">
|
||||
<div class="feed-item-top"><strong>다른 채팅방</strong><span class="badge green">새 답변</span></div>
|
||||
<p>현재 방이 아니어도 읽지 않은 응답과 mention 성격 요청을 한 섹션에 모아 보여줌</p>
|
||||
</div>
|
||||
<div class="feed-item">
|
||||
<div class="feed-item-top"><strong>Apps</strong><span class="badge amber">확인 필요</span></div>
|
||||
<p>캘린더, 알림, 연결 앱의 상태 메시지를 채팅 알림과 동일한 카드 리듬으로 정렬</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="message">
|
||||
<div class="bubble other">필터를 자주 바꾸는 운영자라면 헤더 안에서 바로 전환하고 싶습니다.</div>
|
||||
<div class="bubble self">B안은 필터 중심 운영에 가장 유리합니다.</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="proposal" id="option-c">
|
||||
<div>
|
||||
<h2>C안. Focus Stack</h2>
|
||||
<p>현재 작업중인 방을 더 크게 인지시키는 집중형입니다. 알림센터는 “현재 방 집중 + 다른 방 보조” 우선순위가 드러납니다.</p>
|
||||
</div>
|
||||
<div class="tag-row">
|
||||
<span class="tag">추천: 집중 작업</span>
|
||||
<span class="tag">브랜드감 강함</span>
|
||||
<span class="tag">운영감시형 대시보드</span>
|
||||
</div>
|
||||
<div class="phone">
|
||||
<div class="status-bar"><span>9:41</span><span>Preview theme sync</span></div>
|
||||
<div class="header">
|
||||
<div class="header-top">
|
||||
<div class="room-trigger" style="min-height: 52px;">
|
||||
<div class="room-avatar" style="width: 34px; height: 34px; border-radius: 14px;">UX</div>
|
||||
<div class="room-copy">
|
||||
<strong>UX 검토 채팅방</strong>
|
||||
<span>Hero chip 탭: 방 목록 / 필터 / 최근방</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="icon-circle">≡</div>
|
||||
</div>
|
||||
<div class="header-bottom">
|
||||
<div class="filter-pill">현재 방 진행중 4</div>
|
||||
<div class="filter-pill">전체 알림 9</div>
|
||||
</div>
|
||||
<div class="handle"></div>
|
||||
</div>
|
||||
|
||||
<div class="sheet">
|
||||
<div class="sheet-title">
|
||||
<strong>집중 대시보드</strong>
|
||||
<span class="badge blue">현재 방 우선</span>
|
||||
</div>
|
||||
<div class="dashboard-grid">
|
||||
<div class="metric blue"><strong>4</strong><span>현재 방 처리중</span></div>
|
||||
<div class="metric green"><strong>2</strong><span>완료 직전</span></div>
|
||||
<div class="metric amber"><strong>3</strong><span>다른 방 확인</span></div>
|
||||
<div class="metric red"><strong>1</strong><span>apps 경고</span></div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-label">우선순위 피드</div>
|
||||
<div class="feed">
|
||||
<div class="feed-item">
|
||||
<div class="feed-item-top"><strong>현재 방 요청</strong><span class="badge blue">우선</span></div>
|
||||
<p>마지막 응답 이후 7분 경과. 첨부 preview 3개 생성됨. 바로 이어보기 버튼 노출.</p>
|
||||
</div>
|
||||
<div class="feed-item">
|
||||
<div class="feed-item-top"><strong>다른 방 답변</strong><span class="badge green">보조</span></div>
|
||||
<p>메인 운영방에 새 응답 2건. 눌러서 방 전환 없이 quick peek 가능.</p>
|
||||
</div>
|
||||
<div class="feed-item">
|
||||
<div class="feed-item-top"><strong>Apps 이벤트</strong><span class="badge amber">연결</span></div>
|
||||
<p>배포 완료, 캘린더 일정, 리소스 등록 완료 이벤트를 낮은 대비 카드로 정렬.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="message">
|
||||
<div class="bubble other">작업 중인 방을 잃지 않으면서도 다른 방 알림은 놓치고 싶지 않습니다.</div>
|
||||
<div class="bubble self">C안은 집중감은 가장 좋지만, 운영형 전체 목록성은 A안보다 약합니다.</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="footer-note">
|
||||
추천 순서는 A안 → B안 → C안입니다. A안은 현재 공유채팅의 둥근 pill 액션과 blur 헤더 감성을 가장 자연스럽게 이어가면서,
|
||||
“제목/아이콘으로 방 목록”, “손잡이로 알림센터”라는 두 행동을 가장 덜 헷갈리게 분리합니다.
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,53 @@
|
||||
# 공유채팅 채팅방 헤더 재배치 제안
|
||||
|
||||
## 목적
|
||||
- 실제 공유채팅에서 사용하는 라이트 블루 테마를 유지한 채, 헤더를 방 선택과 알림센터 진입의 허브로 재구성한다.
|
||||
- 채팅방 제목 또는 아이콘 선택 시 필터와 채팅방 목록을 함께 노출하고, 헤더 손잡이를 아래로 내리면 iOS 알림센터 같은 요약 대시보드와 알림 피드를 보여준다.
|
||||
- 기존 기능은 유지하되, 설정/최소화/닫기 같은 툴 액션은 헤더 집중도를 해치지 않도록 보조 영역으로 정리한다.
|
||||
|
||||
## 현재 테마 확인 기준
|
||||
- 공유채팅 카드 바디는 `#edf3fb → #e4edf8` 계열 세로 그라데이션과 얕은 inset border, soft shadow를 사용한다.
|
||||
- 헤더 액션은 흰색 pill 버튼 위에 `#2563eb` 아이콘 포인트를 두고 hover 시 더 밝은 블루 계열로 반응한다.
|
||||
- 헤더 배경은 반투명 흰색보다 한 단계 채도 낮은 블루톤이며 blur가 들어간다.
|
||||
- 방 상태/요청 상태는 회색, 파랑, 초록, 빨강 계열 pill로 구분한다.
|
||||
|
||||
## 제안 방향 공통 원칙
|
||||
- 헤더 1행은 현재 방 인지와 이동, 헤더 2행은 상태/필터/알림센터 진입으로 역할을 명확히 분리한다.
|
||||
- 방 목록은 제목 또는 아이콘을 누르는 행위 하나로 열리도록 통합하고, 목록 안에서 필터 칩과 최근 대화 프리뷰를 동시에 보여준다.
|
||||
- 알림센터는 현재 방 전용이 아니라 전체 채팅방과 apps 알림을 함께 집계한다.
|
||||
- 손잡이 드래그는 모달보다 시스템 오버레이처럼 느껴지게 하고, 상단에는 다시보드형 요약 카드를 고정한다.
|
||||
|
||||
## 제안안 구성
|
||||
### A안 Capsule Rail
|
||||
- 제목 캡슐 자체가 방 목록 트리거다.
|
||||
- 헤더 중앙 손잡이를 내려 알림센터를 여는 패턴으로 가장 직관적이다.
|
||||
- 알림센터 상단은 처리중, 새 답변, apps 경고, 캘린더성 일정 같은 4분할 다시보드다.
|
||||
|
||||
### B안 Split Status Bar
|
||||
- 좌측은 방 정보, 우측은 앱 알림과 빠른 전환 상태를 모은 split bar 구조다.
|
||||
- 필터를 헤더 하단의 segment row로 남겨 자주 쓰는 상태 전환을 더 빠르게 한다.
|
||||
- 데스크톱 확장성은 좋지만 모바일에서는 약간 더 촘촘해질 수 있다.
|
||||
|
||||
### C안 Focus Stack
|
||||
- 방 아이콘과 제목을 한 덩어리 hero chip으로 키워 현재 방 인지를 강화한다.
|
||||
- 알림센터 대시보드 카드를 더 크게 두고, 현재 처리중 요청 중심의 집중도를 높인다.
|
||||
- 정보량이 많을 때보다 “현재 작업 집중” 상황에 적합하다.
|
||||
|
||||
## 유지되어야 할 기존 기능
|
||||
- 방 이동
|
||||
- 필터 전환
|
||||
- 설정
|
||||
- 최소화
|
||||
- 닫기
|
||||
- 현재 연결 상태 표시
|
||||
- 현재 방 상태 요약
|
||||
|
||||
## 알림센터 추천 콘텐츠
|
||||
- 상단 고정 다시보드: 처리중 요청 수, 읽지 않은 다른 채팅방, apps 경고/완료, 오늘 일정 또는 예약 작업
|
||||
- 중단: 현재 방 진행 카드와 다른 방 새 답변 카드 혼합 피드
|
||||
- 하단: 앱별 알림 묶음, 빠른 액션, 전체 읽음/필터 토글
|
||||
|
||||
## 검토 포인트
|
||||
- 방 목록과 알림센터를 둘 다 헤더에 얹되 탭 충돌 없이 한 손 조작이 가능한지
|
||||
- 모바일에서 드래그 손잡이와 브라우저 스크롤 제스처가 충돌하지 않는지
|
||||
- 현재 존재하는 설정/최소화/닫기 액션을 보조 영역으로 빼도 발견 가능성이 유지되는지
|
||||
@@ -12,6 +12,7 @@ import { isPreviewRuntime } from './previewRuntime';
|
||||
export function AppShell() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/shares/:token" element={<ChatSharePage />} />
|
||||
<Route path="/chat-share/:token" element={<ChatSharePage />} />
|
||||
<Route path="/chat/share/:token" element={<ChatSharePage />} />
|
||||
<Route path="/" element={<MainLayout />}>
|
||||
|
||||
@@ -257,6 +257,9 @@ function areChatSettingsEqual(left: AppConfig['chat'], right: AppConfig['chat'])
|
||||
left.codexLiveMaxExecutionSeconds === right.codexLiveMaxExecutionSeconds &&
|
||||
left.codexLiveIdleTimeoutSeconds === right.codexLiveIdleTimeoutSeconds &&
|
||||
left.receiveRoomNotifications === right.receiveRoomNotifications &&
|
||||
left.guidePwaInstallForNotifications === right.guidePwaInstallForNotifications &&
|
||||
left.guideWebPushPermission === right.guideWebPushPermission &&
|
||||
left.guideWebPushRegistration === right.guideWebPushRegistration &&
|
||||
left.restartReservationCompletionDelaySeconds === right.restartReservationCompletionDelaySeconds
|
||||
);
|
||||
}
|
||||
@@ -284,6 +287,18 @@ function getChatSettingsDiffLabels(saved: AppConfig['chat'], draft: AppConfig['c
|
||||
changedLabels.push('채팅방 알림 수신');
|
||||
}
|
||||
|
||||
if (saved.guidePwaInstallForNotifications !== draft.guidePwaInstallForNotifications) {
|
||||
changedLabels.push('PWA 설치 유도');
|
||||
}
|
||||
|
||||
if (saved.guideWebPushPermission !== draft.guideWebPushPermission) {
|
||||
changedLabels.push('웹푸시 권한 유도');
|
||||
}
|
||||
|
||||
if (saved.guideWebPushRegistration !== draft.guideWebPushRegistration) {
|
||||
changedLabels.push('웹푸시 등록 유도');
|
||||
}
|
||||
|
||||
if (saved.restartReservationCompletionDelaySeconds !== draft.restartReservationCompletionDelaySeconds) {
|
||||
changedLabels.push('재기동 예약 자동 실행 대기 시간');
|
||||
}
|
||||
@@ -3522,8 +3537,8 @@ export function MainHeader({
|
||||
message={chatSettingsDirty ? '채팅 문맥 설정에 저장되지 않은 변경이 있습니다.' : '채팅 문맥 설정이 저장되었습니다.'}
|
||||
description={
|
||||
chatSettingsDirty
|
||||
? `변경 항목: ${chatSettingsDiffLabels.join(', ')} / DB 저장값 기준: 최근 ${appConfig.chat.maxContextMessages}개, ${appConfig.chat.maxContextChars}자, 최대 실행 ${appConfig.chat.codexLiveMaxExecutionSeconds}초, 무출력 실패 ${appConfig.chat.codexLiveIdleTimeoutSeconds}초, 채팅방 알림 ${appConfig.chat.receiveRoomNotifications ? '수신' : '차단'}, 재기동 예약 자동 실행 대기 ${appConfig.chat.restartReservationCompletionDelaySeconds}초 / 편집 중: 최근 ${appConfigDraft.chat.maxContextMessages}개, ${appConfigDraft.chat.maxContextChars}자, 최대 실행 ${appConfigDraft.chat.codexLiveMaxExecutionSeconds}초, 무출력 실패 ${appConfigDraft.chat.codexLiveIdleTimeoutSeconds}초, 채팅방 알림 ${appConfigDraft.chat.receiveRoomNotifications ? '수신' : '차단'}, 재기동 예약 자동 실행 대기 ${appConfigDraft.chat.restartReservationCompletionDelaySeconds}초`
|
||||
: `현재 저장값: 최근 ${appConfig.chat.maxContextMessages}개 메시지, 최대 ${appConfig.chat.maxContextChars}자까지 채팅 문맥으로 참조하고 Codex Live 실행 시간은 최대 ${appConfig.chat.codexLiveMaxExecutionSeconds}초, 무출력 실패 시간은 ${appConfig.chat.codexLiveIdleTimeoutSeconds}초이며 채팅방 알림은 ${appConfig.chat.receiveRoomNotifications ? '수신' : '차단'} 상태, 재기동 예약 자동 실행 대기 시간은 ${appConfig.chat.restartReservationCompletionDelaySeconds}초`
|
||||
? `변경 항목: ${chatSettingsDiffLabels.join(', ')} / DB 저장값 기준: 최근 ${appConfig.chat.maxContextMessages}개, ${appConfig.chat.maxContextChars}자, 최대 실행 ${appConfig.chat.codexLiveMaxExecutionSeconds}초, 무출력 실패 ${appConfig.chat.codexLiveIdleTimeoutSeconds}초, 채팅방 알림 ${appConfig.chat.receiveRoomNotifications ? '수신' : '차단'}, PWA 유도 ${appConfig.chat.guidePwaInstallForNotifications ? '사용' : '끔'}, 권한 유도 ${appConfig.chat.guideWebPushPermission ? '사용' : '끔'}, 등록 유도 ${appConfig.chat.guideWebPushRegistration ? '사용' : '끔'}, 재기동 예약 자동 실행 대기 ${appConfig.chat.restartReservationCompletionDelaySeconds}초 / 편집 중: 최근 ${appConfigDraft.chat.maxContextMessages}개, ${appConfigDraft.chat.maxContextChars}자, 최대 실행 ${appConfigDraft.chat.codexLiveMaxExecutionSeconds}초, 무출력 실패 ${appConfigDraft.chat.codexLiveIdleTimeoutSeconds}초, 채팅방 알림 ${appConfigDraft.chat.receiveRoomNotifications ? '수신' : '차단'}, PWA 유도 ${appConfigDraft.chat.guidePwaInstallForNotifications ? '사용' : '끔'}, 권한 유도 ${appConfigDraft.chat.guideWebPushPermission ? '사용' : '끔'}, 등록 유도 ${appConfigDraft.chat.guideWebPushRegistration ? '사용' : '끔'}, 재기동 예약 자동 실행 대기 ${appConfigDraft.chat.restartReservationCompletionDelaySeconds}초`
|
||||
: `현재 저장값: 최근 ${appConfig.chat.maxContextMessages}개 메시지, 최대 ${appConfig.chat.maxContextChars}자까지 채팅 문맥으로 참조하고 Codex Live 실행 시간은 최대 ${appConfig.chat.codexLiveMaxExecutionSeconds}초, 무출력 실패 시간은 ${appConfig.chat.codexLiveIdleTimeoutSeconds}초이며 채팅방 알림은 ${appConfig.chat.receiveRoomNotifications ? '수신' : '차단'} 상태, PWA 유도는 ${appConfig.chat.guidePwaInstallForNotifications ? '사용' : '끔'}, 권한 유도는 ${appConfig.chat.guideWebPushPermission ? '사용' : '끔'}, 등록 유도는 ${appConfig.chat.guideWebPushRegistration ? '사용' : '끔'}, 재기동 예약 자동 실행 대기 시간은 ${appConfig.chat.restartReservationCompletionDelaySeconds}초`
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -3592,6 +3607,66 @@ export function MainHeader({
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Checkbox
|
||||
checked={appConfigDraft.chat.guidePwaInstallForNotifications}
|
||||
onChange={(event) => {
|
||||
setAppConfigDraft((current) => ({
|
||||
...current,
|
||||
chat: {
|
||||
...current.chat,
|
||||
guidePwaInstallForNotifications: event.target.checked,
|
||||
},
|
||||
}));
|
||||
}}
|
||||
>
|
||||
PWA 설치 유도
|
||||
</Checkbox>
|
||||
<Paragraph type="secondary" style={{ marginTop: 8, marginBottom: 0 }}>
|
||||
브라우저 탭에서 알림 설정이 막히는 환경이면 홈 화면 앱으로 열도록 안내하는 개인 기본값입니다.
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Checkbox
|
||||
checked={appConfigDraft.chat.guideWebPushPermission}
|
||||
onChange={(event) => {
|
||||
setAppConfigDraft((current) => ({
|
||||
...current,
|
||||
chat: {
|
||||
...current.chat,
|
||||
guideWebPushPermission: event.target.checked,
|
||||
},
|
||||
}));
|
||||
}}
|
||||
>
|
||||
웹푸시 권한 유도
|
||||
</Checkbox>
|
||||
<Paragraph type="secondary" style={{ marginTop: 8, marginBottom: 0 }}>
|
||||
알림 권한이 미확인 상태면 브라우저 또는 기기 권한 허용을 먼저 안내하는 개인 기본값입니다.
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Checkbox
|
||||
checked={appConfigDraft.chat.guideWebPushRegistration}
|
||||
onChange={(event) => {
|
||||
setAppConfigDraft((current) => ({
|
||||
...current,
|
||||
chat: {
|
||||
...current.chat,
|
||||
guideWebPushRegistration: event.target.checked,
|
||||
},
|
||||
}));
|
||||
}}
|
||||
>
|
||||
웹푸시 등록 유도
|
||||
</Checkbox>
|
||||
<Paragraph type="secondary" style={{ marginTop: 8, marginBottom: 0 }}>
|
||||
권한은 허용됐지만 현재 기기 구독이 없으면 웹푸시 등록까지 이어서 안내하는 개인 기본값입니다.
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text strong>Codex Live 최대 실행 시간(초)</Text>
|
||||
<Paragraph type="secondary">Codex Live 요청 1건이 강제 종료되기 전까지 허용할 최대 실행 시간입니다.</Paragraph>
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
saveAppConfigToServer,
|
||||
type AppConfig,
|
||||
type PlanCostTimeUnit,
|
||||
type WeeklyScheduleDay,
|
||||
} from './appConfig';
|
||||
import './SharedAppSettingsPage.css';
|
||||
|
||||
|
||||
@@ -22,6 +22,9 @@ export type AppConfig = {
|
||||
codexLiveMaxExecutionSeconds: number;
|
||||
codexLiveIdleTimeoutSeconds: number;
|
||||
receiveRoomNotifications: boolean;
|
||||
guidePwaInstallForNotifications: boolean;
|
||||
guideWebPushPermission: boolean;
|
||||
guideWebPushRegistration: boolean;
|
||||
restartReservationCompletionDelaySeconds: number;
|
||||
};
|
||||
automation: {
|
||||
@@ -78,6 +81,9 @@ export const DEFAULT_APP_CONFIG: AppConfig = {
|
||||
codexLiveMaxExecutionSeconds: 600,
|
||||
codexLiveIdleTimeoutSeconds: 180,
|
||||
receiveRoomNotifications: true,
|
||||
guidePwaInstallForNotifications: true,
|
||||
guideWebPushPermission: true,
|
||||
guideWebPushRegistration: true,
|
||||
restartReservationCompletionDelaySeconds: 10,
|
||||
},
|
||||
automation: {
|
||||
@@ -311,6 +317,18 @@ function normalizeConfig(raw?: Partial<AppConfig>): AppConfig {
|
||||
chat?.receiveRoomNotifications,
|
||||
DEFAULT_APP_CONFIG.chat.receiveRoomNotifications,
|
||||
),
|
||||
guidePwaInstallForNotifications: normalizeBooleanValue(
|
||||
chat?.guidePwaInstallForNotifications,
|
||||
DEFAULT_APP_CONFIG.chat.guidePwaInstallForNotifications,
|
||||
),
|
||||
guideWebPushPermission: normalizeBooleanValue(
|
||||
chat?.guideWebPushPermission,
|
||||
DEFAULT_APP_CONFIG.chat.guideWebPushPermission,
|
||||
),
|
||||
guideWebPushRegistration: normalizeBooleanValue(
|
||||
chat?.guideWebPushRegistration,
|
||||
DEFAULT_APP_CONFIG.chat.guideWebPushRegistration,
|
||||
),
|
||||
restartReservationCompletionDelaySeconds: normalizeRestartReservationCompletionDelaySeconds(
|
||||
chat?.restartReservationCompletionDelaySeconds,
|
||||
DEFAULT_APP_CONFIG.chat.restartReservationCompletionDelaySeconds,
|
||||
|
||||
@@ -80,6 +80,7 @@ export type PromptSubmitPayload = {
|
||||
const PROMPT_FREE_TEXT_ONLY_LABEL = '선택 없이 기타 요청';
|
||||
const PROMPT_PARENT_QUESTION_MAX_LENGTH = 400;
|
||||
const PROMPT_PARENT_QUESTION_PREVIEW_MAX_LENGTH = 140;
|
||||
const PROMPT_PREVIEW_CONTENT_CACHE = new Map<string, { content: string | null; contentType: string | null }>();
|
||||
|
||||
function buildOptionSelectionText(options: PromptOption[], selectedValues: string[]) {
|
||||
const selectedOptions = options.filter((option) => selectedValues.includes(option.value));
|
||||
@@ -640,20 +641,23 @@ function usePromptPreviewContent(preview: PromptPreview | null | undefined) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [loadError, setLoadError] = useState('');
|
||||
const normalizedPreviewUrl = resolvePromptPreviewUrl(preview?.url);
|
||||
const previewContent = preview?.content ?? null;
|
||||
const previewType = preview?.type ?? null;
|
||||
const hasPreview = Boolean(preview);
|
||||
|
||||
useEffect(() => {
|
||||
setRemoteContent(preview?.content ?? null);
|
||||
setRemoteContent(previewContent);
|
||||
setRemoteContentType(null);
|
||||
setLoadError('');
|
||||
|
||||
const shouldFetchTextPreview =
|
||||
preview?.type === 'markdown' || preview?.type === 'html';
|
||||
previewType === 'markdown' || previewType === 'html';
|
||||
const shouldInspectResourcePreview =
|
||||
preview?.type === 'resource' && normalizedPreviewUrl && canRenderFramePreview(normalizedPreviewUrl);
|
||||
previewType === 'resource' && normalizedPreviewUrl && canRenderFramePreview(normalizedPreviewUrl);
|
||||
|
||||
if (
|
||||
!preview ||
|
||||
preview.content ||
|
||||
!hasPreview ||
|
||||
previewContent ||
|
||||
!normalizedPreviewUrl ||
|
||||
(!shouldFetchTextPreview && !shouldInspectResourcePreview)
|
||||
) {
|
||||
@@ -661,7 +665,17 @@ function usePromptPreviewContent(preview: PromptPreview | null | undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cachedPreview = PROMPT_PREVIEW_CONTENT_CACHE.get(`${previewType ?? 'unknown'}::${normalizedPreviewUrl}`);
|
||||
|
||||
if (cachedPreview) {
|
||||
setRemoteContent(cachedPreview.content);
|
||||
setRemoteContentType(cachedPreview.contentType);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
let resolvedContentType: string | null = null;
|
||||
setIsLoading(true);
|
||||
|
||||
void fetch(normalizedPreviewUrl, {
|
||||
@@ -674,6 +688,7 @@ function usePromptPreviewContent(preview: PromptPreview | null | undefined) {
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type');
|
||||
resolvedContentType = contentType;
|
||||
|
||||
if (!controller.signal.aborted) {
|
||||
setRemoteContentType(contentType);
|
||||
@@ -687,6 +702,10 @@ function usePromptPreviewContent(preview: PromptPreview | null | undefined) {
|
||||
})
|
||||
.then((text) => {
|
||||
if (!controller.signal.aborted) {
|
||||
PROMPT_PREVIEW_CONTENT_CACHE.set(`${previewType ?? 'unknown'}::${normalizedPreviewUrl}`, {
|
||||
content: text,
|
||||
contentType: resolvedContentType,
|
||||
});
|
||||
setRemoteContent(text);
|
||||
}
|
||||
})
|
||||
@@ -704,7 +723,7 @@ function usePromptPreviewContent(preview: PromptPreview | null | undefined) {
|
||||
});
|
||||
|
||||
return () => controller.abort();
|
||||
}, [normalizedPreviewUrl, preview]);
|
||||
}, [hasPreview, normalizedPreviewUrl, previewContent, previewType]);
|
||||
|
||||
return { remoteContent, remoteContentType, isLoading, loadError };
|
||||
}
|
||||
@@ -872,7 +891,7 @@ function PromptPreviewCard({
|
||||
|
||||
useEffect(() => {
|
||||
onPreviewViewed?.(option);
|
||||
}, [onPreviewViewed, option]);
|
||||
}, [normalizedPreviewUrl, onPreviewViewed, option.label, option.value]);
|
||||
|
||||
const handleSharePreview = (event: ReactKeyboardEvent | ReactMouseEvent<HTMLElement>) => {
|
||||
event.stopPropagation();
|
||||
@@ -1241,18 +1260,25 @@ export function ChatPromptCard({
|
||||
const progressPayload = buildPromptSelectionPayloadWithAttachments(target, stepSelections, attachments);
|
||||
const expandAllOptionPreviews = shouldExpandAllPromptPreviews(activeStep, activeSelection, isLocked);
|
||||
const expandedOptionPreviewUrl = expandedOption?.preview ? resolvePromptPreviewUrl(expandedOption.preview.url) : '';
|
||||
const onSelectionChangeRef = useRef(onSelectionChange);
|
||||
|
||||
useEffect(() => {
|
||||
onSelectionChangeRef.current = onSelectionChange;
|
||||
}, [onSelectionChange]);
|
||||
|
||||
const emitSelectionChange = (nextSelections: Record<string, PromptStepDraftSelection>) => {
|
||||
if (!onSelectionChange) {
|
||||
const selectionChangeHandler = onSelectionChangeRef.current;
|
||||
|
||||
if (!selectionChangeHandler) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isLocked) {
|
||||
onSelectionChange(null);
|
||||
selectionChangeHandler(null);
|
||||
return;
|
||||
}
|
||||
|
||||
onSelectionChange(buildPromptSelectionPayloadWithAttachments(target, nextSelections, attachments));
|
||||
selectionChangeHandler(buildPromptSelectionPayloadWithAttachments(target, nextSelections, attachments));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1287,17 +1313,16 @@ export function ChatPromptCard({
|
||||
return;
|
||||
}
|
||||
|
||||
onSelectionChange(buildPromptSelectionPayloadWithAttachments(target, stepSelections, attachments));
|
||||
}, [attachments, isLocked, onSelectionChange, stepSelections, target]);
|
||||
emitSelectionChange(stepSelections);
|
||||
}, [attachments, isLocked, stepSelections]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!expandedOption?.preview) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previewUrl = resolvePromptPreviewUrl(expandedOption.preview.url);
|
||||
onPreviewViewed?.(previewUrl || null);
|
||||
}, [expandedOption, onPreviewViewed]);
|
||||
onPreviewViewed?.(expandedOptionPreviewUrl || null);
|
||||
}, [expandedOption?.value, expandedOptionPreviewUrl, onPreviewViewed]);
|
||||
|
||||
const handleShareExpandedPreview = () => {
|
||||
if (!expandedOption?.preview) {
|
||||
@@ -1382,7 +1407,6 @@ export function ChatPromptCard({
|
||||
...current,
|
||||
[activeStep.key]: nextSelection,
|
||||
};
|
||||
emitSelectionChange(nextSelections);
|
||||
return nextSelections;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -6,6 +6,83 @@ const CHAT_PUBLIC_DOT_CODEX_MARKER = '/public/.codex_chat/';
|
||||
const RESOURCE_MANAGER_PREVIEW_MARKER = '/api/resource-manager/preview/';
|
||||
const RESOURCE_MANAGER_ROOT_MARKER = 'resource/';
|
||||
|
||||
function normalizeUrlFragmentValue(value: string) {
|
||||
const normalized = String(value ?? '').trim().replace(/^#+/, '');
|
||||
|
||||
if (!normalized) {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
return decodeURIComponent(normalized);
|
||||
} catch {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
function decodeRepeatedly(value: string, maxIterations = 3) {
|
||||
let current = value;
|
||||
|
||||
for (let index = 0; index < maxIterations; index += 1) {
|
||||
try {
|
||||
const decoded = decodeURIComponent(current);
|
||||
|
||||
if (!decoded || decoded === current) {
|
||||
break;
|
||||
}
|
||||
|
||||
current = decoded;
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
function normalizePreviewPathHash(value: string) {
|
||||
const normalized = String(value ?? '').trim();
|
||||
|
||||
if (!normalized) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const isAbsoluteUrl = /^[a-z][a-z0-9+.-]*:\/\//i.test(normalized);
|
||||
const isRootRelative = normalized.startsWith('/');
|
||||
|
||||
try {
|
||||
const parsed = new URL(normalized, 'https://local.invalid');
|
||||
const segments = parsed.pathname.split('/');
|
||||
const lastSegment = segments.at(-1) ?? '';
|
||||
const decodedLastSegment = decodeRepeatedly(lastSegment);
|
||||
const hashIndex = decodedLastSegment.lastIndexOf('#');
|
||||
|
||||
if (hashIndex <= 0 || parsed.hash) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const fileName = decodedLastSegment.slice(0, hashIndex).trim();
|
||||
const fragment = normalizeUrlFragmentValue(decodedLastSegment.slice(hashIndex + 1));
|
||||
|
||||
if (!fileName || !fragment || !/\.[a-z0-9]{1,16}$/i.test(fileName)) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
segments[segments.length - 1] = encodeURIComponent(fileName);
|
||||
parsed.pathname = segments.join('/');
|
||||
parsed.hash = fragment;
|
||||
|
||||
if (isAbsoluteUrl) {
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
const pathWithQueryAndHash = `${parsed.pathname}${parsed.search}${parsed.hash}`;
|
||||
return isRootRelative ? pathWithQueryAndHash : pathWithQueryAndHash.replace(/^\/+/, '');
|
||||
} catch {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeResourceManagerPathSegment(segment: string) {
|
||||
const normalized = String(segment ?? '').trim();
|
||||
|
||||
@@ -21,8 +98,11 @@ function normalizeResourceManagerPathSegment(segment: string) {
|
||||
}
|
||||
|
||||
function buildResourceManagerPreviewPath(value: string) {
|
||||
const normalized = String(value ?? '').trim().replace(/\\/g, '/');
|
||||
const matchedResourcePath = normalized.match(/(?:^|\/)(resource\/.+)$/i)?.[1];
|
||||
const normalized = normalizePreviewPathHash(String(value ?? '').trim().replace(/\\/g, '/'));
|
||||
const hashIndex = normalized.indexOf('#');
|
||||
const hash = hashIndex >= 0 ? normalizeUrlFragmentValue(normalized.slice(hashIndex + 1)) : '';
|
||||
const normalizedPath = hashIndex >= 0 ? normalized.slice(0, hashIndex) : normalized;
|
||||
const matchedResourcePath = normalizedPath.match(/(?:^|\/)(resource\/.+)$/i)?.[1];
|
||||
const resourcePath = String(matchedResourcePath ?? '').trim().replace(/^\/+/, '');
|
||||
|
||||
if (!resourcePath) {
|
||||
@@ -41,7 +121,7 @@ function buildResourceManagerPreviewPath(value: string) {
|
||||
.map((segment) => normalizeResourceManagerPathSegment(segment))
|
||||
.join('/');
|
||||
|
||||
return encodedPath ? `${RESOURCE_MANAGER_PREVIEW_MARKER}${encodedPath}` : '';
|
||||
return encodedPath ? `${RESOURCE_MANAGER_PREVIEW_MARKER}${encodedPath}${hash ? `#${hash}` : ''}` : '';
|
||||
}
|
||||
|
||||
function resolveLegacyExternalImageUrl(value: string) {
|
||||
@@ -113,17 +193,17 @@ function extractKnownPreviewPath(value: string) {
|
||||
const pathname = `${parsed.pathname || ''}${parsed.search || ''}${parsed.hash || ''}`;
|
||||
|
||||
if (pathname.startsWith(CHAT_API_RESOURCE_MARKER) || pathname.startsWith(RESOURCE_MANAGER_PREVIEW_MARKER)) {
|
||||
return pathname;
|
||||
return normalizePreviewPathHash(pathname);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
} catch {
|
||||
return extractEmbeddedResourcePath(normalized);
|
||||
return normalizePreviewPathHash(extractEmbeddedResourcePath(normalized));
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeChatResourceUrl(value: string) {
|
||||
const normalized = extractKnownPreviewPath(value);
|
||||
const normalized = normalizePreviewPathHash(extractKnownPreviewPath(value));
|
||||
const legacyExternalImageUrl = resolveLegacyExternalImageUrl(normalized);
|
||||
|
||||
if (legacyExternalImageUrl) {
|
||||
|
||||
@@ -51,9 +51,89 @@ function normalizeResourceManagerPathSegment(segment: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeUrlFragmentValue(value: string) {
|
||||
const normalized = normalizeText(value).replace(/^#+/, '');
|
||||
|
||||
if (!normalized) {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
return decodeURIComponent(normalized);
|
||||
} catch {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
function decodeRepeatedly(value: string, maxIterations = 3) {
|
||||
let current = value;
|
||||
|
||||
for (let index = 0; index < maxIterations; index += 1) {
|
||||
try {
|
||||
const decoded = decodeURIComponent(current);
|
||||
|
||||
if (!decoded || decoded === current) {
|
||||
break;
|
||||
}
|
||||
|
||||
current = decoded;
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
function normalizePreviewPathHash(value: string) {
|
||||
const normalized = normalizeText(value);
|
||||
|
||||
if (!normalized) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const isAbsoluteUrl = /^[a-z][a-z0-9+.-]*:\/\//i.test(normalized);
|
||||
const isRootRelative = normalized.startsWith('/');
|
||||
|
||||
try {
|
||||
const parsed = new URL(normalized, 'https://local.invalid');
|
||||
const segments = parsed.pathname.split('/');
|
||||
const lastSegment = segments.at(-1) ?? '';
|
||||
const decodedLastSegment = decodeRepeatedly(lastSegment);
|
||||
const hashIndex = decodedLastSegment.lastIndexOf('#');
|
||||
|
||||
if (hashIndex <= 0 || parsed.hash) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const fileName = decodedLastSegment.slice(0, hashIndex).trim();
|
||||
const fragment = normalizeUrlFragmentValue(decodedLastSegment.slice(hashIndex + 1));
|
||||
|
||||
if (!fileName || !fragment || !/\.[a-z0-9]{1,16}$/i.test(fileName)) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
segments[segments.length - 1] = encodeURIComponent(fileName);
|
||||
parsed.pathname = segments.join('/');
|
||||
parsed.hash = fragment;
|
||||
|
||||
if (isAbsoluteUrl) {
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
const pathWithQueryAndHash = `${parsed.pathname}${parsed.search}${parsed.hash}`;
|
||||
return isRootRelative ? pathWithQueryAndHash : pathWithQueryAndHash.replace(/^\/+/, '');
|
||||
} catch {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
function buildResourceManagerPreviewUrl(value: string) {
|
||||
const normalized = normalizeText(value).replace(/\\/g, '/');
|
||||
const matchedResourcePath = normalized.match(/(?:^|\/)(resource\/.+)$/i)?.[1];
|
||||
const normalized = normalizePreviewPathHash(normalizeText(value).replace(/\\/g, '/'));
|
||||
const hashIndex = normalized.indexOf('#');
|
||||
const hash = hashIndex >= 0 ? normalizeUrlFragmentValue(normalized.slice(hashIndex + 1)) : '';
|
||||
const normalizedPath = hashIndex >= 0 ? normalized.slice(0, hashIndex) : normalized;
|
||||
const matchedResourcePath = normalizedPath.match(/(?:^|\/)(resource\/.+)$/i)?.[1];
|
||||
const resourcePath = normalizeText(matchedResourcePath).replace(/^\/+/, '');
|
||||
|
||||
if (!resourcePath) {
|
||||
@@ -72,7 +152,7 @@ function buildResourceManagerPreviewUrl(value: string) {
|
||||
.map((segment) => normalizeResourceManagerPathSegment(segment))
|
||||
.join('/');
|
||||
|
||||
return encodedPath ? `${RESOURCE_MANAGER_PREVIEW_MARKER}${encodedPath}` : '';
|
||||
return encodedPath ? `${RESOURCE_MANAGER_PREVIEW_MARKER}${encodedPath}${hash ? `#${hash}` : ''}` : '';
|
||||
}
|
||||
|
||||
function extractKnownPreviewPath(value: string) {
|
||||
@@ -83,11 +163,11 @@ function extractKnownPreviewPath(value: string) {
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(normalized);
|
||||
const parsed = new URL(normalized, 'https://local.invalid');
|
||||
const pathname = `${parsed.pathname || ''}${parsed.search || ''}${parsed.hash || ''}`;
|
||||
|
||||
if (pathname.startsWith(CHAT_API_RESOURCE_MARKER) || pathname.startsWith(RESOURCE_MANAGER_PREVIEW_MARKER)) {
|
||||
return pathname;
|
||||
return normalizePreviewPathHash(pathname);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
|
||||
@@ -903,12 +903,10 @@ export async function showLocalClientNotification(payload: ClientNotificationPay
|
||||
|
||||
export async function sendClientNotification(payload: ClientNotificationPayload) {
|
||||
const notificationData = withCurrentAppOriginMetadata(payload.data);
|
||||
const targetDeviceIds = payload.targetDeviceIds ?? payload.targetClientIds;
|
||||
return request<ClientNotificationSendResult>('/notifications/send', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
...payload,
|
||||
targetDeviceIds,
|
||||
data: notificationData,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -337,7 +337,7 @@ function readStoredShareLastRoomByToken() {
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(SHARE_LAST_ROOM_STORAGE_KEY);
|
||||
const raw = window.sessionStorage.getItem(SHARE_LAST_ROOM_STORAGE_KEY);
|
||||
|
||||
if (!raw) {
|
||||
return {} as Record<string, string>;
|
||||
@@ -390,7 +390,48 @@ function writeStoredShareLastRoomSessionId(token: string, sessionId: string | nu
|
||||
delete nextMap[normalizedToken];
|
||||
}
|
||||
|
||||
window.localStorage.setItem(SHARE_LAST_ROOM_STORAGE_KEY, JSON.stringify(nextMap));
|
||||
window.sessionStorage.setItem(SHARE_LAST_ROOM_STORAGE_KEY, JSON.stringify(nextMap));
|
||||
}
|
||||
|
||||
function readShareRoomSessionIdFromLocation() {
|
||||
if (typeof window === 'undefined') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return new URLSearchParams(window.location.search).get('roomSessionId')?.trim() || '';
|
||||
}
|
||||
|
||||
function writeShareRoomSessionIdToLocation(roomSessionId: string | null, mode: 'push' | 'replace' = 'replace') {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedSessionId = String(roomSessionId ?? '').trim();
|
||||
const nextUrl = new URL(window.location.href);
|
||||
const currentSessionId = nextUrl.searchParams.get('roomSessionId')?.trim() || '';
|
||||
|
||||
if (normalizedSessionId) {
|
||||
if (currentSessionId === normalizedSessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
nextUrl.searchParams.set('roomSessionId', normalizedSessionId);
|
||||
} else {
|
||||
if (!currentSessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
nextUrl.searchParams.delete('roomSessionId');
|
||||
}
|
||||
|
||||
const nextPath = `${nextUrl.pathname}${nextUrl.search}${nextUrl.hash}`;
|
||||
|
||||
if (mode === 'push') {
|
||||
window.history.pushState(window.history.state, '', nextPath);
|
||||
return;
|
||||
}
|
||||
|
||||
window.history.replaceState(window.history.state, '', nextPath);
|
||||
}
|
||||
|
||||
function getClientNotificationPermission(): ClientNotificationPermissionState {
|
||||
@@ -3557,7 +3598,7 @@ export function ChatSharePage() {
|
||||
return '';
|
||||
}
|
||||
|
||||
const urlRoomSessionId = new URLSearchParams(window.location.search).get('roomSessionId')?.trim() || '';
|
||||
const urlRoomSessionId = readShareRoomSessionIdFromLocation();
|
||||
|
||||
if (urlRoomSessionId) {
|
||||
return urlRoomSessionId;
|
||||
@@ -5278,13 +5319,31 @@ export function ChatSharePage() {
|
||||
return;
|
||||
}
|
||||
|
||||
const urlRoomSessionId = new URLSearchParams(window.location.search).get('roomSessionId')?.trim() || '';
|
||||
const urlRoomSessionId = readShareRoomSessionIdFromLocation();
|
||||
const restoredRoomSessionId = urlRoomSessionId || readStoredShareLastRoomSessionId(normalizedToken);
|
||||
|
||||
requestedRoomSessionIdRef.current = restoredRoomSessionId;
|
||||
setRequestedRoomSessionId(restoredRoomSessionId);
|
||||
}, [normalizedToken]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const handlePopState = () => {
|
||||
const nextRoomSessionId = readShareRoomSessionIdFromLocation() || readStoredShareLastRoomSessionId(normalizedToken);
|
||||
requestedRoomSessionIdRef.current = nextRoomSessionId;
|
||||
setRequestedRoomSessionId(nextRoomSessionId);
|
||||
setIsShareRoomListVisible(false);
|
||||
};
|
||||
|
||||
window.addEventListener('popstate', handlePopState);
|
||||
return () => {
|
||||
window.removeEventListener('popstate', handlePopState);
|
||||
};
|
||||
}, [normalizedToken]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!normalizedToken || !hasSnapshotRef.current) {
|
||||
return;
|
||||
@@ -5329,22 +5388,10 @@ export function ChatSharePage() {
|
||||
writeStoredShareLastRoomSessionId(normalizedToken, persistedRoomSessionId);
|
||||
}, [normalizedToken, selectedShareRoomSessionId, shareRooms]);
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextUrl = new URL(window.location.href);
|
||||
const roomSessionId = shareRooms.some((room) => room.sessionId === requestedRoomSessionId)
|
||||
? requestedRoomSessionId
|
||||
: activeShareRoomSessionId;
|
||||
|
||||
if (roomSessionId) {
|
||||
nextUrl.searchParams.set('roomSessionId', roomSessionId);
|
||||
} else {
|
||||
nextUrl.searchParams.delete('roomSessionId');
|
||||
}
|
||||
|
||||
window.history.replaceState(null, '', `${nextUrl.pathname}${nextUrl.search}${nextUrl.hash}`);
|
||||
writeShareRoomSessionIdToLocation(roomSessionId || null, 'replace');
|
||||
}, [activeShareRoomSessionId, requestedRoomSessionId, shareRooms]);
|
||||
|
||||
const handleUnlockShare = useCallback(async (inputPin?: string) => {
|
||||
@@ -5826,9 +5873,12 @@ export function ChatSharePage() {
|
||||
|
||||
roomSwitchSequenceRef.current += 1;
|
||||
setIsRoomSwitching(true);
|
||||
requestedRoomSessionIdRef.current = normalizedSessionId;
|
||||
writeStoredShareLastRoomSessionId(normalizedToken, normalizedSessionId);
|
||||
writeShareRoomSessionIdToLocation(normalizedSessionId, 'push');
|
||||
setRequestedRoomSessionId(normalizedSessionId);
|
||||
setIsShareRoomListVisible(false);
|
||||
}, [selectedShareRoomSessionId]);
|
||||
}, [normalizedToken, selectedShareRoomSessionId]);
|
||||
|
||||
const handleCreateShareRoom = useCallback(async () => {
|
||||
if (!normalizedToken || isCreatingRoom) {
|
||||
@@ -5871,6 +5921,9 @@ export function ChatSharePage() {
|
||||
? current
|
||||
: [...current, createdRoom]
|
||||
));
|
||||
requestedRoomSessionIdRef.current = createdRoom.sessionId;
|
||||
writeStoredShareLastRoomSessionId(normalizedToken, createdRoom.sessionId);
|
||||
writeShareRoomSessionIdToLocation(createdRoom.sessionId, 'push');
|
||||
setRequestedRoomSessionId(createdRoom.sessionId);
|
||||
setDraftText('');
|
||||
setComposerAttachments([]);
|
||||
|
||||
@@ -79,6 +79,7 @@ type SuccessLogRow = {
|
||||
clientId: string;
|
||||
ownerType: 'client' | 'shared-token';
|
||||
ownerId: string;
|
||||
ownerLabel?: string | null;
|
||||
alertTitle: string;
|
||||
ticketTitle: string;
|
||||
eventDateTime: string;
|
||||
@@ -641,9 +642,15 @@ function formatScopeIdentifierLabel(value: string | null | undefined) {
|
||||
return formatClientIdLabel(value?.trim() ?? '');
|
||||
}
|
||||
|
||||
function formatOwnershipLabel(ownerType: 'client' | 'shared-token', ownerId: string | null | undefined, clientId: string) {
|
||||
function formatOwnershipLabel(
|
||||
ownerType: 'client' | 'shared-token',
|
||||
ownerId: string | null | undefined,
|
||||
clientId: string,
|
||||
ownerLabel?: string | null,
|
||||
) {
|
||||
if (ownerType === 'shared-token') {
|
||||
return `토큰 ${formatScopeIdentifierLabel(ownerId)}`;
|
||||
const normalizedOwnerLabel = ownerLabel?.trim() ?? '';
|
||||
return normalizedOwnerLabel || `토큰 ${formatScopeIdentifierLabel(ownerId)}`;
|
||||
}
|
||||
|
||||
return `기기 ${formatClientIdLabel(clientId)}`;
|
||||
@@ -825,6 +832,7 @@ export function BaseballTicketBayPlayAppView({ onBack, launchContext = 'direct',
|
||||
clientId: item.clientId,
|
||||
ownerType: item.ownerType,
|
||||
ownerId: item.ownerId,
|
||||
ownerLabel: item.ownerLabel ?? alert?.ownerLabel ?? null,
|
||||
alertTitle: alert.title,
|
||||
ticketTitle: result.title,
|
||||
eventDateTime: result.eventDateTime,
|
||||
@@ -865,7 +873,7 @@ export function BaseballTicketBayPlayAppView({ onBack, launchContext = 'direct',
|
||||
[selectedSuccessRows],
|
||||
);
|
||||
const selectableSuccessRows = useMemo(
|
||||
() => (accessScope === 'shared-token' ? successRows : successRows.filter((item) => item.clientId === clientId)),
|
||||
() => (accessScope === 'client' ? successRows.filter((item) => item.clientId === clientId) : successRows),
|
||||
[accessScope, clientId, successRows],
|
||||
);
|
||||
const isAllSuccessRowsSelected = selectableSuccessRows.length > 0 && selectedSuccessRowIds.length === selectableSuccessRows.length;
|
||||
@@ -882,7 +890,7 @@ export function BaseballTicketBayPlayAppView({ onBack, launchContext = 'direct',
|
||||
}
|
||||
|
||||
if (accessScope === 'all') {
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
return targetClientId === clientId;
|
||||
@@ -976,13 +984,17 @@ export function BaseballTicketBayPlayAppView({ onBack, launchContext = 'direct',
|
||||
};
|
||||
|
||||
const appendLog = (
|
||||
item: Pick<AlertLogItem, 'alertId' | 'alertTitle' | 'action' | 'status' | 'message' | 'detail'>,
|
||||
item: Pick<AlertLogItem, 'alertId' | 'alertTitle' | 'action' | 'status' | 'message' | 'detail'>
|
||||
& Partial<Pick<AlertLogItem, 'ownerType' | 'ownerId' | 'ownerLabel'>>,
|
||||
) => {
|
||||
setLogs((previous) => [
|
||||
{
|
||||
id: createId('log'),
|
||||
clientId,
|
||||
createdAt: new Date().toISOString(),
|
||||
ownerType: 'client',
|
||||
ownerId: clientId,
|
||||
ownerLabel: null,
|
||||
...item,
|
||||
},
|
||||
...previous,
|
||||
@@ -1027,6 +1039,9 @@ export function BaseballTicketBayPlayAppView({ onBack, launchContext = 'direct',
|
||||
status: 'error',
|
||||
message: error instanceof Error ? error.message : '즉시 실행에 실패했습니다.',
|
||||
detail: buildAlertSummary(alert),
|
||||
ownerType: alert.ownerType,
|
||||
ownerId: alert.ownerId,
|
||||
ownerLabel: alert.ownerLabel ?? null,
|
||||
});
|
||||
messageApi.error(error instanceof Error ? error.message : '즉시 실행에 실패했습니다.');
|
||||
} finally {
|
||||
@@ -1618,7 +1633,7 @@ export function BaseballTicketBayPlayAppView({ onBack, launchContext = 'direct',
|
||||
<strong>{item.ticketTitle}</strong>
|
||||
<div className="baseball-ticket-bay-app__success-item-top-tags">
|
||||
{isViewingAllClients || isSharedTokenScope ? (
|
||||
<Tag bordered={false}>{formatOwnershipLabel(item.ownerType, item.ownerId, item.clientId)}</Tag>
|
||||
<Tag bordered={false}>{formatOwnershipLabel(item.ownerType, item.ownerId, item.clientId, item.ownerLabel)}</Tag>
|
||||
) : null}
|
||||
<Tag bordered={false} color="green">
|
||||
성공
|
||||
@@ -1651,7 +1666,7 @@ export function BaseballTicketBayPlayAppView({ onBack, launchContext = 'direct',
|
||||
</Tag>
|
||||
{isViewingAllClients || isSharedTokenScope ? (
|
||||
<Tag bordered={false}>
|
||||
{formatOwnershipLabel(selectedSuccessRow.ownerType, selectedSuccessRow.ownerId, selectedSuccessRow.clientId)}
|
||||
{formatOwnershipLabel(selectedSuccessRow.ownerType, selectedSuccessRow.ownerId, selectedSuccessRow.clientId, selectedSuccessRow.ownerLabel)}
|
||||
</Tag>
|
||||
) : null}
|
||||
<Button
|
||||
@@ -2043,7 +2058,7 @@ export function BaseballTicketBayPlayAppView({ onBack, launchContext = 'direct',
|
||||
</div>
|
||||
{isViewingAllClients || isSharedTokenScope ? (
|
||||
<div className="baseball-ticket-bay-app__scope-note">
|
||||
{isSharedTokenScope ? '현재 공유 토큰 기준으로 목록을 표시합니다.' : '등록 토큰으로 전체 기기 목록을 보고 있습니다. 수정과 삭제는 현재 기기 항목만 가능합니다.'}
|
||||
{isSharedTokenScope ? '현재 공유 리소스 별명 기준으로 목록을 표시합니다.' : '등록 토큰으로 전체 기기 목록을 보고 있습니다. 리소스 별명으로 확인하고 수정과 삭제도 바로 할 수 있습니다.'}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="baseball-ticket-bay-app__items">
|
||||
@@ -2063,7 +2078,7 @@ export function BaseballTicketBayPlayAppView({ onBack, launchContext = 'direct',
|
||||
<div className="baseball-ticket-bay-app__item-tags">
|
||||
{isViewingAllClients || isSharedTokenScope ? (
|
||||
<Tag bordered={false} color={isOwnedByCurrentClient ? 'blue' : 'default'}>
|
||||
{formatOwnershipLabel(item.ownerType, item.ownerId, item.clientId)}
|
||||
{formatOwnershipLabel(item.ownerType, item.ownerId, item.clientId, item.ownerLabel)}
|
||||
</Tag>
|
||||
) : null}
|
||||
<Tag bordered={false} color={item.active ? 'green' : 'default'}>
|
||||
@@ -2168,7 +2183,7 @@ export function BaseballTicketBayPlayAppView({ onBack, launchContext = 'direct',
|
||||
<strong>{item.alertTitle}</strong>
|
||||
{isViewingAllClients || isSharedTokenScope ? (
|
||||
<span className="baseball-ticket-bay-app__log-client">
|
||||
{formatOwnershipLabel(item.ownerType, item.ownerId, item.clientId)}
|
||||
{formatOwnershipLabel(item.ownerType, item.ownerId, item.clientId, item.ownerLabel)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -16,6 +16,7 @@ export type BaseballTicketBayAlertItem = {
|
||||
clientId: string;
|
||||
ownerType: 'client' | 'shared-token';
|
||||
ownerId: string;
|
||||
ownerLabel?: string | null;
|
||||
title: string;
|
||||
eventDate: string;
|
||||
team: string;
|
||||
@@ -92,6 +93,7 @@ export type BaseballTicketBayAlertLogItem = {
|
||||
clientId: string;
|
||||
ownerType: 'client' | 'shared-token';
|
||||
ownerId: string;
|
||||
ownerLabel?: string | null;
|
||||
alertId: string | null;
|
||||
alertTitle: string;
|
||||
action: 'create' | 'run' | 'pause' | 'resume' | 'delete' | 'push';
|
||||
@@ -104,7 +106,7 @@ export type BaseballTicketBayAlertLogItem = {
|
||||
|
||||
type BaseballTicketBayAlertMutation = Omit<
|
||||
BaseballTicketBayAlertItem,
|
||||
'id' | 'clientId' | 'ownerType' | 'ownerId' | 'createdAt' | 'updatedAt' | 'lastRunAt' | 'lastMatchAt'
|
||||
'id' | 'clientId' | 'ownerType' | 'ownerId' | 'ownerLabel' | 'createdAt' | 'updatedAt' | 'lastRunAt' | 'lastMatchAt'
|
||||
>;
|
||||
|
||||
type BaseballTicketBayAlertsResponse = {
|
||||
|
||||
Reference in New Issue
Block a user