운영 기능 및 GA4 대시보드 반영

This commit is contained in:
박성필
2026-08-18 10:53:58 +09:00
parent 612e87847c
commit b492abd3b4
24 changed files with 1521 additions and 77 deletions

View File

@@ -64,13 +64,40 @@ final class AdminRepository
{ {
$statement = $this->db->prepare( $statement = $this->db->prepare(
'SELECT posts.*, 'SELECT posts.*,
(SELECT file_path FROM post_images WHERE post_id = posts.id ORDER BY sort_order, id LIMIT 1) AS image_path, (SELECT file_path FROM post_images WHERE post_id = posts.id AND caption NOT LIKE \'@gtsit:inline:%\' ORDER BY sort_order, id LIMIT 1) AS image_path,
(SELECT alt_text FROM post_images WHERE post_id = posts.id ORDER BY sort_order, id LIMIT 1) AS image_alt (SELECT alt_text FROM post_images WHERE post_id = posts.id AND caption NOT LIKE \'@gtsit:inline:%\' ORDER BY sort_order, id LIMIT 1) AS image_alt
FROM posts WHERE posts.id = :id AND posts.deleted_at IS NULL LIMIT 1' FROM posts WHERE posts.id = :id AND posts.deleted_at IS NULL LIMIT 1'
); );
$statement->execute(['id' => $id]); $statement->execute(['id' => $id]);
$post = $statement->fetch(); $post = $statement->fetch();
return $post ?: null; if (!$post) {
return null;
}
$media = $this->postMedia($id);
$post['gallery_images'] = $media['gallery'];
$post['inline_images'] = $media['inline'];
return $post;
}
public function postMedia(int $postId): array
{
$statement = $this->db->prepare(
'SELECT id, file_path, alt_text, caption, sort_order
FROM post_images WHERE post_id = :post_id ORDER BY sort_order, id'
);
$statement->execute(['post_id' => $postId]);
$gallery = [];
$inline = [];
foreach ($statement->fetchAll() as $image) {
if (preg_match('/^@gtsit:inline:(\d+)$/', (string) $image['caption'], $matches) === 1) {
$image['token'] = (int) $matches[1];
$inline[] = $image;
continue;
}
$gallery[] = $image;
}
return ['gallery' => $gallery, 'inline' => $inline];
} }
public function savePost(array $data, ?int $id = null): int public function savePost(array $data, ?int $id = null): int
@@ -80,16 +107,20 @@ final class AdminRepository
: null; : null;
if ($id === null) { if ($id === null) {
$temporarySlug = 'pending-' . bin2hex(random_bytes(12));
$statement = $this->db->prepare( $statement = $this->db->prepare(
'INSERT INTO posts (title, slug, category, excerpt, body, author, status, published_at) 'INSERT INTO posts (title, slug, category, excerpt, body, author, status, published_at)
VALUES (:title, :slug, :category, :excerpt, :body, :author, :status, :published_at)' VALUES (:title, :slug, :category, :excerpt, :body, :author, :status, :published_at)'
); );
$statement->execute([ $statement->execute([
'title'=>$data['title'],'slug'=>$data['slug'],'category'=>$data['category'], 'title'=>$data['title'],'slug'=>$temporarySlug,'category'=>$data['category'],
'excerpt'=>$data['excerpt'],'body'=>$data['body'],'author'=>$data['author'], 'excerpt'=>$data['excerpt'],'body'=>$data['body'],'author'=>$data['author'],
'status'=>$data['status'],'published_at'=>$publishedAt, 'status'=>$data['status'],'published_at'=>$publishedAt,
]); ]);
return (int) $this->db->lastInsertId(); $postId = (int) $this->db->lastInsertId();
$updateSlug = $this->db->prepare('UPDATE posts SET slug = :slug WHERE id = :id');
$updateSlug->execute(['slug' => 'post-' . $postId, 'id' => $postId]);
return $postId;
} }
$statement = $this->db->prepare( $statement = $this->db->prepare(
@@ -97,7 +128,7 @@ final class AdminRepository
body=:body, author=:author, status=:status, published_at=:published_at WHERE id=:id AND deleted_at IS NULL' body=:body, author=:author, status=:status, published_at=:published_at WHERE id=:id AND deleted_at IS NULL'
); );
$statement->execute([ $statement->execute([
'title'=>$data['title'],'slug'=>$data['slug'],'category'=>$data['category'], 'title'=>$data['title'],'slug'=>'post-' . $id,'category'=>$data['category'],
'excerpt'=>$data['excerpt'],'body'=>$data['body'],'author'=>$data['author'], 'excerpt'=>$data['excerpt'],'body'=>$data['body'],'author'=>$data['author'],
'status'=>$data['status'],'published_at'=>$publishedAt,'id'=>$id, 'status'=>$data['status'],'published_at'=>$publishedAt,'id'=>$id,
]); ]);
@@ -138,17 +169,84 @@ final class AdminRepository
} }
} }
public function slugExists(string $slug, ?int $exceptId = null): bool public function savePostWithMedia(
{ array $data,
$sql = 'SELECT COUNT(*) FROM posts WHERE slug = :slug AND deleted_at IS NULL'; ?int $id,
$params = ['slug' => $slug]; array $galleryImages,
if ($exceptId !== null) { array $inlineImages,
$sql .= ' AND id <> :id'; array $deleteImageIds,
$params['id'] = $exceptId; array $imageAlts
): array {
$oldPaths = [];
$this->db->beginTransaction();
try {
$postId = $this->savePost($data, $id);
$existing = $this->db->prepare(
'SELECT id, file_path, caption FROM post_images WHERE post_id = :post_id FOR UPDATE'
);
$existing->execute(['post_id' => $postId]);
$existingImages = $existing->fetchAll();
$existingIds = array_map('intval', array_column($existingImages, 'id'));
foreach ($imageAlts as $imageId => $altText) {
$imageId = (int) $imageId;
if (!in_array($imageId, $existingIds, true)) continue;
$update = $this->db->prepare(
'UPDATE post_images SET alt_text = :alt_text WHERE id = :id AND post_id = :post_id'
);
$update->execute(['alt_text' => $altText, 'id' => $imageId, 'post_id' => $postId]);
}
$deleteIds = array_values(array_intersect($existingIds, array_map('intval', $deleteImageIds)));
if ($deleteIds !== []) {
foreach ($existingImages as $existingImage) {
if (in_array((int) $existingImage['id'], $deleteIds, true)) {
$oldPaths[] = (string) $existingImage['file_path'];
}
}
$placeholders = implode(',', array_fill(0, count($deleteIds), '?'));
$delete = $this->db->prepare("DELETE FROM post_images WHERE post_id = ? AND id IN ({$placeholders})");
$delete->execute([$postId, ...$deleteIds]);
}
$galleryOrder = 0;
foreach ($existingImages as $existingImage) {
if (!in_array((int) $existingImage['id'], $deleteIds, true)
&& !str_starts_with((string) $existingImage['caption'], '@gtsit:inline:')) {
$galleryOrder++;
}
}
$insert = $this->db->prepare(
'INSERT INTO post_images (post_id, file_path, alt_text, caption, sort_order)
VALUES (:post_id, :file_path, :alt_text, :caption, :sort_order)'
);
foreach ($galleryImages as $image) {
$insert->execute([
'post_id' => $postId,
'file_path' => $image['path'],
'alt_text' => $image['alt'],
'caption' => '@gtsit:gallery',
'sort_order' => $galleryOrder++,
]);
}
foreach ($inlineImages as $image) {
$insert->execute([
'post_id' => $postId,
'file_path' => $image['path'],
'alt_text' => $image['alt'],
'caption' => '@gtsit:inline:' . $image['token'],
'sort_order' => 1000 + (int) $image['token'],
]);
}
$this->db->commit();
return ['id' => $postId, 'old_paths' => $oldPaths];
} catch (\Throwable $exception) {
if ($this->db->inTransaction()) {
$this->db->rollBack();
}
throw $exception;
} }
$statement = $this->db->prepare($sql);
$statement->execute($params);
return (int) $statement->fetchColumn() > 0;
} }
public function deletePost(int $id): void public function deletePost(int $id): void

View File

@@ -20,8 +20,8 @@ final class PostRepository
{ {
$statement = $this->db->prepare( $statement = $this->db->prepare(
"SELECT posts.id, posts.title, posts.slug, posts.category, posts.excerpt, posts.published_at, "SELECT posts.id, posts.title, posts.slug, posts.category, posts.excerpt, posts.published_at,
(SELECT file_path FROM post_images WHERE post_id = posts.id ORDER BY sort_order, id LIMIT 1) AS image_path, (SELECT file_path FROM post_images WHERE post_id = posts.id AND caption NOT LIKE '@gtsit:inline:%' ORDER BY sort_order, id LIMIT 1) AS image_path,
(SELECT alt_text FROM post_images WHERE post_id = posts.id ORDER BY sort_order, id LIMIT 1) AS image_alt (SELECT alt_text FROM post_images WHERE post_id = posts.id AND caption NOT LIKE '@gtsit:inline:%' ORDER BY sort_order, id LIMIT 1) AS image_alt
FROM posts FROM posts
WHERE posts.status = 'published' AND posts.deleted_at IS NULL AND posts.published_at <= NOW() WHERE posts.status = 'published' AND posts.deleted_at IS NULL AND posts.published_at <= NOW()
ORDER BY published_at DESC, id DESC ORDER BY published_at DESC, id DESC
@@ -48,8 +48,8 @@ final class PostRepository
$offset = max(0, ($page - 1) * $perPage); $offset = max(0, ($page - 1) * $perPage);
$query = $this->db->prepare( $query = $this->db->prepare(
"SELECT posts.id, posts.title, posts.slug, posts.category, posts.excerpt, posts.published_at, "SELECT posts.id, posts.title, posts.slug, posts.category, posts.excerpt, posts.published_at,
(SELECT file_path FROM post_images WHERE post_id = posts.id ORDER BY sort_order, id LIMIT 1) AS image_path, (SELECT file_path FROM post_images WHERE post_id = posts.id AND caption NOT LIKE '@gtsit:inline:%' ORDER BY sort_order, id LIMIT 1) AS image_path,
(SELECT alt_text FROM post_images WHERE post_id = posts.id ORDER BY sort_order, id LIMIT 1) AS image_alt (SELECT alt_text FROM post_images WHERE post_id = posts.id AND caption NOT LIKE '@gtsit:inline:%' ORDER BY sort_order, id LIMIT 1) AS image_alt
FROM posts WHERE {$where} FROM posts WHERE {$where}
ORDER BY published_at DESC, id DESC ORDER BY published_at DESC, id DESC
LIMIT :limit OFFSET :offset" LIMIT :limit OFFSET :offset"
@@ -68,14 +68,34 @@ final class PostRepository
{ {
$statement = $this->db->prepare( $statement = $this->db->prepare(
"SELECT posts.id, posts.title, posts.slug, posts.category, posts.excerpt, posts.body, posts.author, posts.published_at, "SELECT posts.id, posts.title, posts.slug, posts.category, posts.excerpt, posts.body, posts.author, posts.published_at,
(SELECT file_path FROM post_images WHERE post_id = posts.id ORDER BY sort_order, id LIMIT 1) AS image_path, (SELECT file_path FROM post_images WHERE post_id = posts.id AND caption NOT LIKE '@gtsit:inline:%' ORDER BY sort_order, id LIMIT 1) AS image_path,
(SELECT alt_text FROM post_images WHERE post_id = posts.id ORDER BY sort_order, id LIMIT 1) AS image_alt (SELECT alt_text FROM post_images WHERE post_id = posts.id AND caption NOT LIKE '@gtsit:inline:%' ORDER BY sort_order, id LIMIT 1) AS image_alt
FROM posts FROM posts
WHERE posts.slug = :slug AND posts.status = 'published' AND posts.deleted_at IS NULL AND posts.published_at <= NOW() WHERE posts.slug = :slug AND posts.status = 'published' AND posts.deleted_at IS NULL AND posts.published_at <= NOW()
LIMIT 1" LIMIT 1"
); );
$statement->execute(['slug' => $slug]); $statement->execute(['slug' => $slug]);
$post = $statement->fetch(); $post = $statement->fetch();
return $post ?: null; return $post ? $this->withMedia($post) : null;
}
public function withMedia(array $post): array
{
$statement = $this->db->prepare(
'SELECT id, file_path, alt_text, caption, sort_order
FROM post_images WHERE post_id = :post_id ORDER BY sort_order, id'
);
$statement->execute(['post_id' => $post['id']]);
$post['gallery_images'] = [];
$post['inline_images'] = [];
foreach ($statement->fetchAll() as $image) {
if (preg_match('/^@gtsit:inline:(\d+)$/', (string) $image['caption'], $matches) === 1) {
$image['token'] = (int) $matches[1];
$post['inline_images'][(int) $matches[1]] = $image;
continue;
}
$post['gallery_images'][] = $image;
}
return $post;
} }
} }

View File

@@ -0,0 +1,253 @@
<?php
declare(strict_types=1);
namespace App\Services;
use RuntimeException;
use Throwable;
final class AnalyticsService
{
private string $propertyId;
private string $credentialsPath;
private string $cachePath;
private int $cacheTtl;
public function __construct()
{
$this->propertyId = (string) config('app.analytics.property_id', '');
$this->credentialsPath = (string) config('app.analytics.credentials_path', '');
$this->cachePath = (string) config('app.analytics.cache_path', '');
$this->cacheTtl = max(60, (int) config('app.analytics.cache_ttl', 900));
}
public function dashboard(): array
{
$cache = $this->readCache();
if ($cache !== null && time() - (int) ($cache['cached_at'] ?? 0) < $this->cacheTtl) {
return $cache + ['available' => true, 'stale' => false];
}
try {
$credentials = $this->credentials();
$token = $this->accessToken($credentials);
$data = [
'available' => true,
'stale' => false,
'cached_at' => time(),
'realtime' => $this->realtime($token),
'summary' => $this->summary($token),
'all_time_users' => $this->allTimeUsers($token),
'daily' => $this->daily($token),
'pages' => $this->pages($token),
'channels' => $this->channels($token),
];
$this->writeCache($data);
return $data;
} catch (Throwable) {
if ($cache !== null) {
$cache['available'] = true;
$cache['stale'] = true;
return $cache;
}
return [
'available' => false,
'stale' => false,
'message' => '방문 통계를 불러오지 못했습니다. 잠시 후 다시 확인해 주세요.',
];
}
}
private function credentials(): array
{
if ($this->propertyId === '' || !is_file($this->credentialsPath) || !is_readable($this->credentialsPath)) {
throw new RuntimeException('Analytics configuration is unavailable.');
}
$credentials = json_decode((string) file_get_contents($this->credentialsPath), true);
if (!is_array($credentials) || empty($credentials['client_email']) || empty($credentials['private_key'])) {
throw new RuntimeException('Analytics credentials are invalid.');
}
return $credentials;
}
private function accessToken(array $credentials): string
{
$now = time();
$header = $this->base64Url(json_encode(['alg' => 'RS256', 'typ' => 'JWT'], JSON_THROW_ON_ERROR));
$claims = $this->base64Url(json_encode([
'iss' => $credentials['client_email'],
'scope' => 'https://www.googleapis.com/auth/analytics.readonly',
'aud' => 'https://oauth2.googleapis.com/token',
'iat' => $now,
'exp' => $now + 3600,
], JSON_THROW_ON_ERROR));
$unsigned = $header . '.' . $claims;
$signature = '';
if (!openssl_sign($unsigned, $signature, (string) $credentials['private_key'], OPENSSL_ALGO_SHA256)) {
throw new RuntimeException('Analytics token signing failed.');
}
$jwt = $unsigned . '.' . $this->base64Url($signature);
$response = $this->request('https://oauth2.googleapis.com/token', [
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
'assertion' => $jwt,
], true);
$token = (string) ($response['access_token'] ?? '');
if ($token === '') {
throw new RuntimeException('Analytics token was not issued.');
}
return $token;
}
private function realtime(string $token): int
{
$report = $this->report($token, 'runRealtimeReport', [
'metrics' => [['name' => 'activeUsers']],
]);
return (int) ($report['rows'][0]['metricValues'][0]['value'] ?? 0);
}
private function summary(string $token): array
{
$report = $this->report($token, 'runReport', [
'dateRanges' => [['startDate' => '30daysAgo', 'endDate' => 'today']],
'metrics' => array_map(static fn(string $name): array => ['name' => $name], [
'activeUsers', 'newUsers', 'screenPageViews', 'sessions',
]),
]);
$values = $report['rows'][0]['metricValues'] ?? [];
return [
'active_users' => (int) ($values[0]['value'] ?? 0),
'new_users' => (int) ($values[1]['value'] ?? 0),
'page_views' => (int) ($values[2]['value'] ?? 0),
'sessions' => (int) ($values[3]['value'] ?? 0),
];
}
private function allTimeUsers(string $token): int
{
$report = $this->report($token, 'runReport', [
'dateRanges' => [['startDate' => '2026-08-12', 'endDate' => 'today']],
'metrics' => [['name' => 'totalUsers']],
]);
return (int) ($report['rows'][0]['metricValues'][0]['value'] ?? 0);
}
private function daily(string $token): array
{
$report = $this->report($token, 'runReport', [
'dateRanges' => [['startDate' => '13daysAgo', 'endDate' => 'today']],
'dimensions' => [['name' => 'date']],
'metrics' => [['name' => 'activeUsers'], ['name' => 'screenPageViews']],
'orderBys' => [['dimension' => ['dimensionName' => 'date']]],
]);
return array_map(static function (array $row): array {
$date = (string) ($row['dimensionValues'][0]['value'] ?? '');
return [
'date' => strlen($date) === 8 ? substr($date, 4, 2) . '.' . substr($date, 6, 2) : $date,
'users' => (int) ($row['metricValues'][0]['value'] ?? 0),
'views' => (int) ($row['metricValues'][1]['value'] ?? 0),
];
}, $report['rows'] ?? []);
}
private function pages(string $token): array
{
$report = $this->report($token, 'runReport', [
'dateRanges' => [['startDate' => '30daysAgo', 'endDate' => 'today']],
'dimensions' => [['name' => 'pageTitle'], ['name' => 'pagePath']],
'metrics' => [['name' => 'screenPageViews']],
'orderBys' => [['metric' => ['metricName' => 'screenPageViews'], 'desc' => true]],
'limit' => 5,
]);
return array_map(static fn(array $row): array => [
'title' => (string) ($row['dimensionValues'][0]['value'] ?? '(제목 없음)'),
'path' => (string) ($row['dimensionValues'][1]['value'] ?? '/'),
'views' => (int) ($row['metricValues'][0]['value'] ?? 0),
], $report['rows'] ?? []);
}
private function channels(string $token): array
{
$report = $this->report($token, 'runReport', [
'dateRanges' => [['startDate' => '30daysAgo', 'endDate' => 'today']],
'dimensions' => [['name' => 'sessionDefaultChannelGroup']],
'metrics' => [['name' => 'sessions'], ['name' => 'activeUsers']],
'orderBys' => [['metric' => ['metricName' => 'sessions'], 'desc' => true]],
'limit' => 5,
]);
return array_map(static fn(array $row): array => [
'channel' => (string) ($row['dimensionValues'][0]['value'] ?? 'Unassigned'),
'sessions' => (int) ($row['metricValues'][0]['value'] ?? 0),
'users' => (int) ($row['metricValues'][1]['value'] ?? 0),
], $report['rows'] ?? []);
}
private function report(string $token, string $method, array $body): array
{
return $this->request(
'https://analyticsdata.googleapis.com/v1beta/properties/' . rawurlencode($this->propertyId) . ':' . $method,
$body,
false,
$token
);
}
private function request(string $url, array $body, bool $form = false, string $token = ''): array
{
$handle = curl_init($url);
if ($handle === false) {
throw new RuntimeException('HTTP client initialization failed.');
}
$headers = ['Accept: application/json'];
$payload = $form ? http_build_query($body) : json_encode($body, JSON_THROW_ON_ERROR);
$headers[] = $form ? 'Content-Type: application/x-www-form-urlencoded' : 'Content-Type: application/json';
if ($token !== '') {
$headers[] = 'Authorization: Bearer ' . $token;
}
curl_setopt_array($handle, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => 12,
]);
$raw = curl_exec($handle);
$status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
$error = curl_error($handle);
curl_close($handle);
if (!is_string($raw) || $status < 200 || $status >= 300) {
throw new RuntimeException('Analytics API request failed: ' . ($error !== '' ? $error : (string) $status));
}
$decoded = json_decode($raw, true);
if (!is_array($decoded)) {
throw new RuntimeException('Analytics API response is invalid.');
}
return $decoded;
}
private function base64Url(string $value): string
{
return rtrim(strtr(base64_encode($value), '+/', '-_'), '=');
}
private function readCache(): ?array
{
if ($this->cachePath === '' || !is_file($this->cachePath) || !is_readable($this->cachePath)) {
return null;
}
$cache = json_decode((string) file_get_contents($this->cachePath), true);
return is_array($cache) ? $cache : null;
}
private function writeCache(array $data): void
{
$directory = dirname($this->cachePath);
if (!is_dir($directory) && !mkdir($directory, 0700, true) && !is_dir($directory)) {
return;
}
file_put_contents($this->cachePath, json_encode($data, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), LOCK_EX);
@chmod($this->cachePath, 0600);
}
}

View File

@@ -1 +1,38 @@
<header class="admin-page-heading"><div><span>OVERVIEW</span><h1>대시보드</h1><p>사이트 콘텐츠와 문의 현황입니다.</p></div></header><section class="metric-grid" aria-label="운영 현황"><a href="/admin/posts"><span>전체 </span><strong><?= e($counts['posts']) ?></strong></a><a href="/admin/posts"><span>공개 글</span><strong><?= e($counts['published']) ?></strong></a><a href="/admin/inquiries"><span>전체 문의</span><strong><?= e($counts['inquiries']) ?></strong></a><a href="/admin/inquiries"><span>처리 대기</span><strong><?= e($counts['pending']) ?></strong></a></section> <?php
$summary = $analytics['summary'] ?? [];
$daily = $analytics['daily'] ?? [];
$maxViews = max(1, ...array_map(static fn(array $item): int => (int) ($item['views'] ?? 0), $daily ?: [['views' => 0]]));
$channelLabels = [
'Direct' => '직접 유입',
'Organic Search' => '검색 유입',
'Organic Social' => '소셜 유입',
'Referral' => '외부 링크',
'Unassigned' => '미분류',
];
?>
<header class="admin-page-heading"><div><span>OVERVIEW</span><h1>대시보드</h1><p>사이트 콘텐츠, 문의, 방문 현황입니다.</p></div></header>
<section class="metric-grid" aria-label="운영 현황"><a href="/admin/posts"><span>전체 글</span><strong><?= e($counts['posts']) ?></strong></a><a href="/admin/posts"><span>공개 글</span><strong><?= e($counts['published']) ?></strong></a><a href="/admin/inquiries"><span>전체 문의</span><strong><?= e($counts['inquiries']) ?></strong></a><a href="/admin/inquiries"><span>처리 대기</span><strong><?= e($counts['pending']) ?></strong></a></section>
<section class="analytics-section" aria-labelledby="analytics-heading">
<header class="analytics-heading"><div><span>GOOGLE ANALYTICS</span><h2 id="analytics-heading">방문 현황</h2><p>운영 사이트의 최근 30일 통계입니다. 관리자와 테스트 페이지는 제외됩니다.</p></div><?php if (!empty($analytics['cached_at'])): ?><small><?= e(date('Y.m.d H:i', (int) $analytics['cached_at'])) ?> 기준 · 15분 간격 갱신<?= !empty($analytics['stale']) ? ' · 마지막 정상 데이터' : '' ?></small><?php endif; ?></header>
<?php if (empty($analytics['available'])): ?>
<div class="analytics-unavailable" role="status"><strong>방문 통계를 표시할 수 없습니다.</strong><p><?= e($analytics['message'] ?? '잠시 후 다시 확인해 주세요.') ?></p></div>
<?php else: ?>
<div class="analytics-metrics" aria-label="방문 핵심 지표">
<article><span>최근 접속자</span><strong><?= e(number_format((int) ($analytics['realtime'] ?? 0))) ?></strong><small>최근 30분 내 활동</small></article>
<article><span>전체 방문자</span><strong><?= e(number_format((int) ($analytics['all_time_users'] ?? 0))) ?></strong><small>2026.08.12 측정 시작 이후</small></article>
<article><span>30일 방문자</span><strong><?= e(number_format((int) ($summary['active_users'] ?? 0))) ?></strong><small>최근 30일 내 활동</small></article>
<article><span>처음 온 방문자</span><strong><?= e(number_format((int) ($summary['new_users'] ?? 0))) ?></strong><small>최근 30일 첫 방문</small></article>
<article><span>페이지 열람 수</span><strong><?= e(number_format((int) ($summary['page_views'] ?? 0))) ?></strong><small>같은 페이지 반복 포함</small></article>
<article><span>사이트 방문 횟수</span><strong><?= e(number_format((int) ($summary['sessions'] ?? 0))) ?></strong><small>방문을 시작한 횟수</small></article>
</div>
<div class="analytics-panel analytics-chart-panel">
<div class="analytics-panel-heading"><h3>14일 페이지 열람 추이</h3><span><i></i> 열람 수</span></div>
<?php if ($daily): ?><div class="analytics-chart" role="img" aria-label="최근 14일 일별 페이지 열람 수 막대 차트"><?php foreach ($daily as $item): ?><div class="analytics-bar" title="<?= e($item['date']) ?> · 페이지 열람 <?= e($item['views']) ?> · 방문자 <?= e($item['users']) ?>"><span><?= e($item['views']) ?></span><i style="height:<?= e(max(3, (int) round(((int) $item['views'] / $maxViews) * 100))) ?>%"></i><small><?= e($item['date']) ?></small></div><?php endforeach; ?></div><?php else: ?><p class="analytics-empty">아직 집계된 방문 데이터가 없습니다.</p><?php endif; ?>
</div>
<div class="analytics-detail-grid">
<section class="analytics-panel"><div class="analytics-panel-heading"><h3>많이 본 페이지</h3><span>열람 수</span></div><?php if (!empty($analytics['pages'])): ?><ol class="analytics-ranking"><?php foreach ($analytics['pages'] as $page): ?><li><div><strong><?= e($page['title']) ?></strong><small><?= e($page['path']) ?></small></div><b><?= e(number_format((int) $page['views'])) ?></b></li><?php endforeach; ?></ol><?php else: ?><p class="analytics-empty">아직 집계된 페이지가 없습니다.</p><?php endif; ?></section>
<section class="analytics-panel"><div class="analytics-panel-heading"><h3>방문한 경로</h3><span>방문 횟수 / 방문자</span></div><?php if (!empty($analytics['channels'])): ?><ol class="analytics-ranking"><?php foreach ($analytics['channels'] as $channel): ?><li><div><strong><?= e($channelLabels[$channel['channel']] ?? $channel['channel']) ?></strong><small><?= e($channel['channel']) ?></small></div><b><?= e(number_format((int) $channel['sessions'])) ?> / <?= e(number_format((int) $channel['users'])) ?></b></li><?php endforeach; ?></ol><?php else: ?><p class="analytics-empty">아직 집계된 유입 데이터가 없습니다.</p><?php endif; ?></section>
</div>
<?php endif; ?>
</section>

View File

@@ -1,23 +1,61 @@
<header class="admin-page-heading"> <header class="admin-page-heading">
<div><span>CONTENTS</span><h1><?= $post ? '글 수정' : '새 글 작성' ?></h1><p>초안으로 저장하거나 공개 상태로 게시합니다.</p></div> <div><span>CONTENTS</span><h1><?= $post ? '글 수정' : '새 글 작성' ?></h1><p>초안으로 저장하거나 공개 상태로 게시합니다.</p></div>
</header> </header>
<?php if (!empty($mediaExperiment)): ?><div class="admin-experiment-notice"><strong>테스트 기능</strong><span>여러 대표 이미지와 본문 이미지는 테스트 경로의 초안 미리보기에서만 확인합니다. 확인 전 운영 공개는 제한됩니다.</span></div><?php endif; ?>
<?php if ($errors): ?><div class="admin-error" role="alert"><strong>입력 내용을 확인해 주세요.</strong><ul><?php foreach ($errors as $error): ?><li><?= e($error) ?></li><?php endforeach; ?></ul></div><?php endif; ?> <?php if ($errors): ?><div class="admin-error" role="alert"><strong>입력 내용을 확인해 주세요.</strong><ul><?php foreach ($errors as $error): ?><li><?= e($error) ?></li><?php endforeach; ?></ul></div><?php endif; ?>
<form class="admin-form" method="post" enctype="multipart/form-data" action="<?= $post ? '/admin/posts/' . e($post['id']) . '/edit' : '/admin/posts/new' ?>"> <form class="admin-form" method="post" enctype="multipart/form-data" action="<?= $post ? '/admin/posts/' . e($post['id']) . '/edit' : '/admin/posts/new' ?>">
<?= csrf_field() ?> <?= csrf_field() ?>
<label>제목 <span>*</span><input name="title" value="<?= old('title', $post['title'] ?? '') ?>" required maxlength="200"></label> <label>제목 <span>*</span><input name="title" value="<?= old('title', $post['title'] ?? '') ?>" required maxlength="200"></label>
<div class="admin-field-grid"> <div class="admin-field-grid">
<label>URL slug <span>*</span><input name="slug" value="<?= old('slug', $post['slug'] ?? '') ?>" required maxlength="220" pattern="[a-z0-9-]+" placeholder="office-network-checklist"><small>영문 소문자, 숫자, 하이픈만 사용</small></label> <label>게시글 URL<?php if ($post): ?><input value="/blog/post-<?= e($post['id']) ?>" readonly aria-describedby="post-url-help"><?php else: ?><input value="저장 시 자동 생성" readonly aria-describedby="post-url-help"><?php endif; ?><small id="post-url-help"><?= $post ? '게시글 번호를 기준으로 자동 관리됩니다.' : '저장 후 post-글번호 형식으로 생성됩니다.' ?></small></label>
<label>카테고리<select name="category"><?php $currentCategory=$_POST['category']??$post['category']??'네트워크'; foreach(['네트워크','키폰시스템','CCTV','시공사례'] as $option): ?><option<?= $currentCategory===$option?' selected':'' ?>><?= e($option) ?></option><?php endforeach; ?></select></label> <label>카테고리<select name="category"><?php $currentCategory=$_POST['category']??$post['category']??'네트워크'; foreach(['네트워크','키폰시스템','CCTV','시공사례'] as $option): ?><option<?= $currentCategory===$option?' selected':'' ?>><?= e($option) ?></option><?php endforeach; ?></select></label>
</div> </div>
<label>목록 요약<textarea name="excerpt" rows="3" maxlength="500"><?= old('excerpt', $post['excerpt'] ?? '') ?></textarea></label> <label>목록 요약<textarea name="excerpt" rows="3" maxlength="500"><?= old('excerpt', $post['excerpt'] ?? '') ?></textarea></label>
<fieldset class="admin-upload-field"> <?php if (!empty($mediaExperiment)): ?>
<legend>대표 이미지</legend> <?php
<?php if (!empty($post['image_path'])): ?><img src="<?= e($post['image_path']) ?>" alt="<?= e($post['image_alt']) ?>"><p>새 파일을 선택하면 현재 대표 이미지가 교체됩니다.</p><?php endif; ?> $galleryImages = $post['gallery_images'] ?? [];
<label>이미지 파일<input type="file" name="image" accept="image/jpeg,image/png,image/webp"><small>JPG, PNG, WebP · 최대 8MB · 최대 6000×6000px</small></label> $inlineImages = $post['inline_images'] ?? [];
<label>대체 텍스트<input name="image_alt" value="<?= old('image_alt', $post['image_alt'] ?? '') ?>" maxlength="255"><small>이미지 내용을 간단히 설명합니다. 이미지를 등록할 때 필수입니다.</small></label> $nextInlineToken = 1;
</fieldset> foreach ($inlineImages as $inlineImage) $nextInlineToken = max($nextInlineToken, (int) $inlineImage['token'] + 1);
<label>본문 <span>*</span><textarea name="body" rows="16" required><?= old('body', $post['body'] ?? '') ?></textarea><small>빈 줄을 기준으로 문단이 구분됩니다.</small></label> ?>
<fieldset class="admin-upload-field" data-media-upload="gallery">
<legend>대표 이미지 슬라이드</legend>
<p>최대 6장 · 2장 이상이면 글 상단에서 5초 간격으로 자동 슬라이드됩니다.</p>
<?php if ($galleryImages): ?><div class="admin-media-list"><?php foreach ($galleryImages as $image): ?><article class="admin-media-item"><img src="<?= e($image['file_path']) ?>" alt=""><div><label>대체 텍스트<input name="media_alt[<?= e($image['id']) ?>]" value="<?= e($image['alt_text']) ?>" maxlength="255" required></label><label class="admin-media-delete"><input type="checkbox" name="delete_images[]" value="<?= e($image['id']) ?>"> 이 이미지 삭제</label></div></article><?php endforeach; ?></div><?php endif; ?>
<label>이미지 파일<input type="file" name="gallery_images[]" accept="image/jpeg,image/png,image/webp" multiple data-media-files><small>JPG, PNG, WebP · 파일당 최대 8MB · 최대 6000×6000px</small></label>
<div class="admin-new-media-list" data-media-list aria-live="polite"></div>
</fieldset>
<div class="admin-body-source" data-body-source><label>본문 <span>*</span><textarea name="body" rows="16" required data-post-body><?= old('body', $post['body'] ?? '') ?></textarea><small>JavaScript를 사용할 수 없을 때는 빈 줄로 문단을 나누고 이미지 위치 토큰을 사용합니다.</small></label></div>
<section class="admin-block-editor" data-block-editor data-next-token="<?= e($nextInlineToken) ?>" aria-labelledby="block-editor-title">
<header><div><h2 id="block-editor-title">본문 편집</h2><p>텍스트 문단과 이미지만 사용해 글의 구성을 정리합니다.</p></div><span>본문 이미지 최대 10장</span></header>
<div class="admin-block-list" data-block-list></div>
<div class="admin-block-editor-actions"><button class="button button-secondary" type="button" data-add-text>문단 추가</button><button class="button button-primary" type="button" data-add-image>이미지 추가</button></div>
<p class="admin-block-help">이미지는 JPG, PNG, WebP 형식으로 파일당 8MB·6000×6000px까지 등록할 수 있습니다.</p>
<div data-delete-bin></div>
<div data-inline-media-inventory hidden><?php foreach ($inlineImages as $image): ?><span data-existing-inline data-id="<?= e($image['id']) ?>" data-token="<?= e($image['token']) ?>" data-path="<?= e(url($image['file_path'])) ?>" data-alt="<?= e($image['alt_text']) ?>"></span><?php endforeach; ?></div>
</section>
<?php else: ?>
<fieldset class="admin-upload-field">
<legend>대표 이미지</legend>
<?php if (!empty($post['image_path'])): ?><img src="<?= e($post['image_path']) ?>" alt="<?= e($post['image_alt']) ?>"><p>새 파일을 선택하면 현재 대표 이미지가 교체됩니다.</p><?php endif; ?>
<label>이미지 파일<input type="file" name="image" accept="image/jpeg,image/png,image/webp"><small>JPG, PNG, WebP · 최대 8MB · 최대 6000×6000px</small></label>
<label>대체 텍스트<input name="image_alt" value="<?= old('image_alt', $post['image_alt'] ?? '') ?>" maxlength="255"><small>이미지 내용을 간단히 설명합니다. 이미지를 등록할 때 필수입니다.</small></label>
</fieldset>
<label>본문 <span>*</span><textarea name="body" rows="16" required><?= old('body', $post['body'] ?? '') ?></textarea><small>빈 줄을 기준으로 문단이 구분됩니다.</small></label>
<?php endif; ?>
<div class="admin-field-grid"><label>작성자<input name="author" value="<?= old('author', $post['author'] ?? config('app.legal_name')) ?>" maxlength="100"></label><label>공개 상태<select name="status"><option value="draft"<?= ($_POST['status']??$post['status']??'draft')==='draft'?' selected':'' ?>>초안</option><option value="published"<?= ($_POST['status']??$post['status']??'draft')==='published'?' selected':'' ?>>공개</option></select></label></div> <div class="admin-field-grid"><label>작성자<input name="author" value="<?= old('author', $post['author'] ?? config('app.legal_name')) ?>" maxlength="100"></label><label>공개 상태<select name="status"><option value="draft"<?= ($_POST['status']??$post['status']??'draft')==='draft'?' selected':'' ?>>초안</option><option value="published"<?= ($_POST['status']??$post['status']??'draft')==='published'?' selected':'' ?>>공개</option></select></label></div>
<div class="admin-form-actions"><button class="button button-primary" type="submit">저장하기</button><a class="button button-secondary" href="/admin/posts">취소</a></div> <div class="admin-form-actions"><button class="button button-primary" type="submit">저장하기</button><?php if (!empty($mediaExperiment)): ?><button class="button button-secondary" type="button" data-open-live-preview>작성 내용 미리보기</button><?php endif; ?><a class="button button-secondary" href="/admin/posts">취소</a></div>
</form> </form>
<?php if (!empty($mediaExperiment)): ?>
<dialog class="admin-live-preview" data-live-preview aria-labelledby="live-preview-title">
<div class="admin-live-preview-shell">
<header><div><span>LIVE PREVIEW</span><h2 id="live-preview-title">작성 내용 미리보기</h2><p>현재 입력 상태를 저장하지 않고 확인합니다.</p></div><button type="button" data-close-live-preview aria-label="미리보기 닫기">×</button></header>
<article class="admin-live-preview-article">
<div class="admin-live-preview-meta"><span data-preview-category></span><strong data-preview-title></strong><small data-preview-author></small></div>
<section class="admin-live-preview-gallery" data-preview-gallery hidden><div data-preview-gallery-main></div><div data-preview-gallery-thumbs></div><p data-preview-gallery-count></p></section>
<div class="admin-live-preview-body" data-preview-body></div>
</article>
</div>
</dialog>
<?php endif; ?>
<?php if ($post): ?><form class="danger-zone" method="post" action="/admin/posts/<?= e($post['id']) ?>/delete" onsubmit="return confirm('이 글을 삭제할까요? 삭제 후 목록에 표시되지 않습니다.');"><?= csrf_field() ?><div><strong>글 삭제</strong><p>게시글을 복구 가능한 삭제 상태로 전환합니다.</p></div><button type="submit">삭제</button></form><?php endif; ?> <?php if ($post): ?><form class="danger-zone" method="post" action="/admin/posts/<?= e($post['id']) ?>/delete" onsubmit="return confirm('이 글을 삭제할까요? 삭제 후 목록에 표시되지 않습니다.');"><?= csrf_field() ?><div><strong>글 삭제</strong><p>게시글을 복구 가능한 삭제 상태로 전환합니다.</p></div><button type="submit">삭제</button></form><?php endif; ?>

View File

@@ -1 +1 @@
<header class="admin-page-heading"><div><span>CONTENTS</span><h1> 관리</h1><p>블로그 글을 작성하고 공개 상태를 관리합니다.</p></div><a class="button button-primary" href="/admin/posts/new"> 작성</a></header><?php if ($posts): ?><div class="admin-table-wrap"><table class="admin-table"><thead><tr><th>제목</th><th>분류</th><th>상태</th><th>공개일</th><th>관리</th></tr></thead><tbody><?php foreach ($posts as $post): ?><tr><td data-label="제목"><strong><?= e($post['title']) ?></strong><small>/blog/<?= e($post['slug']) ?></small></td><td data-label="분류"><?= e($post['category']) ?></td><td data-label="상태"><span class="status status-<?= e($post['status']) ?>"><?= $post['status'] === 'published' ? '공개' : '초안' ?></span></td><td data-label="공개일"><?= $post['published_at'] ? e(date('Y.m.d', strtotime($post['published_at']))) : '-' ?></td><td data-label="관리"><a href="/admin/posts/<?= e($post['id']) ?>/edit">수정</a></td></tr><?php endforeach; ?></tbody></table></div><?php else: ?><div class="empty-state"><h2>등록된 글이 없습니다</h2><p>새 글을 작성해 콘텐츠를 추가해 주세요.</p></div><?php endif; ?> <header class="admin-page-heading"><div><span>CONTENTS</span><h1> 관리</h1><p>블로그 글을 작성하고 공개 상태를 관리합니다.</p></div><a class="button button-primary" href="/admin/posts/new"> 작성</a></header><?php if ($posts): ?><div class="admin-table-wrap"><table class="admin-table"><thead><tr><th>제목</th><th>분류</th><th>상태</th><th>공개일</th><th>관리</th></tr></thead><tbody><?php foreach ($posts as $post): ?><tr><td data-label="제목"><strong><?= e($post['title']) ?></strong><small>/blog/<?= e($post['slug']) ?></small></td><td data-label="분류"><?= e($post['category']) ?></td><td data-label="상태"><span class="status status-<?= e($post['status']) ?>"><?= $post['status'] === 'published' ? '공개' : '초안' ?></span></td><td data-label="공개일"><?= $post['published_at'] ? e(date('Y.m.d', strtotime($post['published_at']))) : '-' ?></td><td data-label="관리"><span class="admin-row-actions"><a href="/admin/posts/<?= e($post['id']) ?>/preview" target="_blank" rel="noopener">미리보기</a><a href="/admin/posts/<?= e($post['id']) ?>/edit">수정</a></span></td></tr><?php endforeach; ?></tbody></table></div><?php else: ?><div class="empty-state"><h2>등록된 글이 없습니다</h2><p>새 글을 작성해 콘텐츠를 추가해 주세요.</p></div><?php endif; ?>

View File

@@ -1 +1,30 @@
<article class="article"><a class="back-link" href="/blog"> 블로그 목록</a><header><span class="badge"><?= e($post['category']) ?></span><time datetime="<?= e(date('Y-m-d', strtotime($post['published_at']))) ?>"><?= e(date('Y.m.d', strtotime($post['published_at']))) ?></time><h1><?= e($post['title']) ?></h1><p><?= e($post['author']) ?> 기술팀</p></header><?php if (!empty($post['image_path'])): ?><figure class="article-image"><img src="<?= e($post['image_path']) ?>" alt="<?= e($post['image_alt']) ?>"></figure><?php endif; ?><div class="article-body"><?php foreach (preg_split('/\R{2,}/', trim($post['body'])) ?: [] as $paragraph): ?><p><?= nl2br(e($paragraph)) ?></p><?php endforeach; ?></div><aside class="article-cta"><div><strong>비슷한 시공이 필요하신가요?</strong><p>현장 조건에 맞는 구성을 상담해 드립니다.</p></div><a class="button button-primary" href="/contact">상담 신청</a></aside></article> <?php
$previewMode = !empty($preview);
$displayDate = $post['published_at'] ?: ($post['updated_at'] ?? $post['created_at'] ?? date('Y-m-d H:i:s'));
$galleryImages = $post['gallery_images'] ?? [];
if ($galleryImages === [] && !empty($post['image_path'])) {
$galleryImages[] = ['file_path' => $post['image_path'], 'alt_text' => $post['image_alt'] ?? ''];
}
$inlineImages = [];
foreach (($post['inline_images'] ?? []) as $image) $inlineImages[(int) $image['token']] = $image;
$paragraphs = preg_split('/\R{2,}/', trim($post['body'])) ?: [];
?>
<?php if ($previewMode): ?><aside class="preview-toolbar" role="status"><div><strong><?= $post['status'] === 'published' ? '공개 글 미리보기' : '초안 미리보기' ?></strong><span>저장된 내용을 실제 공개 페이지 형식으로 표시합니다.</span></div><a class="button button-secondary button-small" href="/admin/posts/<?= e($post['id']) ?>/edit">글 수정</a></aside><?php endif; ?>
<article class="article">
<a class="back-link" href="<?= $previewMode ? '/admin/posts' : '/blog' ?>">← <?= $previewMode ? '글 관리' : '블로그 목록' ?></a>
<header><span class="badge"><?= e($post['category']) ?></span><time datetime="<?= e(date('Y-m-d', strtotime($displayDate))) ?>"><?= e(date('Y.m.d', strtotime($displayDate))) ?></time><h1><?= e($post['title']) ?></h1><p><?= e($post['author']) ?> 기술팀</p></header>
<?php if (count($galleryImages) > 1): ?>
<section class="article-gallery" data-article-slider aria-label="대표 이미지 슬라이드">
<div class="article-gallery-slides"><?php foreach ($galleryImages as $index => $image): ?><figure class="article-gallery-slide<?= $index === 0 ? ' is-active' : '' ?>" aria-hidden="<?= $index === 0 ? 'false' : 'true' ?>"><img src="<?= e($image['file_path']) ?>" alt="<?= e($image['alt_text']) ?>"></figure><?php endforeach; ?></div>
<div class="article-gallery-controls"><button type="button" data-article-prev aria-label="이전 이미지"></button><div class="article-gallery-dots"><?php foreach ($galleryImages as $index => $_): ?><button type="button" data-article-dot="<?= e($index) ?>" class="<?= $index === 0 ? 'is-active' : '' ?>"<?= $index === 0 ? ' aria-current="true"' : '' ?> aria-label="<?= e($index + 1) ?>번째 이미지"></button><?php endforeach; ?></div><button type="button" data-article-next aria-label="다음 이미지"></button><button class="article-gallery-pause" type="button" data-article-pause aria-pressed="false">자동재생 일시정지</button></div>
</section>
<?php elseif ($galleryImages): ?><figure class="article-image"><img src="<?= e($galleryImages[0]['file_path']) ?>" alt="<?= e($galleryImages[0]['alt_text']) ?>"></figure><?php endif; ?>
<div class="article-body">
<?php foreach ($paragraphs as $paragraph): ?>
<?php if (preg_match('/^\[\[image:(\d+)\]\]$/', trim($paragraph), $tokenMatch) === 1 && isset($inlineImages[(int) $tokenMatch[1]])): $image = $inlineImages[(int) $tokenMatch[1]]; ?>
<figure class="article-inline-image"><img src="<?= e($image['file_path']) ?>" alt="<?= e($image['alt_text']) ?>"></figure>
<?php else: ?><p><?= nl2br(e($paragraph)) ?></p><?php endif; ?>
<?php endforeach; ?>
</div>
<aside class="article-cta"><div><strong>비슷한 시공이 필요하신가요?</strong><p>현장 조건에 맞는 구성을 상담해 드립니다.</p></div><a class="button button-primary" href="/contact">상담 신청</a></aside>
</article>

View File

@@ -29,6 +29,7 @@
<p><?= e($address) ?></p> <p><?= e($address) ?></p>
<div class="naver-map-frame" data-naver-map data-client-id="<?= e(config('app.naver_maps.client_id')) ?>" data-latitude="<?= e(config('app.naver_maps.latitude')) ?>" data-longitude="<?= e(config('app.naver_maps.longitude')) ?>" data-label="<?= e(config('app.legal_name')) ?>"> <div class="naver-map-frame" data-naver-map data-client-id="<?= e(config('app.naver_maps.client_id')) ?>" data-latitude="<?= e(config('app.naver_maps.latitude')) ?>" data-longitude="<?= e(config('app.naver_maps.longitude')) ?>" data-label="<?= e(config('app.legal_name')) ?>">
<div class="naver-map" aria-label="<?= e(config('app.legal_name')) ?> 위치 지도"></div> <div class="naver-map" aria-label="<?= e(config('app.legal_name')) ?> 위치 지도"></div>
<div class="naver-map-zoom" data-naver-map-zoom hidden><button type="button" aria-label="지도 확대" data-naver-map-zoom-in>+</button><button type="button" aria-label="지도 축소" data-naver-map-zoom-out></button></div>
<p class="naver-map-status" role="status" data-naver-map-status>지도를 불러오는 중입니다.</p> <p class="naver-map-status" role="status" data-naver-map-status>지도를 불러오는 중입니다.</p>
<button class="naver-map-fullscreen" type="button" aria-label="지도를 전체 화면으로 보기" title="전체 화면" data-naver-map-fullscreen hidden><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 3H3v5M16 3h5v5M21 16v5h-5M8 21H3v-5"/></svg></button> <button class="naver-map-fullscreen" type="button" aria-label="지도를 전체 화면으로 보기" title="전체 화면" data-naver-map-fullscreen hidden><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 3H3v5M16 3h5v5M21 16v5h-5M8 21H3v-5"/></svg></button>
</div> </div>

View File

@@ -6,9 +6,10 @@
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="robots" content="noindex,nofollow,noarchive"> <meta name="robots" content="noindex,nofollow,noarchive">
<title><?= e($title ?? '관리자') ?> | <?= e(config('app.legal_name')) ?> 관리자</title> <title><?= e($title ?? '관리자') ?> | <?= e(config('app.legal_name')) ?> 관리자</title>
<link rel="stylesheet" href="<?= e(asset('css/site.css')) ?>"> <link rel="stylesheet" href="<?= e(asset('css/site.css')) ?>?v=20260810-12">
<link rel="stylesheet" href="<?= e(asset('css/content.css')) ?>"> <link rel="stylesheet" href="<?= e(asset('css/content.css')) ?>?v=20260810-13">
<link rel="stylesheet" href="<?= e(asset('css/admin.css')) ?>"> <link rel="stylesheet" href="<?= e(asset('css/admin.css')) ?>?v=20260813-2">
<script src="<?= e(asset('js/admin.js')) ?>?v=20260810-4" defer></script>
</head> </head>
<body class="admin-body"> <body class="admin-body">
<a class="skip-link" href="#admin-main">본문 바로가기</a> <a class="skip-link" href="#admin-main">본문 바로가기</a>

View File

@@ -2,7 +2,8 @@
$pageTitle = isset($title) ? $title . ' | ' . config('app.name') : config('app.name'); $pageTitle = isset($title) ? $title . ' | ' . config('app.name') : config('app.name');
$pageDescription = $description ?? '네트워크, 키폰, CCTV 설계·시공·유지보수 전문 기업'; $pageDescription = $description ?? '네트워크, 키폰, CCTV 설계·시공·유지보수 전문 기업';
$canonicalPath = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/'; $canonicalPath = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
$canonical = rtrim((string) config('app.base_url'), '/') . $canonicalPath; $canonical = isset($canonicalUrl) ? (string) $canonicalUrl : rtrim((string) config('app.base_url'), '/') . $canonicalPath;
$robotsNoindex = config('app.noindex', false) || !empty($noindex);
$legalName = site_setting('legal_name', config('app.legal_name')); $legalName = site_setting('legal_name', config('app.legal_name'));
$phone = site_setting('phone', config('app.contact.phone')); $phone = site_setting('phone', config('app.contact.phone'));
$phoneSub = site_setting('phone_sub', config('app.contact.phone_sub')); $phoneSub = site_setting('phone_sub', config('app.contact.phone_sub'));
@@ -10,6 +11,10 @@ $fax = site_setting('fax', config('app.contact.fax'));
$email = site_setting('email', config('app.contact.email')); $email = site_setting('email', config('app.contact.email'));
$address = site_setting('address', config('app.contact.address')); $address = site_setting('address', config('app.contact.address'));
$bizno = site_setting('bizno', '206-86-70582'); $bizno = site_setting('bizno', '206-86-70582');
$analyticsMeasurementId = (string) config('app.analytics.measurement_id', '');
$analyticsHost = strtolower((string) preg_replace('/:\d+$/', '', $_SERVER['HTTP_HOST'] ?? ''));
$analyticsEnabled = config('app.base_path') === '' && in_array($analyticsHost, ['gtsit.co.kr', 'www.gtsit.co.kr'], true);
$analyticsConsentKey = 'gtsit_analytics_consent_v' . config('app.analytics.consent_version', '1') . ($analyticsEnabled ? '' : '_preview');
?> ?>
<!doctype html> <!doctype html>
<html lang="ko"> <html lang="ko">
@@ -18,7 +23,7 @@ $bizno = site_setting('bizno', '206-86-70582');
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= e($pageTitle) ?></title> <title><?= e($pageTitle) ?></title>
<meta name="description" content="<?= e($pageDescription) ?>"> <meta name="description" content="<?= e($pageDescription) ?>">
<?php if (config('app.noindex', false)): ?><meta name="robots" content="noindex,nofollow,noarchive"><?php endif; ?> <?php if ($robotsNoindex): ?><meta name="robots" content="noindex,nofollow,noarchive"><?php endif; ?>
<link rel="canonical" href="<?= e($canonical) ?>"> <link rel="canonical" href="<?= e($canonical) ?>">
<meta property="og:type" content="website"> <meta property="og:type" content="website">
<meta property="og:locale" content="ko_KR"> <meta property="og:locale" content="ko_KR">
@@ -26,11 +31,11 @@ $bizno = site_setting('bizno', '206-86-70582');
<meta property="og:title" content="<?= e($pageTitle) ?>"> <meta property="og:title" content="<?= e($pageTitle) ?>">
<meta property="og:description" content="<?= e($pageDescription) ?>"> <meta property="og:description" content="<?= e($pageDescription) ?>">
<meta property="og:url" content="<?= e($canonical) ?>"> <meta property="og:url" content="<?= e($canonical) ?>">
<link rel="stylesheet" href="<?= e(asset('css/site.css')) ?>?v=20260805-11"> <link rel="stylesheet" href="<?= e(asset('css/site.css')) ?>?v=20260812-01">
<link rel="stylesheet" href="<?= e(asset('css/content.css')) ?>?v=20260805-9"> <link rel="stylesheet" href="<?= e(asset('css/content.css')) ?>?v=20260810-13">
<script src="<?= e(asset('js/site.js')) ?>?v=20260805-9" defer></script> <script src="<?= e(asset('js/site.js')) ?>?v=20260812-01" defer></script>
</head> </head>
<body> <body data-analytics-consent data-analytics-enabled="<?= $analyticsEnabled ? 'true' : 'false' ?>" data-analytics-measurement-id="<?= e($analyticsMeasurementId) ?>" data-analytics-consent-key="<?= e($analyticsConsentKey) ?>">
<a class="skip-link" href="#main-content">본문 바로가기</a> <a class="skip-link" href="#main-content">본문 바로가기</a>
<header class="site-header"> <header class="site-header">
<div class="header-inner"> <div class="header-inner">
@@ -69,8 +74,18 @@ $bizno = site_setting('bizno', '206-86-70582');
<div><h2>연락처</h2><p>대표번호 <?= e($phone) ?><br>경기지점 <?= e($phoneSub) ?><br>FAX <?= e($fax) ?><br>이메일 <?= e($email) ?></p></div> <div><h2>연락처</h2><p>대표번호 <?= e($phone) ?><br>경기지점 <?= e($phoneSub) ?><br>FAX <?= e($fax) ?><br>이메일 <?= e($email) ?></p></div>
<div><h2>사업자 정보</h2><p><?= e($legalName) ?><br>사업자등록번호 <?= e($bizno) ?><br><?= e($address) ?></p></div> <div><h2>사업자 정보</h2><p><?= e($legalName) ?><br>사업자등록번호 <?= e($bizno) ?><br><?= e($address) ?></p></div>
</div> </div>
<div class="container footer-bottom">© 2026 <?= e($legalName) ?>. All rights reserved.</div> <div class="container footer-bottom"><span>© 2026 <?= e($legalName) ?>. All rights reserved.</span><button type="button" data-cookie-settings>통계 쿠키 설정</button></div>
</footer> </footer>
<aside class="cookie-consent" data-cookie-consent hidden aria-labelledby="cookie-consent-title" aria-describedby="cookie-consent-description">
<div>
<strong id="cookie-consent-title">방문 통계 쿠키 안내</strong>
<p id="cookie-consent-description">사이트 개선을 위해 Google Analytics 통계 쿠키와 이에 따른 국외 이전을 사용합니다. 거부해도 모든 기능을 이용할 수 있습니다. <a href="/privacy#policy-cookie">자세히 보기</a></p>
</div>
<div class="cookie-consent-actions">
<button type="button" class="button button-secondary" data-cookie-decline>거부</button>
<button type="button" class="button button-primary" data-cookie-accept>허용</button>
</div>
</aside>
<a class="floating-call" href="tel:<?= e($phone) ?>" aria-label="전화상담 <?= e($phone) ?>"> <a class="floating-call" href="tel:<?= e($phone) ?>" aria-label="전화상담 <?= e($phone) ?>">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.96.36 1.9.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.91.34 1.85.57 2.81.7A2 2 0 0 1 22 16.92z"/></svg> <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.96.36 1.9.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.91.34 1.85.57 2.81.7A2 2 0 0 1 22 16.92z"/></svg>
<span>전화상담</span> <span>전화상담</span>

View File

@@ -15,6 +15,7 @@ $email = site_setting('email', config('app.contact.email'));
<li><a href="#policy-purpose">처리 목적·항목·보유기간</a></li> <li><a href="#policy-purpose">처리 목적·항목·보유기간</a></li>
<li><a href="#policy-third-party">제3자 제공</a></li> <li><a href="#policy-third-party">제3자 제공</a></li>
<li><a href="#policy-outsourcing">처리 위탁</a></li> <li><a href="#policy-outsourcing">처리 위탁</a></li>
<li><a href="#policy-overseas">국외 이전</a></li>
<li><a href="#policy-destruction">파기</a></li> <li><a href="#policy-destruction">파기</a></li>
<li><a href="#policy-rights">정보주체의 권리</a></li> <li><a href="#policy-rights">정보주체의 권리</a></li>
<li><a href="#policy-security">안전성 확보 조치</a></li> <li><a href="#policy-security">안전성 확보 조치</a></li>
@@ -29,7 +30,10 @@ $email = site_setting('email', config('app.contact.email'));
<div class="policy-table-wrap"> <div class="policy-table-wrap">
<table> <table>
<thead><tr><th scope="col">구분</th><th scope="col">처리 목적</th><th scope="col">처리 항목</th><th scope="col">법적 근거</th><th scope="col">보유기간</th></tr></thead> <thead><tr><th scope="col">구분</th><th scope="col">처리 목적</th><th scope="col">처리 항목</th><th scope="col">법적 근거</th><th scope="col">보유기간</th></tr></thead>
<tbody><tr><th scope="row">온라인 상담 문의</th><td>상담 접수, 문의자 확인, 답변 제공 및 처리 이력 관리</td><td><strong>필수</strong>: 이름 또는 회사명, 연락처, 문의 유형, 제목, 내용, 조회 PIN의 일방향 암호화값<br><strong>선택</strong>: 이메일<br><strong>자동 생성</strong>: 접수번호, 접수·동의·답변·처리 시각</td><td>정보주체의 동의<br>「개인정보 보호법」 제15조 제1항 제1호</td><td>문의 처리 종료 후 1년</td></tr></tbody> <tbody>
<tr><th scope="row">온라인 상담 문의</th><td>상담 접수, 문의자 확인, 답변 제공 및 처리 이력 관리</td><td><strong>필수</strong>: 이름 또는 회사명, 연락처, 문의 유형, 제목, 내용, 조회 PIN의 일방향 암호화값<br><strong>선택</strong>: 이메일<br><strong>자동 생성</strong>: 접수번호, 접수·동의·답변·처리 시각</td><td>정보주체의 동의<br>「개인정보 보호법」 제15조 제1항 제1호</td><td>문의 처리 종료 후 1년</td></tr>
<tr><th scope="row">웹사이트 이용 통계</th><td>방문 현황, 이용 경로, 콘텐츠 성과 분석 및 사이트 개선</td><td>쿠키 식별자, 방문·세션 정보, 페이지 URL·제목, 유입 경로, 브라우저·기기 정보, 대략적인 지역, 상호작용 시각</td><td>통계 쿠키와 국외 이전에 대한 정보주체의 선택 동의</td><td>Google Analytics 사용자·이벤트 데이터 2개월<br>집계 보고서는 서비스 정책에 따른 기간</td></tr>
</tbody>
</table> </table>
</div> </div>
<p>관계 법령에 따라 개인정보를 별도로 보존해야 하는 경우에는 해당 법령에서 정한 기간 동안 분리하여 보관합니다.</p> <p>관계 법령에 따라 개인정보를 별도로 보존해야 하는 경우에는 해당 법령에서 정한 기간 동안 분리하여 보관합니다.</p>
@@ -43,43 +47,57 @@ $email = site_setting('email', config('app.contact.email'));
<section id="policy-outsourcing"> <section id="policy-outsourcing">
<h2>3. 개인정보 처리업무의 위탁</h2> <h2>3. 개인정보 처리업무의 위탁</h2>
<p>회사는 원활한 사이트 운영을 위해 다음 업무를 위탁하고 있으며, 위탁계약 등을 통해 개인정보가 안전하게 관리되도록 필요한 사항을 규정하고 있습니다.</p> <p>회사는 원활한 사이트 운영을 위해 다음 업무를 위탁하고 있으며, 위탁계약 등을 통해 개인정보가 안전하게 관리되도록 필요한 사항을 규정하고 있습니다.</p>
<div class="policy-table-wrap"><table><thead><tr><th scope="col">수탁업체</th><th scope="col">위탁업무</th></tr></thead><tbody><tr><td>카페24(주)</td><td>웹·데이터베이스 호스팅, 데이터 저장 및 백업을 위한 인프라 제공</td></tr></tbody></table></div> <div class="policy-table-wrap"><table><thead><tr><th scope="col">수탁업체</th><th scope="col">위탁업무</th></tr></thead><tbody><tr><td>카페24(주)</td><td>웹·데이터베이스 호스팅, 데이터 저장 및 백업을 위한 인프라 제공</td></tr><tr><td>Google LLC</td><td>동의한 이용자의 Google Analytics 방문 통계 처리</td></tr></tbody></table></div>
<p>수탁업체 또는 위탁업무가 변경되면 본 처리방침을 통해 공개합니다.</p> <p>수탁업체 또는 위탁업무가 변경되면 본 처리방침을 통해 공개합니다.</p>
</section> </section>
<section id="policy-overseas">
<h2>4. 개인정보의 국외 이전</h2>
<p>회사는 이용자가 통계 쿠키를 허용한 경우에만 다음과 같이 이용 정보를 국외로 이전합니다. 동의하지 않거나 이후 동의를 철회해도 웹사이트의 일반 기능 이용에는 제한이 없습니다.</p>
<div class="policy-table-wrap">
<table>
<thead><tr><th scope="col">이전받는 자</th><th scope="col">이전 국가</th><th scope="col">이전 항목·목적</th><th scope="col">이전 시기·방법</th><th scope="col">보유·이용기간</th></tr></thead>
<tbody><tr><td>Google LLC<br><a href="https://support.google.com/policies/troubleshooter/7575787" target="_blank" rel="noopener">문의처</a></td><td>미국 및 Google 데이터센터가 위치한 국가</td><td>쿠키 식별자, 방문·세션 정보, 페이지 URL·제목, 유입 경로, 브라우저·기기 정보, 대략적인 지역, 상호작용 시각<br>방문 통계 분석 및 사이트 개선</td><td>동의 후 사이트 이용 시 암호화된 네트워크를 통해 이전</td><td>사용자·이벤트 데이터 2개월. 관계 법령이나 Google 서비스 정책에 따른 예외가 있는 경우 해당 기간</td></tr></tbody>
</table>
</div>
<p>이용자는 최초 안내에서 “거부”를 선택하거나, 페이지 하단의 “통계 쿠키 설정”에서 동의를 철회할 수 있습니다.</p>
</section>
<section id="policy-destruction"> <section id="policy-destruction">
<h2>4. 개인정보의 파기 절차 및 방법</h2> <h2>5. 개인정보의 파기 절차 및 방법</h2>
<p>회사는 보유기간이 지나거나 처리 목적을 달성해 개인정보가 불필요하게 된 경우 지체 없이 파기합니다. 온라인 문의는 관리자가 처리 상태를 “종료”로 변경한 시점부터 1년간 보관한 뒤 데이터베이스에서 복구할 수 없도록 삭제합니다. 관계 법령에 따라 보존해야 하는 정보는 다른 개인정보와 분리하여 보관한 후 기간이 끝나면 파기합니다.</p> <p>회사는 보유기간이 지나거나 처리 목적을 달성해 개인정보가 불필요하게 된 경우 지체 없이 파기합니다. 온라인 문의는 관리자가 처리 상태를 “종료”로 변경한 시점부터 1년간 보관한 뒤 데이터베이스에서 복구할 수 없도록 삭제합니다. 관계 법령에 따라 보존해야 하는 정보는 다른 개인정보와 분리하여 보관한 후 기간이 끝나면 파기합니다.</p>
<ul><li>전자적 파일: 복구 또는 재생할 수 없는 방법으로 영구 삭제</li><li>종이 문서가 발생한 경우: 분쇄 또는 소각</li></ul> <ul><li>전자적 파일: 복구 또는 재생할 수 없는 방법으로 영구 삭제</li><li>종이 문서가 발생한 경우: 분쇄 또는 소각</li></ul>
</section> </section>
<section id="policy-rights"> <section id="policy-rights">
<h2>5. 정보주체와 법정대리인의 권리·의무 및 행사방법</h2> <h2>6. 정보주체와 법정대리인의 권리·의무 및 행사방법</h2>
<p>정보주체는 회사에 개인정보 열람, 정정·삭제, 처리정지 및 동의 철회를 요구할 수 있습니다. 아래 담당부서에 전화 또는 이메일로 요청하면 본인 확인 후 관련 법령에서 정한 절차에 따라 처리합니다. 법정대리인이나 위임받은 사람을 통해서도 권리를 행사할 수 있으며, 이 경우 위임장 등 정당한 대리권을 확인할 수 있는 자료를 요청할 수 있습니다.</p> <p>정보주체는 회사에 개인정보 열람, 정정·삭제, 처리정지 및 동의 철회를 요구할 수 있습니다. 아래 담당부서에 전화 또는 이메일로 요청하면 본인 확인 후 관련 법령에서 정한 절차에 따라 처리합니다. 법정대리인이나 위임받은 사람을 통해서도 권리를 행사할 수 있으며, 이 경우 위임장 등 정당한 대리권을 확인할 수 있는 자료를 요청할 수 있습니다.</p>
</section> </section>
<section id="policy-security"> <section id="policy-security">
<h2>6. 개인정보의 안전성 확보 조치</h2> <h2>7. 개인정보의 안전성 확보 조치</h2>
<p>회사는 개인정보의 분실·도난·유출·위조·변조 또는 훼손을 방지하기 위해 다음 조치를 시행합니다.</p> <p>회사는 개인정보의 분실·도난·유출·위조·변조 또는 훼손을 방지하기 위해 다음 조치를 시행합니다.</p>
<ul><li>개인정보 취급자 및 관리자 접근 권한 제한</li><li>조회 PIN과 관리자 비밀번호의 일방향 암호화 저장</li><li>HTTPS 암호화 통신, 접근 통제 및 조회 시도 제한</li><li>보안 프로그램과 서버 소프트웨어의 점검 및 갱신</li><li>개인정보 처리시스템 접속기록의 보호와 정기 점검</li></ul> <ul><li>개인정보 취급자 및 관리자 접근 권한 제한</li><li>조회 PIN과 관리자 비밀번호의 일방향 암호화 저장</li><li>HTTPS 암호화 통신, 접근 통제 및 조회 시도 제한</li><li>보안 프로그램과 서버 소프트웨어의 점검 및 갱신</li><li>개인정보 처리시스템 접속기록의 보호와 정기 점검</li></ul>
</section> </section>
<section id="policy-cookie"> <section id="policy-cookie">
<h2>7. 자동으로 수집하는 장치의 설치·운영 및 거부</h2> <h2>8. 자동으로 수집하는 장치의 설치·운영 및 거부</h2>
<p>회사는 로그인 상태 유지, 위조 요청 방지 및 문의 중복 접수 방지를 위해 필수 세션 쿠키를 사용합니다. 이 쿠키에는 임의의 세션 식별자만 저장되며 브라우저를 닫으면 만료됩니다. 브라우저 설정에서 쿠키를 차단할 수 있으나, 차단하면 문의 접수와 관리자 기능을 이용하기 어려울 수 있습니다.</p> <p>회사는 로그인 상태 유지, 위조 요청 방지 및 문의 중복 접수 방지를 위해 필수 세션 쿠키를 사용합니다. 이 쿠키에는 임의의 세션 식별자만 저장되며 브라우저를 닫으면 만료됩니다. 브라우저 설정에서 쿠키를 차단할 수 있으나, 차단하면 문의 접수와 관리자 기능을 이용하기 어려울 수 있습니다.</p>
<p>이용자가 통계 쿠키를 허용한 경우 Google Analytics가 이용자를 구분하기 위한 자사 쿠키 <code>_ga</code> 등을 저장합니다. 회사는 광고 개인 최적화와 Google 신호 기능을 사용하지 않으며, 문의 양식에 입력한 이름·연락처·이메일·내용·접수번호를 Analytics로 전송하지 않습니다.</p>
<p>통계 쿠키는 선택 사항입니다. 최초 안내에서 허용 또는 거부할 수 있고, 언제든 페이지 하단의 “통계 쿠키 설정”을 눌러 선택을 변경할 수 있습니다. 거부하거나 철회하면 통계 스크립트를 불러오지 않거나 Analytics 저장 권한을 철회하고 회사 도메인의 관련 쿠키 삭제를 시도합니다.</p>
<p>오시는 길의 네이버 지도를 불러올 때 이용자의 브라우저가 네이버 지도 서비스에 직접 연결되며, 해당 서비스에서 접속 정보 등이 처리될 수 있습니다. 자세한 내용은 <a href="https://www.navercorp.com/policy/privacy" target="_blank" rel="noopener">네이버 개인정보처리방침</a>에서 확인할 수 있습니다.</p> <p>오시는 길의 네이버 지도를 불러올 때 이용자의 브라우저가 네이버 지도 서비스에 직접 연결되며, 해당 서비스에서 접속 정보 등이 처리될 수 있습니다. 자세한 내용은 <a href="https://www.navercorp.com/policy/privacy" target="_blank" rel="noopener">네이버 개인정보처리방침</a>에서 확인할 수 있습니다.</p>
</section> </section>
<section id="policy-contact"> <section id="policy-contact">
<h2>8. 개인정보 보호 담당부서 및 권익침해 구제</h2> <h2>9. 개인정보 보호 담당부서 및 권익침해 구제</h2>
<div class="policy-contact"><h3>개인정보 보호 담당부서</h3><dl><div><dt>담당</dt><dd><?= e($legalName) ?> 개인정보 보호 담당부서</dd></div><div><dt>전화</dt><dd><a href="tel:<?= e($phone) ?>"><?= e($phone) ?></a></dd></div><div><dt>이메일</dt><dd><a href="mailto:<?= e($email) ?>"><?= e($email) ?></a></dd></div></dl></div> <div class="policy-contact"><h3>개인정보 보호 담당부서</h3><dl><div><dt>담당</dt><dd><?= e($legalName) ?> 개인정보 보호 담당부서</dd></div><div><dt>전화</dt><dd><a href="tel:<?= e($phone) ?>"><?= e($phone) ?></a></dd></div><div><dt>이메일</dt><dd><a href="mailto:<?= e($email) ?>"><?= e($email) ?></a></dd></div></dl></div>
<p>개인정보 침해에 관한 상담이나 피해 구제가 필요한 경우 다음 기관에 문의할 수 있습니다.</p> <p>개인정보 침해에 관한 상담이나 피해 구제가 필요한 경우 다음 기관에 문의할 수 있습니다.</p>
<ul><li>개인정보침해신고센터: 국번 없이 118 · <a href="https://privacy.kisa.or.kr" target="_blank" rel="noopener">privacy.kisa.or.kr</a></li><li>개인정보분쟁조정위원회: 1833-6972 · <a href="https://www.kopico.go.kr" target="_blank" rel="noopener">www.kopico.go.kr</a></li><li>대검찰청: 국번 없이 1301 · <a href="https://www.spo.go.kr" target="_blank" rel="noopener">www.spo.go.kr</a></li><li>경찰청: 국번 없이 182 · <a href="https://ecrm.police.go.kr" target="_blank" rel="noopener">ecrm.police.go.kr</a></li></ul> <ul><li>개인정보침해신고센터: 국번 없이 118 · <a href="https://privacy.kisa.or.kr" target="_blank" rel="noopener">privacy.kisa.or.kr</a></li><li>개인정보분쟁조정위원회: 1833-6972 · <a href="https://www.kopico.go.kr" target="_blank" rel="noopener">www.kopico.go.kr</a></li><li>대검찰청: 국번 없이 1301 · <a href="https://www.spo.go.kr" target="_blank" rel="noopener">www.spo.go.kr</a></li><li>경찰청: 국번 없이 182 · <a href="https://ecrm.police.go.kr" target="_blank" rel="noopener">ecrm.police.go.kr</a></li></ul>
</section> </section>
<section> <section>
<h2>9. 개인정보처리방침의 변경</h2> <h2>10. 개인정보처리방침의 변경</h2>
<p>이 개인정보처리방침은 2026년 8월 5일부터 적용됩니다. 내용이 변경되는 경우 시행 전에 사이트를 통해 안내합니다.</p> <p>이 개인정보처리방침은 2026년 8월 12일부터 적용됩니다. 내용이 변경되는 경우 시행 전에 사이트를 통해 안내합니다.</p>
<p class="policy-effective">공고일자: 2026년 8월 5일<br>시행일자: 2026년 8월 5일</p> <p class="policy-effective">공고일자: 2026년 8월 12일<br>시행일자: 2026년 8월 12일</p>
</section> </section>
</article> </article>

View File

@@ -18,6 +18,15 @@ return [
'latitude' => 37.48524, 'latitude' => 37.48524,
'longitude' => 127.11478, 'longitude' => 127.11478,
], ],
'analytics' => [
'measurement_id' => (string) (getenv('GA4_MEASUREMENT_ID') ?: 'G-92XBR314YD'),
'property_id' => (string) (getenv('GA4_PROPERTY_ID') ?: '549573245'),
'stream_id' => (string) (getenv('GA4_STREAM_ID') ?: '15422491033'),
'consent_version' => '1',
'credentials_path' => (string) (getenv('GA4_CREDENTIALS_PATH') ?: dirname(__DIR__) . '/.secrets/ga4-service-account.json'),
'cache_path' => (string) (getenv('GA4_CACHE_PATH') ?: dirname(__DIR__) . '/storage/cache/ga4-dashboard.json'),
'cache_ttl' => 900,
],
'contact' => [ 'contact' => [
'phone' => '1833-4917', 'phone' => '1833-4917',
'phone_sub' => '1833-4918', 'phone_sub' => '1833-4918',

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
return [
'office-network-move-checklist' => 'post-1',
'lg-vs-samsung-keyphone-guide' => 'post-2',
'warehouse-network-keyphone-case' => 'post-3',
'wired-vs-wireless-office-guide' => 'post-4',
'cctv-resolution-storage-guide' => 'post-5',
'network-switch-troubleshooting-case' => 'post-6',
'hybrid-office-wifi-design' => 'post-7',
'keyphone-hybrid-replacement-guide' => 'post-8',
'multi-store-cctv-monitoring-case' => 'post-9',
'office-lan-structure-cabling-guide' => 'post-10',
'outdoor-wireless-link-installation-guide' => 'post-11',
'keyphone-system-capacity-selection' => 'post-12',
'cctv-recorder-essential-features' => 'post-13',
'network-rack-cabling-maintenance-case' => 'post-14',
'media-gallery-inline-preview' => 'post-15',
];

View File

@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
require dirname(__DIR__) . '/app/bootstrap.php';
$db = \App\Database::connection();
$slug = 'media-gallery-inline-preview';
$db->beginTransaction();
try {
$find = $db->prepare('SELECT id FROM posts WHERE slug = :slug LIMIT 1');
$find->execute(['slug' => $slug]);
$postId = (int) ($find->fetchColumn() ?: 0);
$body = implode("\n\n", [
'여러 장의 대표 이미지가 글 상단에서 자동으로 전환되는지 확인하기 위한 테스트 초안입니다.',
'[[image:1]]',
'본문 문단 사이에 현장 이미지를 배치할 수 있습니다. 이미지 토큰은 관리자 글쓰기 화면의 삽입 버튼으로 추가합니다.',
'[[image:2]]',
'이 글은 운영 반영 전 기능과 화면 구성을 확인하기 위한 전용 초안이며 공개 목록에는 노출되지 않습니다.',
]);
$values = [
'title' => '[테스트] 대표 이미지 슬라이드와 본문 이미지',
'slug' => $slug,
'category' => '시공사례',
'excerpt' => '여러 대표 이미지와 본문 중간 이미지 배치를 확인하는 테스트 전용 초안입니다.',
'body' => $body,
'author' => '(주)지티에스정보통신',
];
if ($postId === 0) {
$insertPost = $db->prepare(
"INSERT INTO posts (title, slug, category, excerpt, body, author, status, published_at)
VALUES (:title, :slug, :category, :excerpt, :body, :author, 'draft', NULL)"
);
$insertPost->execute($values);
$postId = (int) $db->lastInsertId();
} else {
$updatePost = $db->prepare(
"UPDATE posts SET title=:title, category=:category, excerpt=:excerpt, body=:body,
author=:author, status='draft', published_at=NULL, deleted_at=NULL WHERE id=:id"
);
$updatePost->execute([...$values, 'id' => $postId]);
$deleteImages = $db->prepare('DELETE FROM post_images WHERE post_id = :post_id');
$deleteImages->execute(['post_id' => $postId]);
}
$images = [
['/uploads/media-preview/slide1.jpg', '현장 통신 장비 설치 이미지', '@gtsit:gallery', 0],
['/uploads/media-preview/slide3.jpg', '건물 통신 인프라 시공 이미지', '@gtsit:gallery', 1],
['/uploads/media-preview/slide4.jpg', '사무실 네트워크 구축 이미지', '@gtsit:gallery', 2],
['/uploads/media-preview/case1.jpg', '본문에 배치한 현장 시공 이미지 첫 번째', '@gtsit:inline:1', 1001],
['/uploads/media-preview/case2.jpg', '본문에 배치한 현장 시공 이미지 두 번째', '@gtsit:inline:2', 1002],
];
$insertImage = $db->prepare(
'INSERT INTO post_images (post_id, file_path, alt_text, caption, sort_order)
VALUES (:post_id, :file_path, :alt_text, :caption, :sort_order)'
);
foreach ($images as [$filePath, $altText, $caption, $sortOrder]) {
$insertImage->execute([
'post_id' => $postId,
'file_path' => $filePath,
'alt_text' => $altText,
'caption' => $caption,
'sort_order' => $sortOrder,
]);
}
$db->commit();
echo $postId;
} catch (Throwable $exception) {
if ($db->inTransaction()) $db->rollBack();
throw $exception;
}

View File

@@ -1,6 +1,6 @@
# GTSIT 사이트 설계안 # GTSIT 사이트 설계안
최종 수정일: 2026-08-10 최종 수정일: 2026-08-18
## 1. 구축 목표 ## 1. 구축 목표
@@ -132,9 +132,23 @@ public/
- 개인정보 파기: 문의가 `closed`로 전환된 시각을 기록하고 1년이 지난 문의를 `bin/purge-inquiries.php`로 데이터베이스에서 완전 삭제 - 개인정보 파기: 문의가 `closed`로 전환된 시각을 기록하고 1년이 지난 문의를 `bin/purge-inquiries.php`로 데이터베이스에서 완전 삭제
- 파기 실행 환경: 현재 뉴아우토반 공유 웹호스팅은 cron을 지원하지 않아 정리 명령을 수동 실행할 수 있도록 배포했으며, 외부 스케줄러 또는 지원 상품 전환 시 정기 실행 등록 필요 - 파기 실행 환경: 현재 뉴아우토반 공유 웹호스팅은 cron을 지원하지 않아 정리 명령을 수동 실행할 수 있도록 배포했으며, 외부 스케줄러 또는 지원 상품 전환 시 정기 실행 등록 필요
- 관리자: 서버 세션, 비밀번호 해시, 계정별 잠금, 세션 단위 시도 제한, 모든 하위 경로 인증 선행 - 관리자: 서버 세션, 비밀번호 해시, 계정별 잠금, 세션 단위 시도 제한, 모든 하위 경로 인증 선행
- 방문 통계: 무료 GA4 표준 속성으로 운영 공개 페이지만 측정하고 관리자·테스트 경로 제외, Analytics Data API는 서버의 읽기 전용 서비스 계정과 15분 캐시를 통해 관리자 대시보드에 집계값만 제공
- 통계 개인정보 경계: 문의 이름·연락처·이메일·본문과 관리자 식별정보는 GA4 이벤트에 포함하지 않고, 통계 쿠키 동의 전에는 Analytics 저장을 비활성화
- GA4 식별자: 계정 `(주)지티에스정보통신`, 속성 ID `549573245`, 운영 웹 스트림 ID `15422491033`, 측정 ID `G-92XBR314YD`
- 통계 동의 흐름: 최초 선택 전 Google 태그 미로딩, 허용 시 운영 호스트에서만 측정, 거부·철회 시 Analytics 저장 거부와 회사 도메인 `_ga` 쿠키 삭제 시도, 광고 저장·Google 신호·광고 개인화는 항상 비활성화
- 관리자 통계: Google Cloud 프로젝트 `gtsit-ga4-dashboard-20260812`의 전용 서비스 계정에 GA4 속성 `뷰어` 권한만 부여하고, 서버에서 Analytics Data API를 조회해 15분간 비공개 파일 캐시
- GA4 키 보관: JSON 개인키는 Git과 웹 루트 밖의 앱 `.secrets/`에 권한 `600`으로 보관하며 브라우저 HTML·JavaScript·로그에 포함하지 않음
- 통계 장애 대응: API 오류 시 마지막 정상 캐시를 표시하고, 캐시도 없을 때는 관리자 화면에 일반 안내만 표시해 자격 증명·API 오류 세부 정보 비노출
- 전체 방문자 기준: GA4 측정 시작일 `2026-08-12`부터 오늘까지의 `totalUsers`를 표시하며 이전 방문 기록은 포함하지 않음
- 초기 관리자: 24시간 유효한 토큰을 세션으로 교환한 뒤 비밀번호를 직접 설정하며, 생성 즉시 토큰 파일 삭제 - 초기 관리자: 24시간 유효한 토큰을 세션으로 교환한 뒤 비밀번호를 직접 설정하며, 생성 즉시 토큰 파일 삭제
- 게시글: slug, 분류, 초안·공개 상태, 소프트 삭제 기반 - 게시글: 신규·기존 slug는 DB 게시글 번호 기반 `post-{id}` 자동 생성, 종전 slug는 정적 별칭을 통한 301 이동, 분류, 초안·공개 상태, 소프트 삭제 기반
- 대표 이미지: JPG·PNG·WebP, 8MB, 6000px 제한, MIME 재검증, 임의 파일명, 대체 텍스트 적용 - 대표 이미지: JPG·PNG·WebP, 8MB, 6000px 제한, MIME 재검증, 임의 파일명, 대체 텍스트 적용
- 게시글 미디어 실험: 테스트 경로에서 대표 이미지 최대 6장 슬라이드와 본문 이미지 최대 10장을 지원하고, 본문은 독립 줄의 `[[image:번호]]` 토큰 위치에 안전하게 렌더링
- 관리자 본문 편집: 범용 HTML 에디터 대신 textarea 문단과 이미지 카드만 제공하는 전용 블록 UI 사용, 위·아래 이동과 삭제 후 기존 텍스트·토큰 형식으로 직렬화
- 작성 화면 미리보기: 저장 전 폼 상태와 선택 파일의 브라우저 임시 URL을 모달에 안전한 DOM API로 렌더링하며, 미리보기 조작은 서버 요청이나 DB 저장을 발생시키지 않음
- 본문 보안 경계: 임의 HTML·`contenteditable`·`innerHTML`을 사용하지 않고 텍스트는 기존 출력 이스케이프, 이미지는 서버 검증 파일과 DB 연결 정보만 렌더링
- 운영 호환성: 공유 DB 스키마 변경 없이 `post_images.caption``@gtsit:gallery`, `@gtsit:inline:번호` 내부 표식과 정렬순서를 사용하며, 운영 코드가 항상 첫 대표 이미지를 선택하도록 유지
- 실험 공개 제한: 다중 대표 이미지 또는 본문 이미지가 포함된 게시글은 운영 반영 승인 전까지 초안으로만 저장
- 이미지 없는 게시글 카드: 글을 숨기지 않고 `GTS` 로고와 카테고리 색상을 사용한 16:9 브랜드 플레이스홀더 표시, 대표 이미지 등록 시 실제 이미지 우선 - 이미지 없는 게시글 카드: 글을 숨기지 않고 `GTS` 로고와 카테고리 색상을 사용한 16:9 브랜드 플레이스홀더 표시, 대표 이미지 등록 시 실제 이미지 우선
- 회사 정보: 관리자 화면에서 연락처·주소·사업자 정보를 DB 설정값으로 관리 - 회사 정보: 관리자 화면에서 연락처·주소·사업자 정보를 DB 설정값으로 관리
- 오시는 길: NAVER Cloud Dynamic Map으로 주소 기반 지도와 회사 마커 표시, API 오류 시 네이버 지도 검색 링크 제공 - 오시는 길: NAVER Cloud Dynamic Map으로 주소 기반 지도와 회사 마커 표시, API 오류 시 네이버 지도 검색 링크 제공

View File

@@ -1,6 +1,6 @@
# 구현 TODO # 구현 TODO
최종 수정일: 2026-08-10 최종 수정일: 2026-08-18
완료한 작업은 체크하고, 설정값이나 URL이 바뀌면 관련 문서도 함께 갱신합니다. 완료한 작업은 체크하고, 설정값이나 URL이 바뀌면 관련 문서도 함께 갱신합니다.
@@ -106,6 +106,9 @@
- [x] [사용자] Maps Client ID 전달·등록, Client Secret은 전달하지 않음 - [x] [사용자] Maps Client ID 전달·등록, Client Secret은 전달하지 않음
- [x] [Codex] 오시는 길 지도·회사 마커·네이버 지도 열기 fallback 구현 - [x] [Codex] 오시는 길 지도·회사 마커·네이버 지도 열기 fallback 구현
- [x] [Codex] 지도 SDK 요소 높이 안정화 및 사용자 정의 전체화면 컨트롤 적용 - [x] [Codex] 지도 SDK 요소 높이 안정화 및 사용자 정의 전체화면 컨트롤 적용
- [x] [Codex] 지도 내부 컨트롤보다 전화상담 플로팅 버튼이 위에 표시되도록 스태킹 순서 보정
- [x] [Codex] 지도 드래그·키보드 이동 차단 및 마커 중심 확대·축소 고정
- [x] [Codex] 핀치·휠·더블클릭/탭 줌 차단 및 주소 중심 확대·축소 컨트롤만 허용
- [x] [사용자] 기존 사이트 백업의 실제 시공 이미지 원본 전달 - [x] [사용자] 기존 사이트 백업의 실제 시공 이미지 원본 전달
- [ ] [사용자] 최신 시공 이미지 추가 전달 - [ ] [사용자] 최신 시공 이미지 추가 전달
- [x] [Codex] 관리자 인증·세션·접근 통제 방식 확정 - [x] [Codex] 관리자 인증·세션·접근 통제 방식 확정
@@ -147,10 +150,36 @@
- [x] [Codex] 클라이언트 코드의 기존 관리자 인증값 제거 - [x] [Codex] 클라이언트 코드의 기존 관리자 인증값 제거
- [x] [Codex] 대시보드 구현 - [x] [Codex] 대시보드 구현
- [x] [Codex] 게시글 목록, 작성, 수정, 소프트 삭제 구현 - [x] [Codex] 게시글 목록, 작성, 수정, 소프트 삭제 구현
- [x] [Codex] 신규·기존 게시글 URL을 `post-글번호` 형식으로 자동 생성하고 이전 주소 301 연결
- [x] [Codex] 관리자 글 목록에서 초안·공개 글의 인증된 실제 화면 미리보기 구현
- [x] [Codex] 이미지 업로드 검증 구현 - [x] [Codex] 이미지 업로드 검증 구현
- [x] [Codex] 테스트 경로에 대표 이미지 최대 6장 슬라이드와 본문 이미지 최대 10장 배치 기능 구현
- [x] [Codex] 다중·본문 이미지가 있는 글의 테스트 기간 공개 차단과 전용 초안 미리보기 구성
- [x] [Codex] 관리자 본문 입력을 텍스트 문단·이미지 전용 블록 편집 UI로 개선
- [x] [Codex] 블록 이동·삭제·대체 텍스트와 안전한 내부 토큰 직렬화 구현
- [x] [Codex] 글쓰기 화면에서 저장 전 제목·본문·대표 이미지·본문 이미지 즉시 미리보기 구현
- [x] [Codex] 테스트 base path가 등록 본문 이미지의 JavaScript 미리보기 경로에 누락되는 문제 수정
- [ ] [공동] 테스트 초안의 슬라이드·본문 이미지 UI 검토 및 수정사항 확정
- [ ] [Codex] 승인된 다중·본문 이미지 기능 운영 코드 반영
- [x] [Codex] 문의 목록, 상세, 처리 상태 및 공개 답변 구현 - [x] [Codex] 문의 목록, 상세, 처리 상태 및 공개 답변 구현
- [x] [Codex] 회사 및 사이트 설정 구현 - [x] [Codex] 회사 및 사이트 설정 구현
- [x] [Codex] CSRF, 세션 만료, 로그인 시도 제한, 권한 검증 적용 - [x] [Codex] CSRF, 세션 만료, 로그인 시도 제한, 권한 검증 적용
- [x] [사용자] GA4 표준 속성과 `gtsit.co.kr` 웹 데이터 스트림 생성
- [x] [Codex] 운영 공개 페이지에 통계 동의 기반 Google 태그 적용, 관리자·테스트 경로 제외
- [x] [사용자] Analytics Data API 서비스 계정 생성과 GA4 뷰어 권한 부여
- [x] [Codex] GA4 Data API 연동과 15분 서버 캐시 구현
- [x] [Codex] 관리자 대시보드 방문자·페이지뷰·유입경로·인기 페이지 UI 구현
- [x] [Codex] GA4 전문 용어를 최근 접속자·30일 방문자·페이지 열람 수·사이트 방문 횟수로 쉽게 표시
- [x] [Codex] GA4 측정 시작일 이후 전체 방문자 지표 추가
- [x] [공동] 테스트 관리자 대시보드의 데스크톱 UI 확인
- [ ] [공동] 관리자 대시보드 모바일 UI 확인
- [ ] [Codex] 문의 제출 전환 이벤트와 전환 지표 추가
GA4 진행 메모: 2026-08-12 계정 `(주)지티에스정보통신`, 속성 ID `549573245`, 운영 웹 스트림 ID `15422491033`, 측정 ID `G-92XBR314YD`를 생성했습니다. 선택 동의 전에는 Google 스크립트를 로드하지 않고, 테스트·관리자 경로는 측정하지 않습니다. 운영 동의 후 GA4 실시간 활성 사용자 1명을 확인했습니다.
GA4 관리자 연동 메모: 2026-08-13 Google Cloud 프로젝트 `gtsit-ga4-dashboard-20260812`에서 Analytics Data API를 활성화하고 서비스 계정 `gtsit-ga4-reader@gtsit-ga4-dashboard-20260812.iam.gserviceaccount.com`에 GA4 속성 `뷰어` 권한만 부여했습니다. 서버 측 OAuth와 Data API 실제 조회에서 사용자·페이지뷰·세션 데이터 응답을 확인했고, 집계 결과는 웹 루트 밖에 15분간 권한 600으로 캐시합니다. 테스트 관리자 화면의 최종 시각 QA 후 운영에 반영합니다.
운영 반영 메모: 2026-08-13 현재 저장소의 공개·관리자 실행 코드, 번호형 게시글 URL, 문의·개인정보 화면, 관리자 글쓰기 개선, GA4 대시보드와 공개 에셋을 운영에 반영했습니다. 서버 기존 파일은 `/home/hosting_users/gts0201/.deploy-backup-20260813-current-work`에 앱·웹 아카이브로 보관했습니다. 운영 PHP 문법, GA4 Data API 실제 조회, 비밀키·캐시 권한 600, 주요 공개 경로와 에셋 200, 관리자 비로그인 302, 게시글 200과 이전 주소 301, 홈·문의·네이버 지도 렌더링을 확인했습니다.
## 9. 운영 설정 및 출시 ## 9. 운영 설정 및 출시
@@ -162,6 +191,7 @@
- [x] [Codex] 업로드 디렉터리 PHP 실행 차단 설정 작성 - [x] [Codex] 업로드 디렉터리 PHP 실행 차단 설정 작성
- [ ] [사용자] 하드 용량 및 트래픽 알림 수신처 결정 - [ ] [사용자] 하드 용량 및 트래픽 알림 수신처 결정
- [ ] [공동] 용량 및 트래픽 알림 활성화 - [ ] [공동] 용량 및 트래픽 알림 활성화
- [ ] [공동] GA4 통계 쿠키 동의 문구와 개인정보처리방침 국외 처리 항목 최종 검토
- [x] [공동] 검색 봇 차단 정책과 공개 시점 결정 - [x] [공동] 검색 봇 차단 정책과 공개 시점 결정
- [x] [Codex] 공개 페이지 허용·관리자와 비공개 문의 조회 차단 `robots.txt` 배포 - [x] [Codex] 공개 페이지 허용·관리자와 비공개 문의 조회 차단 `robots.txt` 배포
- [x] [Codex] 공개 페이지와 게시글 6건 `sitemap.xml` 배포 - [x] [Codex] 공개 페이지와 게시글 6건 `sitemap.xml` 배포
@@ -176,3 +206,11 @@
지도 연동 메모: 2026-08-05 Client ID `0xuekem2qn``ncpKeyId`로 등록했습니다. 고정된 회사 위치는 주소 변환 API를 매번 호출하지 않고 확인된 위·경도로 표시하며, `Dynamic Map`만으로 회사 마커를 렌더링합니다. 지도 SDK 로드·인증 실패 시에도 주소와 네이버 지도 검색 링크를 유지합니다. 지도 연동 메모: 2026-08-05 Client ID `0xuekem2qn``ncpKeyId`로 등록했습니다. 고정된 회사 위치는 주소 변환 API를 매번 호출하지 않고 확인된 위·경도로 표시하며, `Dynamic Map`만으로 회사 마커를 렌더링합니다. 지도 SDK 로드·인증 실패 시에도 주소와 네이버 지도 검색 링크를 유지합니다.
운영 오픈 메모: 2026-08-10 기존 임시 페이지와 루트 설정을 `.deploy-backup-20260810-production-open`에 보존하고 `https://gtsit.co.kr`을 본 사이트로 전환했습니다. 운영 앱은 웹 루트 밖의 `gtsit-app-20260810`, 공개 파일은 `www`에 배치했습니다. 주요 공개 경로·게시글 6건·에셋·지도·관리자 로그인·HTTPS 리디렉션·업로드 PHP 실행 차단을 확인했습니다. 일반 GET은 정상이나 `HEAD` 요청을 라우터가 404로 처리하는 호환성 항목은 로직 변경 승인 후 보완합니다. 최초 커밋 `dbb35d3`은 생성했으며 원격 저장소 HTTPS 인증 정보가 없어 push는 사용자 인증 후 재시도합니다. 운영 오픈 메모: 2026-08-10 기존 임시 페이지와 루트 설정을 `.deploy-backup-20260810-production-open`에 보존하고 `https://gtsit.co.kr`을 본 사이트로 전환했습니다. 운영 앱은 웹 루트 밖의 `gtsit-app-20260810`, 공개 파일은 `www`에 배치했습니다. 주요 공개 경로·게시글 6건·에셋·지도·관리자 로그인·HTTPS 리디렉션·업로드 PHP 실행 차단을 확인했습니다. 일반 GET은 정상이나 `HEAD` 요청을 라우터가 404로 처리하는 호환성 항목은 로직 변경 승인 후 보완합니다. 최초 커밋 `dbb35d3`은 생성했으며 원격 저장소 HTTPS 인증 정보가 없어 push는 사용자 인증 후 재시도합니다.
게시글 미디어 테스트 메모: 2026-08-10 운영과 테스트가 같은 DB를 사용하므로 스키마는 변경하지 않고 `post_images.caption` 내부 표식으로 대표·본문 이미지를 구분했습니다. `/site-preview-7c2a91f4/admin/posts/15/preview`에 테스트 전용 초안을 구성했으며, 대표 3장 자동 슬라이드와 본문 2장 위치 배치를 확인했습니다. 다중 또는 본문 이미지가 있는 글은 운영 반영 전까지 `draft` 저장만 허용하며 운영 앱 파일은 변경하지 않았습니다.
본문 블록 편집기 메모: 2026-08-10 범용 WYSIWYG와 임의 HTML 저장을 사용하지 않고 테스트 관리자에 텍스트 문단·이미지 블록 편집기를 적용했습니다. 관리 화면은 내부 `[[image:번호]]`를 숨기고 실제 이미지 카드로 보여주며, 저장 직전에만 기존 일반 텍스트 형식으로 변환합니다. 이미지 위치 토큰은 서버에서 숫자 범위·중복·본문 1회 배치·실제 이미지 연결 여부를 재검증합니다.
작성 화면 미리보기 메모: 2026-08-10 테스트 관리자 편집 화면에 저장 전 내용을 즉시 확인하는 모달을 추가했습니다. 현재 입력 중인 제목·분류·작성자·문단과 기존/신규 대표 이미지·본문 이미지를 브라우저 DOM으로만 구성하며, 미리보기를 열거나 대표 이미지 썸네일을 전환해도 데이터베이스에는 저장하지 않습니다.
게시글 URL 자동 생성 메모: 2026-08-10 새 글 저장 시 DB가 발급한 게시글 번호를 기준으로 `post-16`, `post-17`처럼 slug를 자동 생성하도록 변경했습니다. 기존 1~15번 글도 `post-1`~`post-15`로 통일하고 종전 주소는 새 주소로 301 이동하도록 보존하며, 작성·수정 화면에서는 URL을 직접 입력하지 않습니다.

View File

@@ -47,6 +47,8 @@
운영 오픈 기록: 2026-08-10 `https://gtsit.co.kr` 운영 루트를 본 사이트로 전환했습니다. 홈·블로그·문의·개인정보와 공개 게시글 6건은 GET 200, 없는 경로는 404, 비로그인 관리자는 `/admin/login`으로 이동했습니다. 공개 canonical에서 테스트 경로가 제거되고 공개 화면에는 robots 차단이 없으며 관리자 화면은 `noindex,nofollow,noarchive`를 유지합니다. 홈 1280px에서 메인 이미지 4장·서비스 아이콘 4개·최신 글 3개와 가로 넘침 없음을 확인했고, 문의 지도는 `ready`·프레임 300px·지도 298px·타일 27개·전체화면 버튼 표시 상태입니다. 운영 업로드 경로의 임시 PHP 파일 요청은 403으로 차단됐고 검사 파일은 즉시 삭제했습니다. 운영 오픈 기록: 2026-08-10 `https://gtsit.co.kr` 운영 루트를 본 사이트로 전환했습니다. 홈·블로그·문의·개인정보와 공개 게시글 6건은 GET 200, 없는 경로는 404, 비로그인 관리자는 `/admin/login`으로 이동했습니다. 공개 canonical에서 테스트 경로가 제거되고 공개 화면에는 robots 차단이 없으며 관리자 화면은 `noindex,nofollow,noarchive`를 유지합니다. 홈 1280px에서 메인 이미지 4장·서비스 아이콘 4개·최신 글 3개와 가로 넘침 없음을 확인했고, 문의 지도는 `ready`·프레임 300px·지도 298px·타일 27개·전체화면 버튼 표시 상태입니다. 운영 업로드 경로의 임시 PHP 파일 요청은 403으로 차단됐고 검사 파일은 즉시 삭제했습니다.
관리자 글 미리보기 기록: 2026-08-10 글 관리 목록의 14개 글에 관리자 전용 미리보기 링크를 추가했습니다. 초안 1건과 공개 글 1건을 실제 공개 페이지 레이아웃으로 확인했으며, 상태별 미리보기 안내·수정 링크·대표 이미지·초안 수정일 대체 표시·공개 URL canonical·`noindex,nofollow,noarchive`·가로 넘침 없음을 검증했습니다. 비로그인 요청은 관리자 로그인으로 이동합니다.
회사 정보 반영 기록: 2026-08-04 대표 이메일과 주소를 `site_settings` 및 애플리케이션 기본값에 반영했습니다. 테스트 문의 페이지에서 새 주소, 푸터에서 새 이메일·주소가 표시되고 이전 값이 남아 있지 않은 것을 확인했습니다. 회사 정보 반영 기록: 2026-08-04 대표 이메일과 주소를 `site_settings` 및 애플리케이션 기본값에 반영했습니다. 테스트 문의 페이지에서 새 주소, 푸터에서 새 이메일·주소가 표시되고 이전 값이 남아 있지 않은 것을 확인했습니다.
회사명 통일 기록: 2026-08-04 공개 헤더·푸터·메타데이터, 관리자 콘솔, 게시글 작성자 기본값과 기존 초안 9건, 운영 루트 임시 페이지를 `(주)지티에스정보통신`으로 통일했습니다. 실제 응답 3개 화면에서 이전 표기 0건, 새 표기 16건을 확인했고 360·390·1440px에서 가로 넘침이 없었습니다. 회사명 통일 기록: 2026-08-04 공개 헤더·푸터·메타데이터, 관리자 콘솔, 게시글 작성자 기본값과 기존 초안 9건, 운영 루트 임시 페이지를 `(주)지티에스정보통신`으로 통일했습니다. 실제 응답 3개 화면에서 이전 표기 0건, 새 표기 16건을 확인했고 360·390·1440px에서 가로 넘침이 없었습니다.
@@ -63,6 +65,12 @@
지도 표시·전체화면 수정 기록: 2026-08-05 NAVER SDK가 지도 요소의 위치 속성을 덮어써 내부 높이가 0px이 된 원인을 수정했습니다. 일반 상태에서 프레임 300px·지도 298px·타일 이미지 27개·마커 1개를 확인했습니다. 사용자 정의 전체화면 아이콘으로 720px 확장 후 298px 복귀와 마커 유지를 검증했고, 390px에서도 지도 298px와 가로 넘침 없음을 확인했습니다. 정상 상태의 외부 텍스트 링크는 숨기고 실패 시 fallback으로 유지합니다. 지도 표시·전체화면 수정 기록: 2026-08-05 NAVER SDK가 지도 요소의 위치 속성을 덮어써 내부 높이가 0px이 된 원인을 수정했습니다. 일반 상태에서 프레임 300px·지도 298px·타일 이미지 27개·마커 1개를 확인했습니다. 사용자 정의 전체화면 아이콘으로 720px 확장 후 298px 복귀와 마커 유지를 검증했고, 390px에서도 지도 298px와 가로 넘침 없음을 확인했습니다. 정상 상태의 외부 텍스트 링크는 숨기고 실패 시 fallback으로 유지합니다.
지도·전화상담 스태킹 기록: 2026-08-10 네이버 지도 내부 컨트롤이 최대 `z-index: 10000`을 사용해 `z-index: 80`인 전화상담 버튼보다 위에 표시되는 현상을 확인했습니다. 지도 프레임에 독립 스태킹 컨텍스트를 적용하고 전화상담 버튼을 공통 헤더보다 높은 `z-index: 200`으로 조정했습니다. 운영 문의 화면의 실제 겹침 지점에서 최상위 요소가 전화상담 링크이고 지도는 `ready` 상태를 유지하며 가로 넘침이 없음을 확인했습니다.
지도 이동 잠금 기록: 2026-08-10 네이버 지도 옵션에서 마우스·터치 드래그와 키보드 이동을 차단하고 휠·핀치·확대축소 컨트롤은 유지했습니다. 확대 이벤트마다 회사 마커 좌표를 중심으로 재고정합니다. 운영 화면에서 확대 버튼을 눌렀을 때 지도 타일이 27개에서 39개로 갱신되고 마커의 지도 중심 오차가 확대 전후 `1px, -16px`로 동일하며 `ready` 상태가 유지됨을 확인했습니다.
지도 제스처 줌 차단 기록: 2026-08-10 핀치·휠·더블클릭·더블탭·두 손가락 탭 확대축소를 비활성화했습니다. 네이버 기본 줌 컨트롤도 축소 시 세로 중심이 이동하는 것을 확인해 제거하고, 회사 주소 좌표를 중심으로 애니메이션 없이 줌 단계만 변경하는 자체 `+ / ` 컨트롤로 교체했습니다. 운영 화면에서 기본 줌 컨트롤 0개·자체 버튼 2개를 확인했고, 자체 축소 전후 마커 중심 오차가 모두 `1px, -16px`로 동일하며 지도 `ready` 상태가 유지됐습니다.
블로그 임시 썸네일 기록: 2026-08-05 공개 글 6건 중 실제 대표 이미지 5건은 기존 사진을 유지하고, 이미지가 없는 1건에는 `GTS` 브랜드 플레이스홀더가 표시되는 것을 확인했습니다. 1280px에서 카드 행 높이 정렬과 가로 넘침 없음, 390px에서 카드 358px·플레이스홀더 356×199px 및 가로 넘침 없음을 확인했습니다. 블로그 임시 썸네일 기록: 2026-08-05 공개 글 6건 중 실제 대표 이미지 5건은 기존 사진을 유지하고, 이미지가 없는 1건에는 `GTS` 브랜드 플레이스홀더가 표시되는 것을 확인했습니다. 1280px에서 카드 행 높이 정렬과 가로 넘침 없음, 390px에서 카드 358px·플레이스홀더 356×199px 및 가로 넘침 없음을 확인했습니다.
문의 입력 편의 기록: 2026-08-05 이메일 아이디 `11111``naver.com` 선택 시 전송용 값 `11111@naver.com`, `직접입력` 선택 시 전체 주소 `hello@company.co.kr`이 유지되는 것을 확인했습니다. 연락처 숫자 `01011111111``010-1111-1111`로 자동 변환됐습니다. 실제 문의는 생성하지 않았으며 390px에서 이메일 필드 312px와 가로 넘침 없음을 확인했습니다. 문의 입력 편의 기록: 2026-08-05 이메일 아이디 `11111``naver.com` 선택 시 전송용 값 `11111@naver.com`, `직접입력` 선택 시 전체 주소 `hello@company.co.kr`이 유지되는 것을 확인했습니다. 연락처 숫자 `01011111111``010-1111-1111`로 자동 변환됐습니다. 실제 문의는 생성하지 않았으며 390px에서 이메일 필드 312px와 가로 넘침 없음을 확인했습니다.
@@ -178,11 +186,29 @@
- [ ] 반복 로그인 실패 제한 - [ ] 반복 로그인 실패 제한
- [ ] 세션 만료 후 재인증 요구 - [ ] 세션 만료 후 재인증 요구
- [ ] 게시글 작성, 수정, 삭제, 공개 상태 동작 - [ ] 게시글 작성, 수정, 삭제, 공개 상태 동작
- [x] 관리자 글 목록에서 초안·공개 글 미리보기와 비로그인 접근 차단
- [x] 신규·기존 게시글의 `post-{id}` 자동 URL과 종전 주소 301 이동
- [x] 테스트 초안에서 대표 이미지 3장 슬라이드 렌더링과 5초 자동 전환
- [x] 테스트 초안 본문의 `[[image:번호]]` 위치에 이미지 2장 렌더링
- [x] 관리자에서 내부 이미지 토큰 미노출 및 문단·이미지 블록 변환
- [x] 이미지 블록 위·아래 이동 후 저장과 공개 형식 미리보기 순서 일치
- [x] 블록 저장 후 기존 텍스트 이스케이프·이미지 매핑 구조 유지
- [x] 작성 화면 미리보기에 저장 전 제목·본문 변경 즉시 반영
- [x] 작성 화면 미리보기에서 대표 이미지 개수·썸네일·본문 이미지 렌더링 확인
- [x] 대표 이미지 썸네일 선택 시 미리보기의 큰 이미지 전환
- [x] 테스트 관리자 블록 편집기의 등록 본문 이미지 경로와 실제 이미지 응답 확인
- [x] 대표 슬라이드 이전·다음·직접 선택·일시정지 조작 제공
- [x] 다중·본문 이미지가 있는 글의 공개 저장 차단
- [ ] 이미지 파일 선택 후 대체 텍스트·본문 삽입 버튼 실제 업로드 회귀 테스트
- [ ] 관리자 목록의 검색, 필터, 페이지네이션 동작 - [ ] 관리자 목록의 검색, 필터, 페이지네이션 동작
- [ ] 문의 목록, 상세, 처리 상태 변경 동작 - [ ] 문의 목록, 상세, 처리 상태 변경 동작
- [ ] 모바일 표와 조작 버튼의 잘림 없음 - [ ] 모바일 표와 조작 버튼의 잘림 없음
- [x] 관리자 변경 작업에 CSRF 방어 적용 - [x] 관리자 변경 작업에 CSRF 방어 적용
- [x] 일반 사용자가 관리자 경로를 호출할 수 없음 - [x] 일반 사용자가 관리자 경로를 호출할 수 없음
- [x] 관리자 대시보드 GA4 집계 카드·기간 차트·인기 페이지·유입경로의 데스크톱 표시
- [ ] 관리자 대시보드 GA4 지표의 모바일 표시와 가로 넘침 없음
- [x] 관리자 대시보드에 최근 접속자·전체 방문자·30일 방문자·처음 온 방문자·페이지 열람 수·사이트 방문 횟수가 쉬운 설명과 함께 표시됨
- [ ] GA4 API 장애 시 대시보드의 안전한 대체 안내와 기존 관리자 기능 유지
진행 기록: 2026-08-04 공식 PHP 8.4 컨테이너와 Cafe24 PHP 8.4에서 전체 PHP 문법 통과. 로컬 주요 경로 `/`, `/blog`, `/contact`, `/privacy`, `/inquiry/lookup`, `/admin/login` 200, 비로그인 `/admin`, `/admin/posts` 302, 없는 경로 404 확인. 문의 CSRF 누락 403 및 빈 필수값 제출 422 확인. Cafe24가 비표준 419를 500으로 변환하는 것을 확인해 표준 403으로 조정했습니다. 진행 기록: 2026-08-04 공식 PHP 8.4 컨테이너와 Cafe24 PHP 8.4에서 전체 PHP 문법 통과. 로컬 주요 경로 `/`, `/blog`, `/contact`, `/privacy`, `/inquiry/lookup`, `/admin/login` 200, 비로그인 `/admin`, `/admin/posts` 302, 없는 경로 404 확인. 문의 CSRF 누락 403 및 빈 필수값 제출 422 확인. Cafe24가 비표준 419를 500으로 변환하는 것을 확인해 표준 403으로 조정했습니다.
@@ -201,6 +227,16 @@
진행 기록: 2026-08-04 Cafe24 테스트 경로의 업로드 폴더에 무해한 PHP 검사 파일을 잠시 배치해 HTTP 403과 미실행을 확인한 뒤 즉시 삭제했습니다. 진행 기록: 2026-08-04 Cafe24 테스트 경로의 업로드 폴더에 무해한 PHP 검사 파일을 잠시 배치해 HTTP 403과 미실행을 확인한 뒤 즉시 삭제했습니다.
게시글 미디어 기록: 2026-08-10 `https://gtsit.co.kr/site-preview-7c2a91f4/admin/posts/15/preview`에서 대표 이미지 3장, 본문 이미지 2장, 관리자 대체 텍스트 필드와 본문 삽입 토큰을 확인했습니다. 자동재생 후 활성 슬라이드가 변경되고 다음 버튼 조작이 반영되는 것을 확인했습니다. 테스트 자산은 이전 사이트의 메인 이미지와 EXIF 위치정보가 없는 600×450 시공 썸네일만 사용했습니다.
블록 편집기 기록: 2026-08-10 테스트 관리자 글 15번에서 텍스트 3개·이미지 2개 블록 변환, 내부 토큰 미노출, 이동 버튼 상태, 이미지 블록 순서 변경 저장과 미리보기 반영을 확인한 뒤 원래 순서로 복원했습니다. JavaScript와 뷰에서 `innerHTML`, `contenteditable`, 임의 HTML 삽입 API를 사용하지 않으며 모든 편집 요소를 DOM API와 일반 textarea로 생성합니다. 자동 브라우저의 파일 선택 API 제한으로 신규 파일을 통한 전체 업로드 회귀 항목은 수동 확인 대상으로 유지합니다.
작성 화면 미리보기 기록: 2026-08-10 테스트 관리자 글 15번 수정 화면에서 저장하지 않은 제목과 첫 문단이 미리보기 모달에 반영되는 것을 확인했습니다. 대표 이미지 3장과 본문 이미지 2장이 렌더링되고 두 번째 대표 이미지 썸네일 선택 시 큰 이미지가 바뀌는 것을 확인한 뒤, 입력 폼은 원래 값으로 되돌리고 저장하지 않았습니다.
게시글 URL 자동화 기록: 2026-08-10 공유 DB의 기존 게시글 15건을 `post-1`~`post-15`로 변경하고 관리자 목록에서 15건 모두 번호형 주소로 표시되는 것을 확인했습니다. 운영의 종전 공개 주소는 `/blog/post-1`로 301 이동하고 새 주소는 200, 테스트 경로에서도 base path를 포함한 301 이동과 새 주소 200을 확인했습니다. 공개 글 6건의 사이트맵도 번호형 주소로 갱신했습니다.
본문 이미지 경로 기록: 2026-08-10 숨겨진 `data-path`는 공통 HTML의 `src` 경로 보정 대상이 아니어서 테스트 base path가 빠진 `/uploads/...`를 참조하던 문제를 수정했습니다. 관리자 글 15번에서 기존 본문 이미지 2개가 `/site-preview-7c2a91f4/uploads/media-preview/` 경로를 사용하고 각각 600×450px로 정상 로드되는 것을 확인했습니다.
## 11. 보안 및 개인정보 ## 11. 보안 및 개인정보
- [ ] HTML과 스크립트 입력의 XSS 방어 - [ ] HTML과 스크립트 입력의 XSS 방어
@@ -212,8 +248,20 @@
- [x] 개인정보 보관기간·종료일 기록·완전 삭제 명령 구현 - [x] 개인정보 보관기간·종료일 기록·완전 삭제 명령 구현
- [ ] 만료 문의 완전 삭제 명령의 정기 실행 등록 - [ ] 만료 문의 완전 삭제 명령의 정기 실행 등록
- [ ] 보안 헤더 적용 여부 확인 - [ ] 보안 헤더 적용 여부 확인
- [x] 통계 쿠키 동의 전 Google 태그 미로딩
- [ ] 통계 쿠키 거부·철회 후 선택과 측정 차단 유지
- [x] 운영 공개 경로만 GA4 측정, 관리자·테스트 경로 제외
- [x] 문의 개인정보·접수번호와 관리자 정보가 GA4 이벤트로 전송되지 않음
- [ ] ModSecurity와 UploadGuard 오탐 없음 - [ ] ModSecurity와 UploadGuard 오탐 없음
GA4 수집 기록: 2026-08-12 무료 표준 속성 `549573245`, 운영 웹 스트림 `15422491033`, 측정 ID `G-92XBR314YD`를 생성했습니다. 테스트 경로는 허용 전후 Google 태그 0개, 운영 루트는 최초 동의 전 0개·허용 후 1개를 확인했습니다. GA4 홈의 지난 30분 활성 사용자가 1명으로 표시되어 실제 수집을 확인했으며 광고 저장·Google 신호·광고 개인화는 비활성화했습니다. 사용자·이벤트 데이터는 신규 속성 기본값인 2개월을 사용합니다.
GA4 관리자 연동 기록: 2026-08-13 전용 서비스 계정에 GA4 속성 `뷰어` 권한만 부여했고 Cloud 프로젝트 역할은 추가하지 않았습니다. JSON 키와 15분 집계 캐시는 Git·웹 루트 밖에서 권한 `600`으로 보관합니다. Analytics Data API 실제 요청에서 최근 30일 사용자·신규 사용자·페이지뷰·세션, 14일 추이, 인기 페이지, 유입경로 응답을 확인했습니다.
GA4 대시보드 표시 기록: 2026-08-13 테스트 관리자 화면에서 전체 방문자 1명과 최근 30일 방문자·첫 방문자·페이지 열람·사이트 방문 횟수 각 1회를 확인했습니다. 전체 방문자는 GA4 측정 시작일 `2026-08-12` 이후의 `totalUsers`이며 카드 설명에 측정 시작일을 함께 표시합니다.
운영 배포 기록: 2026-08-13 운영 PHP 파일 전체 문법 통과, GA4 Data API 응답 `available=true`와 전체 방문자 1명 확인, 키·캐시 권한 600 확인. `/`, `/blog`, `/contact`, `/privacy`, 사이트맵과 CSS·JavaScript는 200, 비로그인 `/admin`은 302, `/blog/post-10``/blog/post-14`는 200, 종전 게시글 주소는 번호형 주소로 301 응답했습니다. 브라우저에서 운영 홈의 콘텐츠·슬라이드·서비스·글 목록과 문의 폼·네이버 지도·전체화면 및 확대축소 컨트롤을 확인했습니다.
## 12. 검색, 성능 및 운영 ## 12. 검색, 성능 및 운영
- [x] 임시 페이지 종료 후 공개 화면 `noindex` 제거 확인 - [x] 임시 페이지 종료 후 공개 화면 `noindex` 제거 확인
@@ -233,7 +281,9 @@
- [x] 회사 주소 좌표와 마커 위치 일치 - [x] 회사 주소 좌표와 마커 위치 일치
- [x] 지도 SDK 적용 후 실제 지도 요소 높이와 타일 표시 - [x] 지도 SDK 적용 후 실제 지도 요소 높이와 타일 표시
- [x] 사용자 정의 전체화면 아이콘 열기·종료 및 지도 크기 복구 - [x] 사용자 정의 전체화면 아이콘 열기·종료 및 지도 크기 복구
- [ ] 데스크톱·모바일 지도 확대·축소 및 스크롤 충돌 없음 - [x] 지도 드래그·키보드 이동 차단 및 마커 중심 확대·축소 유지
- [x] 핀치·휠·더블클릭/탭 확대·축소 차단 및 지도 컨트롤만 허용
- [ ] 데스크톱·모바일 지도 확대·축소 및 페이지 스크롤 충돌 없음
- [x] 지도 API 로드 실패 시 주소와 네이버 지도 링크 유지 - [x] 지도 API 로드 실패 시 주소와 네이버 지도 링크 유지
- [x] Client Secret이 HTML·JavaScript·저장소에 포함되지 않음 - [x] Client Secret이 HTML·JavaScript·저장소에 포함되지 않음
- [ ] API 이용 한도와 임계치 알림 설정 - [ ] API 이용 한도와 임계치 알림 설정

View File

@@ -3,3 +3,8 @@
@media(max-width:480px){.admin-account .button{display:none}.admin-main{padding-top:28px}.metric-grid{grid-template-columns:1fr 1fr;gap:10px}.metric-grid a{padding:18px}.login-panel{margin:26px 0;padding:24px}} @media(max-width:480px){.admin-account .button{display:none}.admin-main{padding-top:28px}.metric-grid{grid-template-columns:1fr 1fr;gap:10px}.metric-grid a{padding:18px}.login-panel{margin:26px 0;padding:24px}}
.admin-form{max-width:780px;padding:28px;background:#fff;border:1px solid var(--border);border-radius:var(--radius)}.admin-form label{display:block;margin-bottom:17px;color:var(--secondary);font-size:13px;font-weight:700}.admin-form label>span{color:#dc2626}.admin-form input,.admin-form select,.admin-form textarea{width:100%;margin-top:7px;padding:10px 12px;border:1px solid #d4d4d8;border-radius:9px;background:#fff}.admin-form input,.admin-form select{height:44px}.admin-form textarea{resize:vertical;line-height:1.6}.admin-form label small{display:block;margin-top:5px;color:var(--muted);font-weight:400}.admin-field-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}.admin-form-actions{display:flex;gap:10px;margin-top:24px}.danger-zone{max-width:780px;margin-top:24px;padding:20px 24px;display:flex;align-items:center;justify-content:space-between;gap:20px;border:1px solid #fecaca;border-radius:var(--radius);background:#fff}.danger-zone strong{color:#991b1b}.danger-zone p{margin:4px 0 0;color:var(--muted);font-size:13px}.danger-zone button{padding:9px 14px;border:1px solid #fecaca;border-radius:8px;background:#fff;color:#b91c1c;cursor:pointer}.inquiry-heading{margin-top:22px}.inquiry-detail-grid{display:grid;grid-template-columns:1.1fr .9fr;gap:20px;align-items:start}.inquiry-content{padding:26px;background:#fff;border:1px solid var(--border);border-radius:var(--radius)}.contact-summary{padding:16px;background:#f8fafc;border-radius:9px}.contact-summary>span{float:right;color:var(--primary);font-size:12px;font-weight:800}.contact-summary p{margin:0;line-height:1.8}.inquiry-content h2{margin-top:25px;font-size:17px}.inquiry-content>p{line-height:1.85}.reply-form{padding:26px}.reply-form h2{margin:0 0 20px} .admin-form{max-width:780px;padding:28px;background:#fff;border:1px solid var(--border);border-radius:var(--radius)}.admin-form label{display:block;margin-bottom:17px;color:var(--secondary);font-size:13px;font-weight:700}.admin-form label>span{color:#dc2626}.admin-form input,.admin-form select,.admin-form textarea{width:100%;margin-top:7px;padding:10px 12px;border:1px solid #d4d4d8;border-radius:9px;background:#fff}.admin-form input,.admin-form select{height:44px}.admin-form textarea{resize:vertical;line-height:1.6}.admin-form label small{display:block;margin-top:5px;color:var(--muted);font-weight:400}.admin-field-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}.admin-form-actions{display:flex;gap:10px;margin-top:24px}.danger-zone{max-width:780px;margin-top:24px;padding:20px 24px;display:flex;align-items:center;justify-content:space-between;gap:20px;border:1px solid #fecaca;border-radius:var(--radius);background:#fff}.danger-zone strong{color:#991b1b}.danger-zone p{margin:4px 0 0;color:var(--muted);font-size:13px}.danger-zone button{padding:9px 14px;border:1px solid #fecaca;border-radius:8px;background:#fff;color:#b91c1c;cursor:pointer}.inquiry-heading{margin-top:22px}.inquiry-detail-grid{display:grid;grid-template-columns:1.1fr .9fr;gap:20px;align-items:start}.inquiry-content{padding:26px;background:#fff;border:1px solid var(--border);border-radius:var(--radius)}.contact-summary{padding:16px;background:#f8fafc;border-radius:9px}.contact-summary>span{float:right;color:var(--primary);font-size:12px;font-weight:800}.contact-summary p{margin:0;line-height:1.8}.inquiry-content h2{margin-top:25px;font-size:17px}.inquiry-content>p{line-height:1.85}.reply-form{padding:26px}.reply-form h2{margin:0 0 20px}
@media(max-width:760px){.admin-field-grid,.inquiry-detail-grid{grid-template-columns:1fr}.danger-zone{align-items:stretch;flex-direction:column}.danger-zone button{width:100%}} @media(max-width:760px){.admin-field-grid,.inquiry-detail-grid{grid-template-columns:1fr}.danger-zone{align-items:stretch;flex-direction:column}.danger-zone button{width:100%}}
.admin-experiment-notice{max-width:780px;margin:0 0 18px;padding:14px 16px;display:grid;gap:3px;border:1px solid #fde68a;border-radius:10px;background:#fffbeb;color:#92400e;font-size:13px}.admin-experiment-notice strong{font-size:14px}.admin-upload-field>p:first-of-type{margin-top:0}.admin-media-list{display:grid;gap:12px;margin-bottom:18px}.admin-media-item{display:grid;grid-template-columns:160px 1fr;gap:14px;padding:12px;border:1px solid var(--border);border-radius:9px;background:#f8fafc}.admin-media-item>img{width:160px;height:100px}.admin-media-item>div{min-width:0;display:flex;align-items:flex-start;flex-direction:column;gap:8px}.admin-media-item label{width:100%;margin:0}.admin-media-item code{padding:4px 7px;border-radius:5px;background:#e2e8f0;color:#334155;font-size:12px}.admin-media-delete{display:flex!important;align-items:center;gap:7px}.admin-media-delete input{width:16px!important;height:16px!important;margin:0!important}.admin-media-item.is-delete{opacity:.5}.admin-new-media-list{display:grid;gap:10px}.admin-new-media-item{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:8px 12px;padding:12px;border:1px solid #bfdbfe;border-radius:9px;background:#eff6ff}.admin-new-media-item strong{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px}.admin-new-media-item label{grid-column:1/-1;margin:0}.admin-form code{font-size:12px}@media(max-width:600px){.admin-media-item{grid-template-columns:1fr}.admin-media-item>img{width:100%;height:auto}.admin-new-media-item{grid-template-columns:1fr}.admin-new-media-item .button{width:100%}}
.admin-row-actions{display:flex;align-items:center;gap:12px;white-space:nowrap}
.admin-block-editor{display:none;margin-bottom:20px}.admin-block-editor.is-ready{display:block}.admin-body-source.is-enhanced{display:none}.admin-block-editor>header{display:flex;align-items:flex-start;justify-content:space-between;gap:18px;margin-bottom:14px}.admin-block-editor>header h2{margin:0;font-size:17px}.admin-block-editor>header p{margin:5px 0 0;color:var(--muted);font-size:13px}.admin-block-editor>header>span{padding:5px 9px;border-radius:999px;background:#eff6ff;color:#1d4ed8;font-size:11px;font-weight:800;white-space:nowrap}.admin-block-list{display:grid;gap:12px}.admin-content-block{padding:14px;border:1px solid #dbe2ea;border-radius:11px;background:#f8fafc}.admin-content-block>header{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:11px}.admin-content-block>header strong{color:#475569;font-size:12px}.admin-content-block-actions{display:flex;gap:5px}.admin-content-block-actions button{min-height:30px;padding:0 9px;border:1px solid #d4d4d8;border-radius:7px;background:#fff;color:#52525b;font-size:11px;cursor:pointer}.admin-content-block-actions button:hover{background:#f1f5f9}.admin-content-block-actions button:disabled{opacity:.4;cursor:not-allowed}.admin-content-block-actions .is-danger{border-color:#fecaca;color:#b91c1c}.admin-content-block-text textarea{min-height:112px;margin:0;overflow:hidden;background:#fff}.admin-content-block-image{display:grid;grid-template-columns:220px minmax(0,1fr);gap:14px}.admin-content-block-image>header{grid-column:1/-1;margin-bottom:0}.admin-content-block-image>img{width:220px;height:148px;object-fit:cover;border-radius:9px;background:#e2e8f0}.admin-content-image-fields{min-width:0;display:flex;align-items:stretch;justify-content:center;flex-direction:column}.admin-content-image-fields label{margin:0}.admin-content-insert-actions{grid-column:1/-1;display:flex;justify-content:center;gap:7px;padding-top:10px;border-top:1px dashed #cbd5e1}.admin-content-insert-actions button{padding:6px 9px;border:0;background:transparent;color:#2563eb;font-size:11px;font-weight:700;cursor:pointer}.admin-content-insert-actions button:hover{text-decoration:underline}.admin-hidden-file{position:absolute!important;width:1px!important;height:1px!important;padding:0!important;margin:-1px!important;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0!important}.admin-block-editor-actions{display:flex;gap:9px;margin-top:12px}.admin-block-help{margin:8px 0 0;color:var(--muted);font-size:12px}@media(max-width:600px){.admin-block-editor>header{flex-direction:column}.admin-content-block-image{grid-template-columns:1fr}.admin-content-block-image>img{width:100%;height:auto;aspect-ratio:16/10}.admin-content-block-actions button{padding:0 7px}.admin-block-editor-actions{display:grid;grid-template-columns:1fr 1fr}.admin-block-editor-actions .button{width:100%}}
.admin-new-media-item>img{width:72px;height:52px;grid-row:1;object-fit:cover;border-radius:7px}.admin-new-media-item:has(>img){grid-template-columns:72px minmax(0,1fr)}.admin-new-media-item:has(>img)>label{grid-column:1/-1}.admin-live-preview{width:min(920px,calc(100% - 32px));max-width:none;max-height:calc(100vh - 40px);padding:0;border:0;border-radius:16px;background:#fff;box-shadow:0 24px 70px rgba(15,23,42,.28)}.admin-live-preview::backdrop{background:rgba(15,23,42,.58);backdrop-filter:blur(3px)}.admin-live-preview-shell>header{position:sticky;top:0;z-index:2;display:flex;align-items:flex-start;justify-content:space-between;gap:20px;padding:18px 22px;border-bottom:1px solid var(--border);background:#fff}.admin-live-preview-shell>header span{color:#2563eb;font-size:10px;font-weight:900;letter-spacing:.14em}.admin-live-preview-shell>header h2{margin:3px 0 0;font-size:20px}.admin-live-preview-shell>header p{margin:4px 0 0;color:var(--muted);font-size:12px}.admin-live-preview-shell>header button{width:38px;height:38px;border:1px solid var(--border);border-radius:50%;background:#fff;color:#27272a;font-size:25px;line-height:1;cursor:pointer}.admin-live-preview-article{max-width:760px;margin:auto;padding:38px 32px 58px}.admin-live-preview-meta{display:grid;gap:8px;margin-bottom:24px}.admin-live-preview-meta>span{justify-self:start;padding:5px 10px;border-radius:999px;background:#dbeafe;color:#1d4ed8;font-size:12px;font-weight:800}.admin-live-preview-meta>strong{font-size:30px;line-height:1.3}.admin-live-preview-meta>small{color:var(--muted)}.admin-live-preview-gallery{margin:0 0 28px}.admin-live-preview-gallery>[data-preview-gallery-main]{overflow:hidden;aspect-ratio:16/9;border-radius:12px;background:#e2e8f0}.admin-live-preview-gallery>[data-preview-gallery-main] img{width:100%;height:100%;object-fit:cover}.admin-live-preview-gallery>[data-preview-gallery-thumbs]{display:flex;gap:7px;margin-top:9px;overflow:auto}.admin-live-preview-gallery>[data-preview-gallery-thumbs] button{flex:0 0 70px;height:48px;padding:0;overflow:hidden;border:2px solid transparent;border-radius:7px;background:#e2e8f0;cursor:pointer}.admin-live-preview-gallery>[data-preview-gallery-thumbs] button.is-active{border-color:#2563eb}.admin-live-preview-gallery>[data-preview-gallery-thumbs] img{width:100%;height:100%;object-fit:cover}.admin-live-preview-gallery>p{margin:7px 0 0;color:var(--muted);font-size:11px}.admin-live-preview-body{display:grid;gap:22px}.admin-live-preview-body p{margin:0;color:#3f3f46;line-height:1.9;white-space:pre-line}.admin-live-preview-body p.is-empty{padding:30px;text-align:center;border:1px dashed #cbd5e1;border-radius:10px;color:var(--muted)}.admin-live-preview-body figure{margin:0}.admin-live-preview-body figure img{display:block;width:100%;height:auto;border-radius:12px}@media(max-width:600px){.admin-form-actions{flex-wrap:wrap}.admin-form-actions .button,.admin-form-actions a{flex:1 1 calc(50% - 6px);text-align:center}.admin-live-preview{width:100%;max-height:100%;height:100%;border-radius:0}.admin-live-preview-article{padding:28px 18px 44px}.admin-live-preview-meta>strong{font-size:24px}}
.analytics-section{margin-top:44px}.analytics-heading{display:flex;align-items:end;justify-content:space-between;gap:24px;margin-bottom:18px}.analytics-heading>div>span{color:var(--primary);font-size:11px;font-weight:900;letter-spacing:.12em}.analytics-heading h2{margin:5px 0 0;font-size:24px}.analytics-heading p{margin:5px 0 0;color:var(--muted);font-size:13px}.analytics-heading>small{color:var(--muted);font-size:11px;white-space:nowrap}.analytics-metrics{display:grid;grid-template-columns:repeat(6,1fr);gap:12px}.analytics-metrics article{padding:20px;background:#fff;border:1px solid var(--border);border-radius:var(--radius)}.analytics-metrics span{display:block;color:var(--muted);font-size:12px}.analytics-metrics strong{display:block;margin:5px 0 1px;font-size:27px}.analytics-metrics small{color:#94a3b8;font-size:10px}.analytics-panel{margin-top:14px;padding:20px;background:#fff;border:1px solid var(--border);border-radius:var(--radius)}.analytics-panel-heading{display:flex;align-items:center;justify-content:space-between;gap:14px}.analytics-panel-heading h3{margin:0;font-size:15px}.analytics-panel-heading>span{color:var(--muted);font-size:10px}.analytics-panel-heading>span i{display:inline-block;width:7px;height:7px;margin-right:4px;border-radius:2px;background:var(--primary)}.analytics-chart{height:210px;margin-top:24px;display:flex;align-items:end;gap:9px}.analytics-bar{height:100%;min-width:0;flex:1;display:grid;grid-template-rows:20px 1fr 20px;align-items:end;text-align:center}.analytics-bar>span{align-self:start;color:#64748b;font-size:9px}.analytics-bar>i{width:min(24px,70%);min-height:3px;margin:0 auto;border-radius:5px 5px 2px 2px;background:linear-gradient(180deg,#3b82f6,#2563eb)}.analytics-bar>small{padding-top:5px;color:#94a3b8;font-size:9px;white-space:nowrap}.analytics-detail-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}.analytics-ranking{margin:14px 0 0;padding:0;list-style:none}.analytics-ranking li{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:11px 0;border-top:1px solid var(--border)}.analytics-ranking li>div{min-width:0}.analytics-ranking strong,.analytics-ranking small{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.analytics-ranking strong{font-size:12px}.analytics-ranking small{margin-top:3px;color:var(--muted);font-size:10px}.analytics-ranking b{color:#334155;font-size:12px;white-space:nowrap}.analytics-empty{margin:18px 0 0;color:var(--muted);font-size:12px}.analytics-unavailable{padding:22px;border:1px solid #fde68a;border-radius:var(--radius);background:#fffbeb;color:#92400e}.analytics-unavailable p{margin:5px 0 0;font-size:12px}@media(max-width:900px){.analytics-metrics{grid-template-columns:repeat(3,1fr)}}@media(max-width:760px){.analytics-heading{align-items:flex-start;flex-direction:column;gap:8px}.analytics-detail-grid{grid-template-columns:1fr}.analytics-chart{gap:4px}.analytics-bar>span{display:none}}@media(max-width:520px){.analytics-metrics{grid-template-columns:1fr 1fr}.analytics-metrics article{padding:16px}.analytics-chart{height:170px}.analytics-bar>small{font-size:8px;transform:rotate(-45deg);transform-origin:center top}.analytics-chart-panel{overflow:hidden}}

View File

@@ -65,3 +65,7 @@
.naver-map-external{display:inline-flex;align-items:center;min-height:44px;margin-top:14px;color:var(--primary);font-weight:700} .naver-map-external{display:inline-flex;align-items:center;min-height:44px;margin-top:14px;color:var(--primary);font-weight:700}
.naver-map-frame[data-map-state=loading]+.naver-map-external,.naver-map-frame[data-map-state=ready]+.naver-map-external{display:none} .naver-map-frame[data-map-state=loading]+.naver-map-external,.naver-map-frame[data-map-state=ready]+.naver-map-external{display:none}
@media(max-width:640px){.post-card:hover{transform:none}.email-template{grid-template-columns:minmax(0,1fr) auto minmax(116px,.85fr);gap:6px}.email-direct-row{grid-template-columns:1fr}.email-template-return{width:100%;margin-top:0}.floating-call{display:none}} @media(max-width:640px){.post-card:hover{transform:none}.email-template{grid-template-columns:minmax(0,1fr) auto minmax(116px,.85fr);gap:6px}.email-direct-row{grid-template-columns:1fr}.email-template-return{width:100%;margin-top:0}.floating-call{display:none}}
.naver-map-frame{isolation:isolate}
.preview-toolbar{position:sticky;top:88px;z-index:90;width:min(100% - 48px,820px);margin:24px auto -18px;padding:14px 16px;display:flex;align-items:center;justify-content:space-between;gap:18px;border:1px solid #bfdbfe;border-radius:10px;background:#eff6ff;box-shadow:0 8px 20px rgba(15,23,42,.08)}.preview-toolbar div{display:grid;gap:2px}.preview-toolbar strong{color:#1d4ed8}.preview-toolbar span{color:var(--secondary);font-size:13px}@media(max-width:640px){.preview-toolbar{top:76px;width:min(100% - 32px,820px);align-items:stretch;flex-direction:column}.preview-toolbar .button{width:100%}}
.naver-map-zoom{position:absolute;right:10px;top:58px;z-index:5;display:grid;overflow:hidden;border:1px solid rgba(15,23,42,.14);border-radius:8px;background:#fff;box-shadow:0 2px 8px rgba(15,23,42,.14)}.naver-map-zoom[hidden]{display:none}.naver-map-zoom button{width:38px;height:38px;padding:0;border:0;background:#fff;color:#27272a;font-size:24px;line-height:1;cursor:pointer}.naver-map-zoom button+button{border-top:1px solid var(--border)}.naver-map-zoom button:hover{background:#f8fafc}.naver-map-zoom button:disabled{color:#a1a1aa;cursor:not-allowed}
.article-gallery{position:relative;overflow:hidden;margin:0 0 30px;border-radius:var(--radius);background:#0f172a;box-shadow:var(--shadow)}.article-gallery-slides{position:relative;aspect-ratio:16/9}.article-gallery-slide{position:absolute;inset:0;margin:0;opacity:0;visibility:hidden;transition:opacity .45s ease,visibility .45s ease}.article-gallery-slide.is-active{opacity:1;visibility:visible}.article-gallery-slide img{width:100%;height:100%;object-fit:cover}.article-gallery-controls{position:absolute;left:14px;right:14px;bottom:12px;z-index:2;display:flex;align-items:center;gap:8px}.article-gallery-controls button{border:0;color:#fff;cursor:pointer}.article-gallery-controls>[data-article-prev],.article-gallery-controls>[data-article-next]{width:34px;height:34px;border-radius:50%;background:rgba(15,23,42,.72);font-size:26px;line-height:1}.article-gallery-dots{display:flex;align-items:center;gap:7px}.article-gallery-dots button{width:8px;height:8px;padding:0;border-radius:999px;background:rgba(255,255,255,.52)}.article-gallery-dots button.is-active{width:22px;background:#fff}.article-gallery-pause{min-height:34px;margin-left:auto;padding:0 11px;border-radius:999px;background:rgba(15,23,42,.72);font-size:11px;font-weight:700}.article-inline-image{margin:28px 0}.article-inline-image img{display:block;width:100%;height:auto;border-radius:12px}.article-body code{font-size:.9em}@media(prefers-reduced-motion:reduce){.article-gallery-slide{transition:none}}@media(max-width:600px){.article-gallery-pause{width:34px;overflow:hidden;padding:0;font-size:0}.article-gallery-pause::before{content:"Ⅱ";font-size:12px}.article-gallery-pause[aria-pressed=true]::before{content:"▶"}}

View File

@@ -8,3 +8,5 @@
.policy{width:min(100% - 48px,940px)}.policy-heading>span{color:var(--primary);font-size:13px;font-weight:800;letter-spacing:.09em}.policy-heading h1{margin:10px 0 0}.policy h2{margin-top:48px;font-size:22px}.policy h3{font-size:16px}.policy li{line-height:1.8;color:var(--secondary)}.policy a{color:var(--primary)}.policy-index{margin-top:32px;padding:22px 26px;background:#fff;border:1px solid var(--border);border-radius:var(--radius)}.policy-index ol{margin:0;padding-left:22px;columns:2;column-gap:36px}.policy-index li{break-inside:avoid;margin:5px 0}.policy-table-wrap{margin-top:18px;overflow-x:auto;border:1px solid var(--border);border-radius:10px}.policy table{width:100%;min-width:760px;border-collapse:collapse;background:#fff}.policy th,.policy td{padding:14px 16px;border-right:1px solid var(--border);border-bottom:1px solid var(--border);text-align:left;vertical-align:top;font-size:14px;line-height:1.7}.policy th{background:#f8fafc}.policy tr:last-child th,.policy tr:last-child td{border-bottom:0}.policy th:last-child,.policy td:last-child{border-right:0}.policy ul{padding-left:22px}.policy-contact{padding:20px 22px;background:var(--subtle);border-radius:10px}.policy-contact h3{margin:0 0 12px}.policy-contact dl{margin:0}.policy-contact dl div{display:flex;gap:18px;padding:5px 0}.policy-contact dt{width:58px;color:var(--muted);font-weight:700}.policy-contact dd{margin:0}.policy-effective{padding:16px 18px;background:var(--subtle);border-radius:9px}.consent-notice{margin:4px 0 14px;padding:18px;border:1px solid var(--border);border-radius:9px;background:#fff}.consent-notice h3{margin:0 0 12px;font-size:15px}.consent-notice dl{margin:0}.consent-notice dl div{display:grid;grid-template-columns:86px 1fr;gap:12px;padding:7px 0;border-bottom:1px solid var(--border)}.consent-notice dl div:last-child{border:0}.consent-notice dt{color:var(--muted);font-size:12px;font-weight:700}.consent-notice dd{margin:0;color:var(--secondary);font-size:12px;line-height:1.6}.consent-notice>p{margin:12px 0 0;color:var(--muted);font-size:12px;line-height:1.6} .policy{width:min(100% - 48px,940px)}.policy-heading>span{color:var(--primary);font-size:13px;font-weight:800;letter-spacing:.09em}.policy-heading h1{margin:10px 0 0}.policy h2{margin-top:48px;font-size:22px}.policy h3{font-size:16px}.policy li{line-height:1.8;color:var(--secondary)}.policy a{color:var(--primary)}.policy-index{margin-top:32px;padding:22px 26px;background:#fff;border:1px solid var(--border);border-radius:var(--radius)}.policy-index ol{margin:0;padding-left:22px;columns:2;column-gap:36px}.policy-index li{break-inside:avoid;margin:5px 0}.policy-table-wrap{margin-top:18px;overflow-x:auto;border:1px solid var(--border);border-radius:10px}.policy table{width:100%;min-width:760px;border-collapse:collapse;background:#fff}.policy th,.policy td{padding:14px 16px;border-right:1px solid var(--border);border-bottom:1px solid var(--border);text-align:left;vertical-align:top;font-size:14px;line-height:1.7}.policy th{background:#f8fafc}.policy tr:last-child th,.policy tr:last-child td{border-bottom:0}.policy th:last-child,.policy td:last-child{border-right:0}.policy ul{padding-left:22px}.policy-contact{padding:20px 22px;background:var(--subtle);border-radius:10px}.policy-contact h3{margin:0 0 12px}.policy-contact dl{margin:0}.policy-contact dl div{display:flex;gap:18px;padding:5px 0}.policy-contact dt{width:58px;color:var(--muted);font-weight:700}.policy-contact dd{margin:0}.policy-effective{padding:16px 18px;background:var(--subtle);border-radius:9px}.consent-notice{margin:4px 0 14px;padding:18px;border:1px solid var(--border);border-radius:9px;background:#fff}.consent-notice h3{margin:0 0 12px;font-size:15px}.consent-notice dl{margin:0}.consent-notice dl div{display:grid;grid-template-columns:86px 1fr;gap:12px;padding:7px 0;border-bottom:1px solid var(--border)}.consent-notice dl div:last-child{border:0}.consent-notice dt{color:var(--muted);font-size:12px;font-weight:700}.consent-notice dd{margin:0;color:var(--secondary);font-size:12px;line-height:1.6}.consent-notice>p{margin:12px 0 0;color:var(--muted);font-size:12px;line-height:1.6}
@media(max-width:640px){.policy{width:min(100% - 32px,940px)}.policy-index ol{columns:1}.policy h1{font-size:32px}.policy h2{font-size:20px}.consent-notice dl div{grid-template-columns:1fr;gap:2px}} @media(max-width:640px){.policy{width:min(100% - 32px,940px)}.policy-index ol{columns:1}.policy h1{font-size:32px}.policy h2{font-size:20px}.consent-notice dl div{grid-template-columns:1fr;gap:2px}}
.service-icon{--service-icon-color:var(--primary);display:grid;place-items:center}.service-icon svg{width:26px;height:26px;fill:none;stroke:var(--service-icon-color);stroke-width:1.9;stroke-linecap:round;stroke-linejoin:round}.service-icon.type-keyphone{--service-icon-color:#7c3aed;background:#f5f3ff}.service-icon.type-cctv{--service-icon-color:#059669;background:#ecfdf5}.service-icon.type-maintenance{--service-icon-color:#ea580c;background:#fff7ed} .service-icon{--service-icon-color:var(--primary);display:grid;place-items:center}.service-icon svg{width:26px;height:26px;fill:none;stroke:var(--service-icon-color);stroke-width:1.9;stroke-linecap:round;stroke-linejoin:round}.service-icon.type-keyphone{--service-icon-color:#7c3aed;background:#f5f3ff}.service-icon.type-cctv{--service-icon-color:#059669;background:#ecfdf5}.service-icon.type-maintenance{--service-icon-color:#ea580c;background:#fff7ed}
.floating-call{z-index:200}
.footer-bottom{display:flex;align-items:center;justify-content:space-between;gap:16px}.footer-bottom button{padding:4px 0;border:0;background:none;color:var(--secondary);text-decoration:underline;text-underline-offset:3px;cursor:pointer}.cookie-consent{position:fixed;left:24px;right:24px;bottom:24px;z-index:300;width:min(calc(100% - 48px),760px);margin:auto;padding:20px;display:flex;align-items:center;justify-content:space-between;gap:24px;border:1px solid #dbeafe;border-radius:14px;background:#fff;box-shadow:0 18px 50px rgba(15,23,42,.2)}.cookie-consent[hidden]{display:none}.cookie-consent strong{display:block;font-size:16px}.cookie-consent p{margin:6px 0 0;color:var(--secondary);font-size:13px;line-height:1.65}.cookie-consent p a{color:var(--primary);font-weight:700}.cookie-consent-actions{display:flex;gap:8px;flex:none}.cookie-consent-actions .button{min-width:72px}@media(max-width:640px){.footer-bottom{align-items:flex-start;flex-direction:column}.cookie-consent{left:16px;right:16px;bottom:16px;width:calc(100% - 32px);padding:18px;align-items:stretch;flex-direction:column;gap:16px}.cookie-consent-actions{display:grid;grid-template-columns:1fr 1fr}.cookie-consent-actions .button{width:100%}}

338
public/assets/js/admin.js Normal file
View File

@@ -0,0 +1,338 @@
(() => {
const gallery = document.querySelector('[data-media-upload="gallery"]');
if (gallery) {
const fileInput = gallery.querySelector('[data-media-files]');
const list = gallery.querySelector('[data-media-list]');
fileInput?.addEventListener('change', () => {
list.replaceChildren();
const title = document.querySelector('input[name="title"]')?.value.trim() || '게시글';
[...fileInput.files].forEach((file, index) => {
const item = document.createElement('article');
item.className = 'admin-new-media-item';
const preview = document.createElement('img');
preview.src = URL.createObjectURL(file);
preview.alt = '';
const name = document.createElement('strong');
name.textContent = file.name;
const label = document.createElement('label');
label.textContent = '대체 텍스트';
const alt = document.createElement('input');
alt.name = 'new_gallery_alts[]';
alt.value = `${title} - 대표 이미지 ${index + 1}`;
alt.maxLength = 255;
alt.required = true;
label.append(alt);
item.append(preview, name, label);
list.append(item);
});
});
}
document.querySelectorAll('.admin-media-delete input[type="checkbox"]').forEach((checkbox) => {
checkbox.addEventListener('change', () => {
const item = checkbox.closest('.admin-media-item');
const alt = item?.querySelector('input:not([type]), input[type="text"]');
item?.classList.toggle('is-delete', checkbox.checked);
if (alt) alt.required = !checkbox.checked;
});
});
})();
(() => {
const editor = document.querySelector('[data-block-editor]');
const sourceWrap = document.querySelector('[data-body-source]');
const source = document.querySelector('[data-post-body]');
if (!editor || !sourceWrap || !source) return;
const list = editor.querySelector('[data-block-list]');
const deleteBin = editor.querySelector('[data-delete-bin]');
const inventory = new Map();
editor.querySelectorAll('[data-existing-inline]').forEach((item) => {
inventory.set(Number(item.dataset.token), {
id: Number(item.dataset.id),
token: Number(item.dataset.token),
path: item.dataset.path,
alt: item.dataset.alt,
});
});
let nextToken = Number(editor.dataset.nextToken || 1);
const button = (label, action, className = '') => {
const element = document.createElement('button');
element.type = 'button';
element.textContent = label;
element.dataset.blockAction = action;
if (className) element.className = className;
return element;
};
const resizeText = (textarea) => {
textarea.style.height = 'auto';
textarea.style.height = `${Math.max(112, textarea.scrollHeight)}px`;
};
const updateControls = () => {
const blocks = [...list.children];
blocks.forEach((block, index) => {
block.querySelector('[data-block-action="up"]').disabled = index === 0;
block.querySelector('[data-block-action="down"]').disabled = index === blocks.length - 1;
});
};
const bindBlockActions = (block) => {
block.querySelector('[data-block-action="up"]').addEventListener('click', () => {
const previous = block.previousElementSibling;
if (previous) list.insertBefore(block, previous);
updateControls();
});
block.querySelector('[data-block-action="down"]').addEventListener('click', () => {
const next = block.nextElementSibling;
if (next) list.insertBefore(next, block);
updateControls();
});
block.querySelector('[data-block-action="remove"]').addEventListener('click', () => {
if (block.dataset.existingId) {
const deleted = document.createElement('input');
deleted.type = 'hidden';
deleted.name = 'delete_images[]';
deleted.value = block.dataset.existingId;
deleteBin.append(deleted);
}
block.remove();
if (!list.children.length) addTextBlock('');
updateControls();
});
};
const createShell = (type, title) => {
const block = document.createElement('article');
block.className = `admin-content-block admin-content-block-${type}`;
block.dataset.blockType = type;
const header = document.createElement('header');
const heading = document.createElement('strong');
heading.textContent = title;
const actions = document.createElement('div');
actions.className = 'admin-content-block-actions';
actions.append(
button('위로', 'up'),
button('아래로', 'down'),
button('삭제', 'remove', 'is-danger'),
);
header.append(heading, actions);
block.append(header);
bindBlockActions(block);
return block;
};
const appendInsertControls = (block) => {
const controls = document.createElement('div');
controls.className = 'admin-content-insert-actions';
const addText = document.createElement('button');
addText.type = 'button';
addText.textContent = '아래에 문단 추가';
addText.addEventListener('click', () => {
const added = addTextBlock('', block.nextElementSibling);
added.querySelector('textarea').focus();
});
const addImage = document.createElement('button');
addImage.type = 'button';
addImage.textContent = '아래에 이미지 추가';
addImage.addEventListener('click', () => chooseImage(block.nextElementSibling));
controls.append(addText, addImage);
block.append(controls);
};
const addTextBlock = (value, before = null) => {
const block = createShell('text', '텍스트 문단');
const textarea = document.createElement('textarea');
textarea.rows = 4;
textarea.value = value;
textarea.placeholder = '본문 내용을 입력하세요.';
textarea.setAttribute('aria-label', '본문 텍스트 문단');
textarea.addEventListener('input', () => resizeText(textarea));
block.append(textarea);
appendInsertControls(block);
list.insertBefore(block, before);
requestAnimationFrame(() => resizeText(textarea));
updateControls();
return block;
};
const addImageBlock = ({ id = null, token, path, alt, fileInput = null }, before = null) => {
const block = createShell('image', id ? '등록된 본문 이미지' : '새 본문 이미지');
block.dataset.token = String(token);
if (id) block.dataset.existingId = String(id);
const preview = document.createElement('img');
preview.src = path;
preview.alt = '';
const fields = document.createElement('div');
fields.className = 'admin-content-image-fields';
const label = document.createElement('label');
label.textContent = '대체 텍스트';
const altInput = document.createElement('input');
altInput.name = id ? `media_alt[${id}]` : 'new_inline_alts[]';
altInput.value = alt;
altInput.maxLength = 255;
altInput.required = true;
label.append(altInput);
fields.append(label);
if (fileInput) {
fileInput.name = 'inline_images[]';
fileInput.className = 'admin-hidden-file';
const tokenInput = document.createElement('input');
tokenInput.type = 'hidden';
tokenInput.name = 'new_inline_tokens[]';
tokenInput.value = String(token);
fields.append(fileInput, tokenInput);
}
block.append(preview, fields);
appendInsertControls(block);
list.insertBefore(block, before);
updateControls();
return block;
};
const imageCount = () => list.querySelectorAll('[data-block-type="image"]').length;
const chooseImage = (before = null) => {
if (imageCount() >= 10) {
window.alert('본문 이미지는 최대 10장까지 등록할 수 있습니다.');
return;
}
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/jpeg,image/png,image/webp';
input.className = 'admin-hidden-file';
input.addEventListener('change', () => {
const file = input.files?.[0];
if (!file) return;
const title = document.querySelector('input[name="title"]')?.value.trim() || '게시글';
addImageBlock({
token: nextToken++,
path: URL.createObjectURL(file),
alt: `${title} - 본문 이미지`,
fileInput: input,
}, before?.isConnected ? before : null);
}, { once: true });
deleteBin.append(input);
input.click();
};
const usedTokens = new Set();
const initialBody = source.value.trim();
const parts = initialBody ? initialBody.split(/\r?\n(?:[\t ]*\r?\n)+/) : [];
parts.forEach((part) => {
const match = part.trim().match(/^\[\[image:(\d+)\]\]$/);
const media = match ? inventory.get(Number(match[1])) : null;
if (media) {
addImageBlock(media);
usedTokens.add(media.token);
} else {
addTextBlock(part);
}
});
inventory.forEach((media) => {
if (!usedTokens.has(media.token)) addImageBlock(media);
});
if (!list.children.length) addTextBlock('');
editor.querySelector('[data-add-text]').addEventListener('click', () => {
const block = addTextBlock('');
block.querySelector('textarea').focus();
});
editor.querySelector('[data-add-image]').addEventListener('click', () => chooseImage());
source.required = false;
sourceWrap.classList.add('is-enhanced');
editor.classList.add('is-ready');
source.closest('form').addEventListener('submit', () => {
source.value = [...list.children].map((block) => {
if (block.dataset.blockType === 'image') return `[[image:${block.dataset.token}]]`;
return block.querySelector('textarea').value.trim();
}).filter(Boolean).join('\n\n');
});
const previewDialog = document.querySelector('[data-live-preview]');
const openPreview = document.querySelector('[data-open-live-preview]');
const closePreview = previewDialog?.querySelector('[data-close-live-preview]');
const renderPreview = () => {
const previewTitle = previewDialog.querySelector('[data-preview-title]');
const previewCategory = previewDialog.querySelector('[data-preview-category]');
const previewAuthor = previewDialog.querySelector('[data-preview-author]');
const previewBody = previewDialog.querySelector('[data-preview-body]');
const previewGallery = previewDialog.querySelector('[data-preview-gallery]');
const galleryMain = previewDialog.querySelector('[data-preview-gallery-main]');
const galleryThumbs = previewDialog.querySelector('[data-preview-gallery-thumbs]');
const galleryCount = previewDialog.querySelector('[data-preview-gallery-count]');
previewTitle.textContent = document.querySelector('input[name="title"]')?.value.trim() || '제목 없는 글';
previewCategory.textContent = document.querySelector('select[name="category"]')?.value || '';
previewAuthor.textContent = `${document.querySelector('input[name="author"]')?.value.trim() || ''} 기술팀`;
previewBody.replaceChildren();
[...list.children].forEach((block) => {
if (block.dataset.blockType === 'image') {
const figure = document.createElement('figure');
const image = document.createElement('img');
image.src = block.querySelector('img').src;
image.alt = block.querySelector('.admin-content-image-fields input')?.value.trim() || '';
figure.append(image);
previewBody.append(figure);
return;
}
const value = block.querySelector('textarea').value.trim();
if (!value) return;
const paragraph = document.createElement('p');
paragraph.textContent = value;
previewBody.append(paragraph);
});
if (!previewBody.children.length) {
const empty = document.createElement('p');
empty.className = 'is-empty';
empty.textContent = '본문 내용을 입력하면 여기에 표시됩니다.';
previewBody.append(empty);
}
const galleryItems = [
...document.querySelectorAll('[data-media-upload="gallery"] .admin-media-item:not(.is-delete), [data-media-upload="gallery"] .admin-new-media-item'),
].map((item) => ({
src: item.querySelector('img')?.src || '',
alt: item.querySelector('input:not([type="checkbox"])')?.value.trim() || '',
})).filter((item) => item.src);
galleryMain.replaceChildren();
galleryThumbs.replaceChildren();
previewGallery.hidden = galleryItems.length === 0;
if (galleryItems.length) {
const mainImage = document.createElement('img');
mainImage.src = galleryItems[0].src;
mainImage.alt = galleryItems[0].alt;
galleryMain.append(mainImage);
galleryItems.forEach((item, index) => {
const thumb = document.createElement('button');
thumb.type = 'button';
thumb.setAttribute('aria-label', `${index + 1}번째 대표 이미지 보기`);
if (index === 0) thumb.classList.add('is-active');
const image = document.createElement('img');
image.src = item.src;
image.alt = '';
thumb.append(image);
thumb.addEventListener('click', () => {
mainImage.src = item.src;
mainImage.alt = item.alt;
[...galleryThumbs.children].forEach((child) => child.classList.toggle('is-active', child === thumb));
});
galleryThumbs.append(thumb);
});
galleryCount.textContent = `대표 이미지 ${galleryItems.length}`;
}
};
openPreview?.addEventListener('click', () => {
renderPreview();
previewDialog.showModal();
});
closePreview?.addEventListener('click', () => previewDialog.close());
previewDialog?.addEventListener('click', (event) => {
if (event.target === previewDialog) previewDialog.close();
});
})();

View File

@@ -1,3 +1,115 @@
(() => {
const root = document.querySelector('[data-analytics-consent]');
if (!root) return;
const banner = document.querySelector('[data-cookie-consent]');
const settingsButton = document.querySelector('[data-cookie-settings]');
const acceptButton = banner?.querySelector('[data-cookie-accept]');
const declineButton = banner?.querySelector('[data-cookie-decline]');
const enabled = root.dataset.analyticsEnabled === 'true';
const measurementId = root.dataset.analyticsMeasurementId || '';
const consentKey = root.dataset.analyticsConsentKey || 'gtsit_analytics_consent_v1';
let analyticsLoaded = false;
const readConsent = () => {
try {
return window.localStorage.getItem(consentKey);
} catch {
return null;
}
};
const saveConsent = (value) => {
try {
window.localStorage.setItem(consentKey, value);
} catch {
// 저장소를 사용할 수 없는 경우 현재 페이지만 선택을 유지합니다.
}
};
const setGoogleConsent = (analyticsStorage) => {
window.dataLayer = window.dataLayer || [];
window.gtag = window.gtag || function gtag() {
window.dataLayer.push(arguments);
};
window.gtag('consent', 'update', {
analytics_storage: analyticsStorage,
ad_storage: 'denied',
ad_user_data: 'denied',
ad_personalization: 'denied',
});
};
const loadAnalytics = () => {
if (!enabled || !measurementId || analyticsLoaded) return;
analyticsLoaded = true;
window.dataLayer = window.dataLayer || [];
window.gtag = window.gtag || function gtag() {
window.dataLayer.push(arguments);
};
window.gtag('consent', 'default', {
analytics_storage: 'denied',
ad_storage: 'denied',
ad_user_data: 'denied',
ad_personalization: 'denied',
});
window.gtag('consent', 'update', {
analytics_storage: 'granted',
ad_storage: 'denied',
ad_user_data: 'denied',
ad_personalization: 'denied',
});
window.gtag('js', new Date());
window.gtag('config', measurementId, {
allow_google_signals: false,
allow_ad_personalization_signals: false,
});
const script = document.createElement('script');
script.async = true;
script.src = `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(measurementId)}`;
document.head.append(script);
};
const clearAnalyticsCookies = () => {
const names = document.cookie.split(';').map((cookie) => cookie.split('=')[0].trim()).filter((name) => name.startsWith('_ga'));
names.forEach((name) => {
document.cookie = `${name}=; Max-Age=0; path=/; SameSite=Lax`;
document.cookie = `${name}=; Max-Age=0; path=/; domain=.gtsit.co.kr; SameSite=Lax`;
});
};
const hideBanner = () => {
if (banner) banner.hidden = true;
};
const showBanner = () => {
if (!banner) return;
banner.hidden = false;
acceptButton?.focus();
};
acceptButton?.addEventListener('click', () => {
saveConsent('granted');
hideBanner();
if (analyticsLoaded) setGoogleConsent('granted');
else loadAnalytics();
});
declineButton?.addEventListener('click', () => {
saveConsent('denied');
setGoogleConsent('denied');
clearAnalyticsCookies();
hideBanner();
});
settingsButton?.addEventListener('click', showBanner);
const consent = readConsent();
if (consent === 'granted') loadAnalytics();
else if (consent !== 'denied') showBanner();
})();
(() => { (() => {
const toggle = document.querySelector('.menu-toggle'); const toggle = document.querySelector('.menu-toggle');
const navigation = document.querySelector('.site-navigation'); const navigation = document.querySelector('.site-navigation');
@@ -212,6 +324,82 @@
else returnButton.hidden = false; else returnButton.hidden = false;
})(); })();
(() => {
const slider = document.querySelector('[data-article-slider]');
if (!slider) return;
const slides = [...slider.querySelectorAll('.article-gallery-slide')];
const dots = [...slider.querySelectorAll('[data-article-dot]')];
const previous = slider.querySelector('[data-article-prev]');
const next = slider.querySelector('[data-article-next]');
const pause = slider.querySelector('[data-article-pause]');
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
let current = 0;
let timer = null;
let isPaused = false;
let isInteracting = false;
const show = (index) => {
current = (index + slides.length) % slides.length;
slides.forEach((slide, slideIndex) => {
const active = slideIndex === current;
slide.classList.toggle('is-active', active);
slide.setAttribute('aria-hidden', String(!active));
});
dots.forEach((dot, dotIndex) => {
const active = dotIndex === current;
dot.classList.toggle('is-active', active);
if (active) dot.setAttribute('aria-current', 'true');
else dot.removeAttribute('aria-current');
});
};
const stop = () => {
if (timer !== null) window.clearInterval(timer);
timer = null;
};
const start = () => {
stop();
if (isPaused || isInteracting || reducedMotion.matches || document.hidden) return;
timer = window.setInterval(() => show(current + 1), 5000);
};
const move = (direction) => {
show(current + direction);
start();
};
previous?.addEventListener('click', () => move(-1));
next?.addEventListener('click', () => move(1));
dots.forEach((dot) => dot.addEventListener('click', () => {
show(Number(dot.dataset.articleDot));
start();
}));
pause?.addEventListener('click', () => {
isPaused = !isPaused;
pause.setAttribute('aria-pressed', String(isPaused));
pause.textContent = isPaused ? '자동재생 시작' : '자동재생 일시정지';
start();
});
slider.addEventListener('mouseenter', () => {
isInteracting = true;
stop();
});
slider.addEventListener('mouseleave', () => {
isInteracting = false;
start();
});
slider.addEventListener('focusin', () => {
isInteracting = true;
stop();
});
slider.addEventListener('focusout', () => {
isInteracting = false;
start();
});
document.addEventListener('visibilitychange', start);
reducedMotion.addEventListener('change', start);
start();
})();
(() => { (() => {
const mapFrame = document.querySelector('[data-naver-map]'); const mapFrame = document.querySelector('[data-naver-map]');
if (!mapFrame) return; if (!mapFrame) return;
@@ -219,6 +407,9 @@
const mapElement = mapFrame.querySelector('.naver-map'); const mapElement = mapFrame.querySelector('.naver-map');
const statusElement = mapFrame.querySelector('[data-naver-map-status]'); const statusElement = mapFrame.querySelector('[data-naver-map-status]');
const fullscreenButton = mapFrame.querySelector('[data-naver-map-fullscreen]'); const fullscreenButton = mapFrame.querySelector('[data-naver-map-fullscreen]');
const zoomControl = mapFrame.querySelector('[data-naver-map-zoom]');
const zoomInButton = mapFrame.querySelector('[data-naver-map-zoom-in]');
const zoomOutButton = mapFrame.querySelector('[data-naver-map-zoom-out]');
const externalLink = mapFrame.parentElement?.querySelector('[data-naver-map-external]'); const externalLink = mapFrame.parentElement?.querySelector('[data-naver-map-external]');
const clientId = mapFrame.dataset.clientId; const clientId = mapFrame.dataset.clientId;
const latitude = Number(mapFrame.dataset.latitude); const latitude = Number(mapFrame.dataset.latitude);
@@ -231,6 +422,7 @@
mapFrame.dataset.mapState = state; mapFrame.dataset.mapState = state;
mapFrame.setAttribute('aria-busy', String(state === 'loading')); mapFrame.setAttribute('aria-busy', String(state === 'loading'));
if (fullscreenButton) fullscreenButton.hidden = state !== 'ready'; if (fullscreenButton) fullscreenButton.hidden = state !== 'ready';
if (zoomControl) zoomControl.hidden = state !== 'ready';
if (statusElement && message) statusElement.textContent = message; if (statusElement && message) statusElement.textContent = message;
}; };
@@ -249,10 +441,15 @@
center: position, center: position,
zoom: 17, zoom: 17,
minZoom: 10, minZoom: 10,
zoomControl: true, maxZoom: 21,
zoomControlOptions: { draggable: false,
position: window.naver.maps.Position.RIGHT_CENTER, keyboardShortcuts: false,
}, scrollWheel: false,
pinchZoom: false,
disableDoubleClickZoom: true,
disableDoubleTapZoom: true,
disableTwoFingerTapZoom: true,
zoomControl: false,
}); });
new window.naver.maps.Marker({ new window.naver.maps.Marker({
@@ -261,6 +458,22 @@
title: label, title: label,
}); });
const updateZoomButtons = () => {
const zoom = map.getZoom();
if (zoomInButton) zoomInButton.disabled = zoom >= 21;
if (zoomOutButton) zoomOutButton.disabled = zoom <= 10;
};
const setFixedZoom = (difference) => {
const nextZoom = Math.min(21, Math.max(10, map.getZoom() + difference));
map.setCenter(position);
map.setZoom(nextZoom, false);
map.setCenter(position);
updateZoomButtons();
};
zoomInButton?.addEventListener('click', () => setFixedZoom(1));
zoomOutButton?.addEventListener('click', () => setFixedZoom(-1));
updateZoomButtons();
const refreshMapSize = () => { const refreshMapSize = () => {
window.setTimeout(() => { window.setTimeout(() => {
window.naver.maps.Event.trigger(map, 'resize'); window.naver.maps.Event.trigger(map, 'resize');

View File

@@ -10,9 +10,11 @@ use App\Repositories\InquiryRepository;
use App\Repositories\AdminRepository; use App\Repositories\AdminRepository;
use App\Auth\AdminAuth; use App\Auth\AdminAuth;
use App\Repositories\SiteSettingRepository; use App\Repositories\SiteSettingRepository;
use App\Services\AnalyticsService;
$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/'; $path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
$basePath = (string) config('app.base_path', ''); $basePath = (string) config('app.base_path', '');
$mediaExperiment = $basePath !== '';
if ($basePath !== '') { if ($basePath !== '') {
if ($path !== $basePath && !str_starts_with($path, $basePath . '/')) { if ($path !== $basePath && !str_starts_with($path, $basePath . '/')) {
http_response_code(404); http_response_code(404);
@@ -68,7 +70,12 @@ if ($path === '/blog' && $method === 'GET') {
} }
if (preg_match('#^/blog/([a-z0-9-]+)$#', $path, $matches) === 1 && $method === 'GET') { if (preg_match('#^/blog/([a-z0-9-]+)$#', $path, $matches) === 1 && $method === 'GET') {
$post = $postRepository?->findPublishedBySlug($matches[1]); $requestedSlug = $matches[1];
$slugRedirects = config('post_slug_redirects', []);
if (is_array($slugRedirects) && isset($slugRedirects[$requestedSlug])) {
redirect('/blog/' . $slugRedirects[$requestedSlug], 301);
}
$post = $postRepository?->findPublishedBySlug($requestedSlug);
if ($post === null) { if ($post === null) {
http_response_code(404); http_response_code(404);
render('errors/404', ['title' => '게시글을 찾을 수 없습니다']); render('errors/404', ['title' => '게시글을 찾을 수 없습니다']);
@@ -346,20 +353,48 @@ if (str_starts_with($path, '/admin')) {
} }
if ($path === '/admin' && $method === 'GET') { if ($path === '/admin' && $method === 'GET') {
render('admin/dashboard', ['title'=>'대시보드','counts'=>$adminRepository->dashboardCounts()], 'layouts/admin'); $analytics = (new AnalyticsService())->dashboard();
render('admin/dashboard', ['title'=>'대시보드','counts'=>$adminRepository->dashboardCounts(),'analytics'=>$analytics], 'layouts/admin');
exit; exit;
} }
if ($path === '/admin/posts' && $method === 'GET') { if ($path === '/admin/posts' && $method === 'GET') {
render('admin/posts/index', ['title'=>'글 관리','posts'=>$adminRepository->posts()], 'layouts/admin'); render('admin/posts/index', ['title'=>'글 관리','posts'=>$adminRepository->posts()], 'layouts/admin');
exit; exit;
} }
if (preg_match('#^/admin/posts/(\d+)/preview$#', $path, $adminPreviewMatch) === 1 && $method === 'GET') {
$post = $adminRepository->findPost((int) $adminPreviewMatch[1]);
if (!$post) {
http_response_code(404);
render('errors/404', ['title'=>'게시글을 찾을 수 없습니다'], 'layouts/admin');
exit;
}
render('blog/show', [
'title'=>'미리보기 · ' . $post['title'],
'description'=>$post['excerpt'] ?: '관리자 게시글 미리보기',
'post'=>$post,
'preview'=>true,
'mediaExperiment'=>$mediaExperiment,
'noindex'=>true,
'canonicalUrl'=>rtrim((string) config('app.base_url'), '/') . '/blog/' . $post['slug'],
]);
exit;
}
if ($path === '/admin/posts/new' && $method === 'GET') { if ($path === '/admin/posts/new' && $method === 'GET') {
render('admin/posts/form', ['title'=>'새 글 작성','post'=>null,'errors'=>[]], 'layouts/admin'); render('admin/posts/form', ['title'=>'새 글 작성','post'=>null,'errors'=>[],'mediaExperiment'=>$mediaExperiment], 'layouts/admin');
exit; exit;
} }
if ($path === '/admin/posts/new' && $method === 'POST') { if ($path === '/admin/posts/new' && $method === 'POST') {
verify_csrf(); verify_csrf();
$postData = collect_post_input(); $postData = collect_post_input();
if ($mediaExperiment) {
$result = save_media_post_request($adminRepository, $postData, null, null);
if ($result['errors']) {
http_response_code(422);
render('admin/posts/form', ['title'=>'새 글 작성','post'=>null,'errors'=>$result['errors'],'mediaExperiment'=>true], 'layouts/admin');
exit;
}
redirect('/admin/posts/' . $result['id'] . '/preview');
}
$errors = validate_post_input($postData, $adminRepository); $errors = validate_post_input($postData, $adminRepository);
[$image, $imageError] = prepare_post_image($_FILES['image'] ?? null); [$image, $imageError] = prepare_post_image($_FILES['image'] ?? null);
if ($imageError !== null) $errors[] = $imageError; if ($imageError !== null) $errors[] = $imageError;
@@ -390,12 +425,22 @@ if (str_starts_with($path, '/admin')) {
exit; exit;
} }
if ($method === 'GET') { if ($method === 'GET') {
render('admin/posts/form', ['title'=>'글 수정','post'=>$post,'errors'=>[]], 'layouts/admin'); render('admin/posts/form', ['title'=>'글 수정','post'=>$post,'errors'=>[],'mediaExperiment'=>$mediaExperiment], 'layouts/admin');
exit; exit;
} }
if ($method === 'POST') { if ($method === 'POST') {
verify_csrf(); verify_csrf();
$postData = collect_post_input(); $postData = collect_post_input();
if ($mediaExperiment) {
$result = save_media_post_request($adminRepository, $postData, $post, $postId);
if ($result['errors']) {
$post = $adminRepository->findPost($postId) ?? $post;
http_response_code(422);
render('admin/posts/form', ['title'=>'글 수정','post'=>$post,'errors'=>$result['errors'],'mediaExperiment'=>true], 'layouts/admin');
exit;
}
redirect('/admin/posts/' . $postId . '/preview');
}
$errors = validate_post_input($postData, $adminRepository, $postId); $errors = validate_post_input($postData, $adminRepository, $postId);
[$image, $imageError] = prepare_post_image($_FILES['image'] ?? null); [$image, $imageError] = prepare_post_image($_FILES['image'] ?? null);
if ($imageError !== null) $errors[] = $imageError; if ($imageError !== null) $errors[] = $imageError;
@@ -515,8 +560,6 @@ function validate_post_input(array $data, AdminRepository $repository, ?int $exc
{ {
$errors = []; $errors = [];
if ($data['title']==='' || mb_strlen($data['title'])>200) $errors[]='제목을 200자 이내로 입력해 주세요.'; if ($data['title']==='' || mb_strlen($data['title'])>200) $errors[]='제목을 200자 이내로 입력해 주세요.';
if (!preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/', $data['slug'])) $errors[]='slug는 영문 소문자, 숫자, 하이픈으로 입력해 주세요.';
elseif ($repository->slugExists($data['slug'], $exceptId)) $errors[]='이미 사용 중인 slug입니다.';
if (!in_array($data['category'], ['네트워크','키폰시스템','CCTV','시공사례'], true)) $errors[]='카테고리를 확인해 주세요.'; if (!in_array($data['category'], ['네트워크','키폰시스템','CCTV','시공사례'], true)) $errors[]='카테고리를 확인해 주세요.';
if (mb_strlen($data['excerpt'])>500) $errors[]='목록 요약을 500자 이내로 입력해 주세요.'; if (mb_strlen($data['excerpt'])>500) $errors[]='목록 요약을 500자 이내로 입력해 주세요.';
if ($data['body']==='') $errors[]='본문을 입력해 주세요.'; if ($data['body']==='') $errors[]='본문을 입력해 주세요.';
@@ -526,14 +569,140 @@ function validate_post_input(array $data, AdminRepository $repository, ?int $exc
return $errors; return $errors;
} }
function prepare_post_image(?array $file): array function save_media_post_request(AdminRepository $repository, array $postData, ?array $post, ?int $postId): array
{
$deleteIds = array_values(array_unique(array_filter(array_map('intval', (array) ($_POST['delete_images'] ?? [])))));
$existingGallery = $post['gallery_images'] ?? [];
$existingInline = $post['inline_images'] ?? [];
$existingById = [];
foreach ([...$existingGallery, ...$existingInline] as $image) {
$existingById[(int) $image['id']] = $image;
}
$deleteIds = array_values(array_filter($deleteIds, static fn (int $id): bool => isset($existingById[$id])));
foreach ($existingInline as $image) {
if (in_array((int) $image['id'], $deleteIds, true)) {
$postData['body'] = preg_replace(
'/(?:\R{2,})?\[\[image:' . preg_quote((string) $image['token'], '/') . '\]\](?:\R{2,})?/',
"\n\n",
$postData['body']
) ?? $postData['body'];
}
}
$postData['body'] = trim($postData['body']);
$_POST['body'] = $postData['body'];
$errors = validate_post_input($postData, $repository, $postId);
[$galleryUploads, $galleryErrors] = prepare_post_images($_FILES['gallery_images'] ?? null, '대표 이미지');
[$inlineUploads, $inlineErrors] = prepare_post_images($_FILES['inline_images'] ?? null, '본문 이미지');
$errors = [...$errors, ...$galleryErrors, ...$inlineErrors];
$newGalleryAlts = array_map(static fn ($value): string => trim((string) $value), (array) ($_POST['new_gallery_alts'] ?? []));
$newInlineAlts = array_map(static fn ($value): string => trim((string) $value), (array) ($_POST['new_inline_alts'] ?? []));
$submittedInlineTokens = array_map(static fn ($value): string => trim((string) $value), (array) ($_POST['new_inline_tokens'] ?? []));
$newInlineTokens = [];
foreach ($submittedInlineTokens as $token) {
if (preg_match('/^[1-9]\d{0,2}$/D', $token) !== 1) {
$errors[] = '본문 이미지 위치 정보를 다시 확인해 주세요.';
continue;
}
$newInlineTokens[] = (int) $token;
}
if (count($newGalleryAlts) !== count($galleryUploads)) $errors[] = '선택한 대표 이미지 정보를 다시 확인해 주세요.';
if (count($newInlineAlts) !== count($inlineUploads)) $errors[] = '선택한 본문 이미지 정보를 다시 확인해 주세요.';
if (count($newInlineTokens) !== count($inlineUploads)) $errors[] = '선택한 본문 이미지의 위치 정보를 다시 확인해 주세요.';
foreach ($newGalleryAlts as $alt) if ($alt === '' || mb_strlen($alt) > 255) $errors[] = '새 대표 이미지의 대체 텍스트를 255자 이내로 입력해 주세요.';
foreach ($newInlineAlts as $alt) if ($alt === '' || mb_strlen($alt) > 255) $errors[] = '새 본문 이미지의 대체 텍스트를 255자 이내로 입력해 주세요.';
$imageAlts = [];
foreach ((array) ($_POST['media_alt'] ?? []) as $imageId => $alt) {
$imageId = (int) $imageId;
if (!isset($existingById[$imageId]) || in_array($imageId, $deleteIds, true)) continue;
$alt = trim((string) $alt);
if ($alt === '' || mb_strlen($alt) > 255) $errors[] = '등록된 이미지의 대체 텍스트를 255자 이내로 입력해 주세요.';
$imageAlts[$imageId] = $alt;
}
$remainingGallery = count(array_filter($existingGallery, static fn (array $image): bool => !in_array((int) $image['id'], $deleteIds, true)));
$remainingInlineImages = array_values(array_filter($existingInline, static fn (array $image): bool => !in_array((int) $image['id'], $deleteIds, true)));
$remainingInline = count($remainingInlineImages);
$totalGallery = $remainingGallery + count($galleryUploads);
$totalInline = $remainingInline + count($inlineUploads);
if ($totalGallery > 6) $errors[] = '대표 이미지는 최대 6장까지 등록할 수 있습니다.';
if ($totalInline > 10) $errors[] = '본문 이미지는 최대 10장까지 등록할 수 있습니다.';
if (($totalGallery > 1 || $totalInline > 0) && $postData['status'] !== 'draft') {
$errors[] = '다중·본문 이미지 기능은 테스트 중이므로 초안으로만 저장할 수 있습니다.';
}
$activeTokens = array_map(static fn (array $image): int => (int) $image['token'], $remainingInlineImages);
foreach ($newInlineTokens as $token) {
if (in_array($token, $activeTokens, true)) $errors[] = '본문 이미지 위치 정보가 중복되었습니다.';
$activeTokens[] = $token;
}
if (count($activeTokens) !== count(array_unique($activeTokens))) $errors[] = '본문 이미지 위치 정보가 중복되었습니다.';
preg_match_all('/\[\[image:(\d+)\]\]/', $postData['body'], $bodyTokenMatches);
$bodyTokens = array_map('intval', $bodyTokenMatches[1] ?? []);
foreach ($activeTokens as $token) {
if (count(array_keys($bodyTokens, $token, true)) !== 1) $errors[] = '각 본문 이미지는 글 안에 한 번씩 배치해 주세요.';
}
foreach ($bodyTokens as $token) {
if (!in_array($token, $activeTokens, true)) $errors[] = '연결되지 않은 본문 이미지 위치가 있습니다.';
}
if ($errors !== []) return ['id' => $postId, 'errors' => array_values(array_unique($errors))];
$storedPaths = [];
$galleryImages = [];
$inlineImages = [];
try {
foreach ($galleryUploads as $index => $image) {
$path = store_post_image($image);
$storedPaths[] = $path;
$galleryImages[] = ['path' => $path, 'alt' => $newGalleryAlts[$index]];
}
foreach ($inlineUploads as $index => $image) {
$path = store_post_image($image);
$storedPaths[] = $path;
$inlineImages[] = ['path' => $path, 'alt' => $newInlineAlts[$index], 'token' => $newInlineTokens[$index]];
}
$result = $repository->savePostWithMedia($postData, $postId, $galleryImages, $inlineImages, $deleteIds, $imageAlts);
foreach ($result['old_paths'] as $oldPath) remove_stored_image((string) $oldPath);
return ['id' => $result['id'], 'errors' => []];
} catch (Throwable) {
foreach ($storedPaths as $storedPath) remove_stored_image($storedPath);
return ['id' => $postId, 'errors' => ['저장하지 못했습니다. 잠시 후 다시 시도해 주세요.']];
}
}
function prepare_post_images(?array $files, string $label): array
{
if ($files === null || !isset($files['name'], $files['error'], $files['size'], $files['tmp_name'])) return [[], []];
$names = is_array($files['name']) ? $files['name'] : [$files['name']];
$errors = is_array($files['error']) ? $files['error'] : [$files['error']];
$sizes = is_array($files['size']) ? $files['size'] : [$files['size']];
$temporaryNames = is_array($files['tmp_name']) ? $files['tmp_name'] : [$files['tmp_name']];
$prepared = [];
$messages = [];
foreach ($names as $index => $name) {
if (($errors[$index] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) continue;
[$image, $error] = prepare_post_image([
'name' => $name,
'error' => $errors[$index] ?? UPLOAD_ERR_NO_FILE,
'size' => $sizes[$index] ?? 0,
'tmp_name' => $temporaryNames[$index] ?? '',
], $label);
if ($error !== null) $messages[] = $error;
if ($image !== null) $prepared[] = $image;
}
return [$prepared, $messages];
}
function prepare_post_image(?array $file, string $label = '대표 이미지'): array
{ {
if ($file === null || ($file['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) return [null, null]; if ($file === null || ($file['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) return [null, null];
if (!isset($file['error'], $file['size'], $file['tmp_name']) || is_array($file['error']) || is_array($file['size']) || is_array($file['tmp_name'])) { if (!isset($file['error'], $file['size'], $file['tmp_name']) || is_array($file['error']) || is_array($file['size']) || is_array($file['tmp_name'])) {
return [null, '이미지 업로드 요청이 올바르지 않습니다.']; return [null, '이미지 업로드 요청이 올바르지 않습니다.'];
} }
if ((int) $file['error'] !== UPLOAD_ERR_OK) return [null, '이미지를 업로드하지 못했습니다. 파일 크기를 확인해 주세요.']; if ((int) $file['error'] !== UPLOAD_ERR_OK) return [null, '이미지를 업로드하지 못했습니다. 파일 크기를 확인해 주세요.'];
if ((int) $file['size'] <= 0 || (int) $file['size'] > 8 * 1024 * 1024) return [null, '대표 이미지는 8MB 이하만 등록할 수 있습니다.']; if ((int) $file['size'] <= 0 || (int) $file['size'] > 8 * 1024 * 1024) return [null, $label . '는 파일당 8MB 이하만 등록할 수 있습니다.'];
$tmpName = (string) $file['tmp_name']; $tmpName = (string) $file['tmp_name'];
if (!is_uploaded_file($tmpName)) return [null, '업로드된 이미지 파일을 확인할 수 없습니다.']; if (!is_uploaded_file($tmpName)) return [null, '업로드된 이미지 파일을 확인할 수 없습니다.'];

View File

@@ -4,10 +4,10 @@
<url><loc>https://gtsit.co.kr/blog</loc></url> <url><loc>https://gtsit.co.kr/blog</loc></url>
<url><loc>https://gtsit.co.kr/contact</loc></url> <url><loc>https://gtsit.co.kr/contact</loc></url>
<url><loc>https://gtsit.co.kr/privacy</loc></url> <url><loc>https://gtsit.co.kr/privacy</loc></url>
<url><loc>https://gtsit.co.kr/blog/office-lan-structure-cabling-guide</loc></url> <url><loc>https://gtsit.co.kr/blog/post-1</loc></url>
<url><loc>https://gtsit.co.kr/blog/outdoor-wireless-link-installation-guide</loc></url> <url><loc>https://gtsit.co.kr/blog/post-10</loc></url>
<url><loc>https://gtsit.co.kr/blog/keyphone-system-capacity-selection</loc></url> <url><loc>https://gtsit.co.kr/blog/post-11</loc></url>
<url><loc>https://gtsit.co.kr/blog/cctv-recorder-essential-features</loc></url> <url><loc>https://gtsit.co.kr/blog/post-12</loc></url>
<url><loc>https://gtsit.co.kr/blog/network-rack-cabling-maintenance-case</loc></url> <url><loc>https://gtsit.co.kr/blog/post-13</loc></url>
<url><loc>https://gtsit.co.kr/blog/office-network-move-checklist</loc></url> <url><loc>https://gtsit.co.kr/blog/post-14</loc></url>
</urlset> </urlset>