From b492abd3b45958afc8ba56d5230bea808843f545 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=84=B1=ED=95=84?= Date: Tue, 18 Aug 2026 10:53:58 +0900 Subject: [PATCH] =?UTF-8?q?=EC=9A=B4=EC=98=81=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EB=B0=8F=20GA4=20=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20?= =?UTF-8?q?=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Repositories/AdminRepository.php | 130 +++++++++-- app/Repositories/PostRepository.php | 34 ++- app/Services/AnalyticsService.php | 253 ++++++++++++++++++++ app/Views/admin/dashboard.php | 39 +++- app/Views/admin/posts/form.php | 56 ++++- app/Views/admin/posts/index.php | 2 +- app/Views/blog/show.php | 31 ++- app/Views/contact.php | 1 + app/Views/layouts/admin.php | 7 +- app/Views/layouts/site.php | 29 ++- app/Views/privacy.php | 38 ++- config/app.php | 9 + config/post_slug_redirects.php | 21 ++ database/seed-media-preview.php | 71 ++++++ docs/architecture.md | 18 +- docs/implementation-todo.md | 40 +++- docs/qa-list.md | 52 ++++- public/assets/css/admin.css | 5 + public/assets/css/content.css | 4 + public/assets/css/site.css | 2 + public/assets/js/admin.js | 338 +++++++++++++++++++++++++++ public/assets/js/site.js | 221 +++++++++++++++++- public/index.php | 185 ++++++++++++++- public/sitemap.xml | 12 +- 24 files changed, 1521 insertions(+), 77 deletions(-) create mode 100644 app/Services/AnalyticsService.php create mode 100644 config/post_slug_redirects.php create mode 100644 database/seed-media-preview.php create mode 100644 public/assets/js/admin.js diff --git a/app/Repositories/AdminRepository.php b/app/Repositories/AdminRepository.php index f07823d..1d30e31 100644 --- a/app/Repositories/AdminRepository.php +++ b/app/Repositories/AdminRepository.php @@ -64,13 +64,40 @@ final class AdminRepository { $statement = $this->db->prepare( 'SELECT posts.*, - (SELECT file_path FROM post_images WHERE post_id = posts.id 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 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 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' ); $statement->execute(['id' => $id]); $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 @@ -80,16 +107,20 @@ final class AdminRepository : null; if ($id === null) { + $temporarySlug = 'pending-' . bin2hex(random_bytes(12)); $statement = $this->db->prepare( 'INSERT INTO posts (title, slug, category, excerpt, body, author, status, published_at) VALUES (:title, :slug, :category, :excerpt, :body, :author, :status, :published_at)' ); $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'], '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( @@ -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' ); $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'], 'status'=>$data['status'],'published_at'=>$publishedAt,'id'=>$id, ]); @@ -138,17 +169,84 @@ final class AdminRepository } } - public function slugExists(string $slug, ?int $exceptId = null): bool - { - $sql = 'SELECT COUNT(*) FROM posts WHERE slug = :slug AND deleted_at IS NULL'; - $params = ['slug' => $slug]; - if ($exceptId !== null) { - $sql .= ' AND id <> :id'; - $params['id'] = $exceptId; + public function savePostWithMedia( + array $data, + ?int $id, + array $galleryImages, + array $inlineImages, + array $deleteImageIds, + 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 diff --git a/app/Repositories/PostRepository.php b/app/Repositories/PostRepository.php index 634bd45..21481ae 100644 --- a/app/Repositories/PostRepository.php +++ b/app/Repositories/PostRepository.php @@ -20,8 +20,8 @@ final class PostRepository { $statement = $this->db->prepare( "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 alt_text FROM post_images WHERE post_id = posts.id ORDER BY sort_order, id LIMIT 1) AS image_alt + (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 AND caption NOT LIKE '@gtsit:inline:%' ORDER BY sort_order, id LIMIT 1) AS image_alt FROM posts WHERE posts.status = 'published' AND posts.deleted_at IS NULL AND posts.published_at <= NOW() ORDER BY published_at DESC, id DESC @@ -48,8 +48,8 @@ final class PostRepository $offset = max(0, ($page - 1) * $perPage); $query = $this->db->prepare( "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 alt_text FROM post_images WHERE post_id = posts.id ORDER BY sort_order, id LIMIT 1) AS image_alt + (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 AND caption NOT LIKE '@gtsit:inline:%' ORDER BY sort_order, id LIMIT 1) AS image_alt FROM posts WHERE {$where} ORDER BY published_at DESC, id DESC LIMIT :limit OFFSET :offset" @@ -68,14 +68,34 @@ final class PostRepository { $statement = $this->db->prepare( "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 alt_text FROM post_images WHERE post_id = posts.id ORDER BY sort_order, id LIMIT 1) AS image_alt + (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 AND caption NOT LIKE '@gtsit:inline:%' ORDER BY sort_order, id LIMIT 1) AS image_alt FROM posts WHERE posts.slug = :slug AND posts.status = 'published' AND posts.deleted_at IS NULL AND posts.published_at <= NOW() LIMIT 1" ); $statement->execute(['slug' => $slug]); $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; } } diff --git a/app/Services/AnalyticsService.php b/app/Services/AnalyticsService.php new file mode 100644 index 0000000..31e7149 --- /dev/null +++ b/app/Services/AnalyticsService.php @@ -0,0 +1,253 @@ +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); + } +} diff --git a/app/Views/admin/dashboard.php b/app/Views/admin/dashboard.php index 4799b24..798cbd7 100644 --- a/app/Views/admin/dashboard.php +++ b/app/Views/admin/dashboard.php @@ -1 +1,38 @@ -
OVERVIEW

대시보드

사이트 콘텐츠와 문의 현황입니다.

전체 글공개 글전체 문의처리 대기
+ (int) ($item['views'] ?? 0), $daily ?: [['views' => 0]])); +$channelLabels = [ + 'Direct' => '직접 유입', + 'Organic Search' => '검색 유입', + 'Organic Social' => '소셜 유입', + 'Referral' => '외부 링크', + 'Unassigned' => '미분류', +]; +?> +
OVERVIEW

대시보드

사이트 콘텐츠, 문의, 방문 현황입니다.

+
전체 글공개 글전체 문의처리 대기
+ +
+
GOOGLE ANALYTICS

방문 현황

운영 사이트의 최근 30일 통계입니다. 관리자와 테스트 페이지는 제외됩니다.

기준 · 15분 간격 갱신
+ +
방문 통계를 표시할 수 없습니다.

+ +
+
최근 접속자최근 30분 내 활동
+
전체 방문자2026.08.12 측정 시작 이후
+
30일 방문자최근 30일 내 활동
+
처음 온 방문자최근 30일 첫 방문
+
페이지 열람 수같은 페이지 반복 포함
+
사이트 방문 횟수방문을 시작한 횟수
+
+
+

14일 페이지 열람 추이

열람 수
+

아직 집계된 방문 데이터가 없습니다.

+
+
+

많이 본 페이지

열람 수

아직 집계된 페이지가 없습니다.

+

방문한 경로

방문 횟수 / 방문자
  1. /

아직 집계된 유입 데이터가 없습니다.

+
+ +
diff --git a/app/Views/admin/posts/form.php b/app/Views/admin/posts/form.php index 3a0313b..bc579d8 100644 --- a/app/Views/admin/posts/form.php +++ b/app/Views/admin/posts/form.php @@ -1,23 +1,61 @@
CONTENTS

초안으로 저장하거나 공개 상태로 게시합니다.

+
테스트 기능여러 대표 이미지와 본문 이미지는 테스트 경로의 초안 미리보기에서만 확인합니다. 확인 전 운영 공개는 제한됩니다.
- +
-
- 대표 이미지 - <?= e($post['image_alt']) ?>

새 파일을 선택하면 현재 대표 이미지가 교체됩니다.

- - -
- + + +
+ 대표 이미지 슬라이드 +

최대 6장 · 2장 이상이면 글 상단에서 5초 간격으로 자동 슬라이드됩니다.

+
+ +
+
+
+
+

본문 편집

텍스트 문단과 이미지만 사용해 글의 구성을 정리합니다.

본문 이미지 최대 10장
+
+
+

이미지는 JPG, PNG, WebP 형식으로 파일당 8MB·6000×6000px까지 등록할 수 있습니다.

+
+ +
+ +
+ 대표 이미지 + <?= e($post['image_alt']) ?>

새 파일을 선택하면 현재 대표 이미지가 교체됩니다.

+ + +
+ +
-
취소
+
취소
+ + +
+
LIVE PREVIEW

작성 내용 미리보기

현재 입력 상태를 저장하지 않고 확인합니다.

+
+
+ +
+
+
+
+
글 삭제

게시글을 복구 가능한 삭제 상태로 전환합니다.

diff --git a/app/Views/admin/posts/index.php b/app/Views/admin/posts/index.php index dec7968..be054aa 100644 --- a/app/Views/admin/posts/index.php +++ b/app/Views/admin/posts/index.php @@ -1 +1 @@ -
CONTENTS

글 관리

블로그 글을 작성하고 공개 상태를 관리합니다.

새 글 작성
제목분류상태공개일관리
/blog/수정

등록된 글이 없습니다

새 글을 작성해 콘텐츠를 추가해 주세요.

+
CONTENTS

글 관리

블로그 글을 작성하고 공개 상태를 관리합니다.

새 글 작성
제목분류상태공개일관리
/blog/미리보기수정

등록된 글이 없습니다

새 글을 작성해 콘텐츠를 추가해 주세요.

diff --git a/app/Views/blog/show.php b/app/Views/blog/show.php index 3b78717..b5897a4 100644 --- a/app/Views/blog/show.php +++ b/app/Views/blog/show.php @@ -1 +1,30 @@ -
← 블로그 목록

기술팀

<?= e($post['image_alt']) ?>

+ $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'])) ?: []; +?> + +
+ +

기술팀

+ 1): ?> +
+
$image): ?>
<?= e($image['alt_text']) ?>
+
$_): ?>
+
+
<?= e($galleryImages[0]['alt_text']) ?>
+
+ + +
<?= e($image['alt_text']) ?>
+

+ +
+ +
diff --git a/app/Views/contact.php b/app/Views/contact.php index 589dd45..cbbfa23 100644 --- a/app/Views/contact.php +++ b/app/Views/contact.php @@ -29,6 +29,7 @@

diff --git a/app/Views/layouts/admin.php b/app/Views/layouts/admin.php index 0305882..67d83f8 100644 --- a/app/Views/layouts/admin.php +++ b/app/Views/layouts/admin.php @@ -6,9 +6,10 @@ <?= e($title ?? '관리자') ?> | <?= e(config('app.legal_name')) ?> 관리자 - - - + + + + diff --git a/app/Views/layouts/site.php b/app/Views/layouts/site.php index 716b701..86a697f 100644 --- a/app/Views/layouts/site.php +++ b/app/Views/layouts/site.php @@ -2,7 +2,8 @@ $pageTitle = isset($title) ? $title . ' | ' . config('app.name') : config('app.name'); $pageDescription = $description ?? '네트워크, 키폰, CCTV 설계·시공·유지보수 전문 기업'; $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')); $phone = site_setting('phone', config('app.contact.phone')); $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')); $address = site_setting('address', config('app.contact.address')); $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'); ?> @@ -18,7 +23,7 @@ $bizno = site_setting('bizno', '206-86-70582'); <?= e($pageTitle) ?> - + @@ -26,11 +31,11 @@ $bizno = site_setting('bizno', '206-86-70582'); - - - + + + - +