운영 기능 및 GA4 대시보드 반영
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
253
app/Services/AnalyticsService.php
Normal file
253
app/Services/AnalyticsService.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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; ?>
|
||||||
|
|||||||
@@ -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; ?>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
21
config/post_slug_redirects.php
Normal file
21
config/post_slug_redirects.php
Normal 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',
|
||||||
|
];
|
||||||
71
database/seed-media-preview.php
Normal file
71
database/seed-media-preview.php
Normal 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;
|
||||||
|
}
|
||||||
@@ -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 오류 시 네이버 지도 검색 링크 제공
|
||||||
|
|||||||
@@ -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을 직접 입력하지 않습니다.
|
||||||
|
|||||||
@@ -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 이용 한도와 임계치 알림 설정
|
||||||
|
|||||||
@@ -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}}
|
||||||
|
|||||||
@@ -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:"▶"}}
|
||||||
|
|||||||
@@ -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
338
public/assets/js/admin.js
Normal 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();
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -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');
|
||||||
|
|||||||
185
public/index.php
185
public/index.php
@@ -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, '업로드된 이미지 파일을 확인할 수 없습니다.'];
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user