diff --git a/app/Commands/SyncFigmaData.php b/app/Commands/SyncFigmaData.php
new file mode 100644
index 0000000..70877ba
--- /dev/null
+++ b/app/Commands/SyncFigmaData.php
@@ -0,0 +1,42 @@
+syncAll();
+
+ if (!($result['success'] ?? false)) {
+ CLI::error('Sync failed: ' . ($result['message'] ?? 'Unknown error'));
+ return;
+ }
+
+ $stats = $result['stats'] ?? [];
+ CLI::write('Sync done.', 'green');
+ CLI::write('Files: ' . ($stats['files_synced'] ?? 0));
+ CLI::write('Snapshots: ' . ($stats['versions_synced'] ?? 0));
+ CLI::write('Comments: ' . ($stats['comments_synced'] ?? 0));
+
+ $errors = $stats['errors'] ?? [];
+ if (!empty($errors)) {
+ CLI::newLine();
+ CLI::write('Warnings / Errors:', 'yellow');
+ foreach ($errors as $error) {
+ CLI::write('- ' . $error, 'light_red');
+ }
+ }
+ }
+}
diff --git a/app/Commands/SyncFigmaIncremental.php b/app/Commands/SyncFigmaIncremental.php
new file mode 100644
index 0000000..d4e7f5f
--- /dev/null
+++ b/app/Commands/SyncFigmaIncremental.php
@@ -0,0 +1,49 @@
+syncIncremental($days);
+
+ if (!($result['success'] ?? false)) {
+ CLI::error('Sync failed: ' . ($result['message'] ?? 'Unknown error'));
+ return;
+ }
+
+ $stats = $result['stats'] ?? [];
+ CLI::write('Sync done.', 'green');
+ CLI::write('Mode: ' . ($stats['mode'] ?? 'incremental'));
+ CLI::write('Since: ' . ($stats['since'] ?? '-'));
+ CLI::write('Files: ' . ($stats['files_synced'] ?? 0));
+ CLI::write('Snapshots: ' . ($stats['versions_synced'] ?? 0));
+ CLI::write('Comments: ' . ($stats['comments_synced'] ?? 0));
+
+ $errors = $stats['errors'] ?? [];
+ if (!empty($errors)) {
+ CLI::newLine();
+ CLI::write('Warnings / Errors:', 'yellow');
+ foreach ($errors as $error) {
+ CLI::write('- ' . $error, 'light_red');
+ }
+ }
+ }
+}
diff --git a/app/Config/Routes.php b/app/Config/Routes.php
index 17bf968..b1ed20f 100644
--- a/app/Config/Routes.php
+++ b/app/Config/Routes.php
@@ -275,6 +275,14 @@ $routes->group('api/gitea', function($routes) {
$routes->get('getcommits/(:segment)/(:segment)', 'Gitea::getCommits/$1/$2');
});
+$routes->get('/figma', 'Figma::index');
+$routes->group('api/figma', function($routes) {
+ $routes->get('summary', 'Api\FigmaApi::summary');
+ $routes->get('snapshots', 'Api\FigmaApi::snapshots');
+ $routes->get('comments', 'Api\FigmaApi::comments');
+ $routes->post('sync', 'Api\FigmaApi::sync');
+});
+
$routes->group('api/git', function($routes) {
$routes->get('summary', 'Api\GitApi::summary');
$routes->get('users', 'Api\GitApi::users');
diff --git a/app/Controllers/Api/FigmaApi.php b/app/Controllers/Api/FigmaApi.php
new file mode 100644
index 0000000..a00b556
--- /dev/null
+++ b/app/Controllers/Api/FigmaApi.php
@@ -0,0 +1,189 @@
+get('userid')) {
+ return $this->respond([
+ 'status' => 'error',
+ 'message' => 'Unauthorized',
+ ], 401);
+ }
+
+ return null;
+ }
+
+ private function ensureAdmin()
+ {
+ $level = (int) session()->get('level');
+ if (!in_array($level, [0, 1, 2], true)) {
+ return $this->respond([
+ 'status' => 'error',
+ 'message' => 'Forbidden. Admin only.',
+ ], 403);
+ }
+
+ return null;
+ }
+
+ public function summary()
+ {
+ if ($response = $this->ensureLoggedIn()) {
+ return $response;
+ }
+
+ $db = \Config\Database::connect();
+ $file = $db->table('figma_files')->orderBy('id', 'DESC')->get()->getRowArray();
+ $versionsCount = $db->table('figma_file_versions')->countAllResults();
+ $commentsCount = $db->table('figma_comments')->countAllResults();
+ $latestVersion = $db->table('figma_file_versions')->orderBy('created_at_figma', 'DESC')->get()->getRowArray();
+ $latestComment = $db->table('figma_comments')->selectMax('created_at_figma', 'latest')->get()->getRowArray();
+
+ return $this->respond([
+ 'status' => 'success',
+ 'message' => 'Summary fetched',
+ 'data' => [
+ 'file' => $file,
+ 'versions' => $versionsCount,
+ 'comments' => $commentsCount,
+ 'latest_version_at' => $latestVersion['created_at_figma'] ?? null,
+ 'latest_version_label' => $latestVersion['label'] ?? $latestVersion['version'] ?? null,
+ 'latest_version_description' => $latestVersion['description'] ?? null,
+ 'latest_comment_at' => $latestComment['latest'] ?? null,
+ ],
+ ], 200);
+ }
+
+ private function getPaginationParams(): array
+ {
+ $page = (int) ($this->request->getGet('page') ?? 1);
+ if ($page <= 0) {
+ $page = 1;
+ }
+
+ $perPage = (int) ($this->request->getGet('per_page') ?? $this->request->getGet('limit') ?? 25);
+ if ($perPage <= 0 || $perPage > 100) {
+ $perPage = 25;
+ }
+
+ return [$page, $perPage];
+ }
+
+ public function snapshots()
+ {
+ if ($response = $this->ensureLoggedIn()) {
+ return $response;
+ }
+
+ $startDate = $this->request->getGet('start_date');
+ $endDate = $this->request->getGet('end_date');
+ [$page, $perPage] = $this->getPaginationParams();
+ $offset = ($page - 1) * $perPage;
+
+ $db = \Config\Database::connect();
+ $baseBuilder = $db->table('figma_file_versions v')
+ ->join('figma_files f', 'f.id = v.file_id', 'inner');
+
+ if (!empty($startDate)) {
+ $baseBuilder->where('v.created_at_figma >=', $startDate . ' 00:00:00');
+ }
+
+ if (!empty($endDate)) {
+ $baseBuilder->where('v.created_at_figma <=', $endDate . ' 23:59:59');
+ }
+
+ $total = (int) (clone $baseBuilder)->countAllResults();
+ $rows = $baseBuilder
+ ->select('v.id, v.figma_version_id, v.version, v.label, v.description, v.name, v.editor_type, v.last_modified_figma, v.created_at_figma, f.file_key, f.last_synced_at')
+ ->orderBy('v.created_at_figma', 'DESC')
+ ->limit($perPage, $offset)
+ ->get()
+ ->getResultArray();
+
+ return $this->respond([
+ 'status' => 'success',
+ 'message' => 'Snapshots fetched',
+ 'data' => $rows,
+ 'meta' => [
+ 'total' => $total,
+ 'page' => $page,
+ 'per_page' => $perPage,
+ 'total_pages' => $perPage > 0 ? (int) ceil($total / $perPage) : 0,
+ ],
+ ], 200);
+ }
+
+ public function comments()
+ {
+ if ($response = $this->ensureLoggedIn()) {
+ return $response;
+ }
+
+ $startDate = $this->request->getGet('start_date');
+ $endDate = $this->request->getGet('end_date');
+ [$page, $perPage] = $this->getPaginationParams();
+ $offset = ($page - 1) * $perPage;
+
+ $db = \Config\Database::connect();
+ $baseBuilder = $db->table('figma_comments c')
+ ->join('figma_files f', 'f.id = c.file_id', 'inner');
+
+ if (!empty($startDate)) {
+ $baseBuilder->where('c.created_at_figma >=', $startDate . ' 00:00:00');
+ }
+
+ if (!empty($endDate)) {
+ $baseBuilder->where('c.created_at_figma <=', $endDate . ' 23:59:59');
+ }
+
+ $total = (int) (clone $baseBuilder)->countAllResults();
+ $rows = $baseBuilder
+ ->select('c.id, c.figma_comment_id, c.user_name, c.message, c.is_resolved, c.resolved_at, c.created_at_figma, c.client_meta_json, f.file_key')
+ ->orderBy('c.created_at_figma', 'DESC')
+ ->limit($perPage, $offset)
+ ->get()
+ ->getResultArray();
+
+ return $this->respond([
+ 'status' => 'success',
+ 'message' => 'Comments fetched',
+ 'data' => $rows,
+ 'meta' => [
+ 'total' => $total,
+ 'page' => $page,
+ 'per_page' => $perPage,
+ 'total_pages' => $perPage > 0 ? (int) ceil($total / $perPage) : 0,
+ ],
+ ], 200);
+ }
+
+ public function sync()
+ {
+ if ($response = $this->ensureLoggedIn()) {
+ return $response;
+ }
+
+ if ($response = $this->ensureAdmin()) {
+ return $response;
+ }
+
+ $service = new FigmaSyncService();
+ $result = $service->syncAll();
+
+ $statusCode = $result['success'] ? 200 : 500;
+ return $this->respond([
+ 'status' => $result['success'] ? 'success' : 'error',
+ 'message' => $result['message'],
+ 'data' => $result['stats'] ?? [],
+ ], $statusCode);
+ }
+}
diff --git a/app/Controllers/Figma.php b/app/Controllers/Figma.php
new file mode 100644
index 0000000..65cdb81
--- /dev/null
+++ b/app/Controllers/Figma.php
@@ -0,0 +1,14 @@
+get('level');
+ $data['isAdmin'] = in_array($level, [0, 1, 2], true);
+
+ return view('figma_dashboard', $data);
+ }
+}
diff --git a/app/Database/Migrations/2026-04-27-000001_CreateFigmaTables.php b/app/Database/Migrations/2026-04-27-000001_CreateFigmaTables.php
new file mode 100644
index 0000000..3f4ed02
--- /dev/null
+++ b/app/Database/Migrations/2026-04-27-000001_CreateFigmaTables.php
@@ -0,0 +1,176 @@
+forge->addField([
+ 'id' => [
+ 'type' => 'BIGINT',
+ 'constraint' => 20,
+ 'unsigned' => true,
+ 'auto_increment' => true,
+ ],
+ 'file_key' => [
+ 'type' => 'VARCHAR',
+ 'constraint' => 255,
+ ],
+ 'name' => [
+ 'type' => 'VARCHAR',
+ 'constraint' => 255,
+ ],
+ 'version' => [
+ 'type' => 'VARCHAR',
+ 'constraint' => 255,
+ 'null' => true,
+ ],
+ 'last_modified' => [
+ 'type' => 'DATETIME',
+ 'null' => true,
+ ],
+ 'editor_type' => [
+ 'type' => 'VARCHAR',
+ 'constraint' => 100,
+ 'null' => true,
+ ],
+ 'last_synced_at' => [
+ 'type' => 'DATETIME',
+ 'null' => true,
+ ],
+ 'created_at' => [
+ 'type' => 'DATETIME',
+ 'null' => true,
+ ],
+ 'updated_at' => [
+ 'type' => 'DATETIME',
+ 'null' => true,
+ ],
+ ]);
+ $this->forge->addKey('id', true);
+ $this->forge->addUniqueKey('file_key');
+ $this->forge->createTable('figma_files', true);
+
+ $this->forge->addField([
+ 'id' => [
+ 'type' => 'BIGINT',
+ 'constraint' => 20,
+ 'unsigned' => true,
+ 'auto_increment' => true,
+ ],
+ 'file_id' => [
+ 'type' => 'BIGINT',
+ 'constraint' => 20,
+ 'unsigned' => true,
+ ],
+ 'figma_version_id' => [
+ 'type' => 'VARCHAR',
+ 'constraint' => 255,
+ ],
+ 'version' => [
+ 'type' => 'VARCHAR',
+ 'constraint' => 255,
+ 'null' => true,
+ ],
+ 'name' => [
+ 'type' => 'VARCHAR',
+ 'constraint' => 255,
+ 'null' => true,
+ ],
+ 'editor_type' => [
+ 'type' => 'VARCHAR',
+ 'constraint' => 100,
+ 'null' => true,
+ ],
+ 'last_modified_figma' => [
+ 'type' => 'DATETIME',
+ 'null' => true,
+ ],
+ 'created_at_figma' => [
+ 'type' => 'DATETIME',
+ 'null' => true,
+ ],
+ 'created_at' => [
+ 'type' => 'DATETIME',
+ 'null' => true,
+ ],
+ 'updated_at' => [
+ 'type' => 'DATETIME',
+ 'null' => true,
+ ],
+ ]);
+ $this->forge->addKey('id', true);
+ $this->forge->addUniqueKey(['file_id', 'figma_version_id']);
+ $this->forge->addKey('file_id');
+ $this->forge->addKey('created_at_figma');
+ $this->forge->addKey('last_modified_figma');
+ $this->forge->createTable('figma_file_versions', true);
+
+ $this->forge->addField([
+ 'id' => [
+ 'type' => 'BIGINT',
+ 'constraint' => 20,
+ 'unsigned' => true,
+ 'auto_increment' => true,
+ ],
+ 'file_id' => [
+ 'type' => 'BIGINT',
+ 'constraint' => 20,
+ 'unsigned' => true,
+ ],
+ 'figma_comment_id' => [
+ 'type' => 'VARCHAR',
+ 'constraint' => 255,
+ ],
+ 'user_name' => [
+ 'type' => 'VARCHAR',
+ 'constraint' => 255,
+ 'null' => true,
+ ],
+ 'message' => [
+ 'type' => 'LONGTEXT',
+ 'null' => true,
+ ],
+ 'is_resolved' => [
+ 'type' => 'TINYINT',
+ 'constraint' => 1,
+ 'default' => 0,
+ ],
+ 'resolved_at' => [
+ 'type' => 'DATETIME',
+ 'null' => true,
+ ],
+ 'created_at_figma' => [
+ 'type' => 'DATETIME',
+ 'null' => true,
+ ],
+ 'client_meta_json' => [
+ 'type' => 'LONGTEXT',
+ 'null' => true,
+ ],
+ 'created_at' => [
+ 'type' => 'DATETIME',
+ 'null' => true,
+ ],
+ 'updated_at' => [
+ 'type' => 'DATETIME',
+ 'null' => true,
+ ],
+ ]);
+ $this->forge->addKey('id', true);
+ $this->forge->addUniqueKey('figma_comment_id');
+ $this->forge->addKey('file_id');
+ $this->forge->addKey('created_at_figma');
+ $this->forge->createTable('figma_comments', true);
+ }
+
+ public function down()
+ {
+ $this->forge->dropTable('figma_comments', true);
+ $this->forge->dropTable('figma_file_versions', true);
+ $this->forge->dropTable('figma_files', true);
+ }
+}
diff --git a/app/Database/Migrations/2026-04-27-000002_AddLabelDescriptionToFigmaVersions.php b/app/Database/Migrations/2026-04-27-000002_AddLabelDescriptionToFigmaVersions.php
new file mode 100644
index 0000000..274350b
--- /dev/null
+++ b/app/Database/Migrations/2026-04-27-000002_AddLabelDescriptionToFigmaVersions.php
@@ -0,0 +1,43 @@
+db->fieldExists('label', 'figma_file_versions')) {
+ $fields['label'] = [
+ 'type' => 'VARCHAR',
+ 'constraint' => 255,
+ 'null' => true,
+ ];
+ }
+
+ if (!$this->db->fieldExists('description', 'figma_file_versions')) {
+ $fields['description'] = [
+ 'type' => 'LONGTEXT',
+ 'null' => true,
+ ];
+ }
+
+ if (!empty($fields)) {
+ $this->forge->addColumn('figma_file_versions', $fields);
+ }
+ }
+
+ public function down()
+ {
+ if ($this->db->fieldExists('description', 'figma_file_versions')) {
+ $this->forge->dropColumn('figma_file_versions', 'description');
+ }
+
+ if ($this->db->fieldExists('label', 'figma_file_versions')) {
+ $this->forge->dropColumn('figma_file_versions', 'label');
+ }
+ }
+}
diff --git a/app/Database/Migrations/2026-04-27-000003_RemoveFileUrlAndThumbnailFromFigmaTables.php b/app/Database/Migrations/2026-04-27-000003_RemoveFileUrlAndThumbnailFromFigmaTables.php
new file mode 100644
index 0000000..85370c6
--- /dev/null
+++ b/app/Database/Migrations/2026-04-27-000003_RemoveFileUrlAndThumbnailFromFigmaTables.php
@@ -0,0 +1,68 @@
+db->fieldExists('file_url', 'figma_files')) {
+ $this->forge->dropColumn('figma_files', 'file_url');
+ }
+
+ if ($this->db->fieldExists('thumbnail_url', 'figma_files')) {
+ $this->forge->dropColumn('figma_files', 'thumbnail_url');
+ }
+
+ if ($this->db->fieldExists('file_url', 'figma_file_versions')) {
+ $this->forge->dropColumn('figma_file_versions', 'file_url');
+ }
+
+ if ($this->db->fieldExists('thumbnail_url', 'figma_file_versions')) {
+ $this->forge->dropColumn('figma_file_versions', 'thumbnail_url');
+ }
+ }
+
+ public function down()
+ {
+ $fieldsFiles = [];
+ if (!$this->db->fieldExists('file_url', 'figma_files')) {
+ $fieldsFiles['file_url'] = [
+ 'type' => 'TEXT',
+ 'null' => true,
+ ];
+ }
+
+ if (!$this->db->fieldExists('thumbnail_url', 'figma_files')) {
+ $fieldsFiles['thumbnail_url'] = [
+ 'type' => 'TEXT',
+ 'null' => true,
+ ];
+ }
+
+ if (!empty($fieldsFiles)) {
+ $this->forge->addColumn('figma_files', $fieldsFiles);
+ }
+
+ $fieldsVersions = [];
+ if (!$this->db->fieldExists('file_url', 'figma_file_versions')) {
+ $fieldsVersions['file_url'] = [
+ 'type' => 'TEXT',
+ 'null' => true,
+ ];
+ }
+
+ if (!$this->db->fieldExists('thumbnail_url', 'figma_file_versions')) {
+ $fieldsVersions['thumbnail_url'] = [
+ 'type' => 'TEXT',
+ 'null' => true,
+ ];
+ }
+
+ if (!empty($fieldsVersions)) {
+ $this->forge->addColumn('figma_file_versions', $fieldsVersions);
+ }
+ }
+}
diff --git a/app/Libraries/FigmaSyncService.php b/app/Libraries/FigmaSyncService.php
new file mode 100644
index 0000000..29e9038
--- /dev/null
+++ b/app/Libraries/FigmaSyncService.php
@@ -0,0 +1,439 @@
+baseUrl = rtrim((string) (env('FIGMA_BASE_URL') ?: 'https://api.figma.com/v1'), '/');
+ $this->token = (string) env('FIGMA_TOKEN');
+ $this->fileKey = (string) env('FIGMA_FILE_KEY');
+
+ $this->filesModel = new FigmaFilesModel();
+ $this->versionsModel = new FigmaFileVersionsModel();
+ $this->commentsModel = new FigmaCommentsModel();
+ }
+
+ public function syncAll(): array
+ {
+ return $this->performSync();
+ }
+
+ public function syncIncremental(int $days = 1): array
+ {
+ if ($days < 1) {
+ $days = 1;
+ }
+
+ $sinceIso = (new \DateTimeImmutable('now'))
+ ->modify('-' . $days . ' days')
+ ->format(DATE_ATOM);
+
+ return $this->performSync($sinceIso, $days);
+ }
+
+ private function performSync(?string $sinceIso = null, ?int $days = null): array
+ {
+ if ($this->token === '') {
+ return [
+ 'success' => false,
+ 'message' => 'FIGMA_TOKEN missing in .env',
+ 'stats' => [],
+ ];
+ }
+
+ if ($this->fileKey === '') {
+ return [
+ 'success' => false,
+ 'message' => 'FIGMA_FILE_KEY missing in .env',
+ 'stats' => [],
+ ];
+ }
+
+ $stats = [
+ 'mode' => $sinceIso === null ? 'full' : 'incremental',
+ 'since' => $sinceIso,
+ 'files_synced' => 0,
+ 'versions_synced' => 0,
+ 'comments_synced' => 0,
+ 'errors' => [],
+ ];
+
+ $fileId = $this->upsertFile([
+ 'key' => $this->fileKey,
+ 'name' => env('FIGMA_FILE_NAME') ?: 'Figma File',
+ 'version' => null,
+ 'lastModified' => null,
+ 'editorType' => null,
+ ]);
+
+ if ($fileId === null) {
+ return [
+ 'success' => false,
+ 'message' => 'Failed upsert Figma file metadata',
+ 'stats' => $stats,
+ ];
+ }
+
+ $stats['files_synced'] = 1;
+
+ $versionsResult = $this->fetchAllCollection('/files/' . $this->fileKey . '/versions', ['versions', 'data', 'items']);
+ if ($versionsResult['success'] === false) {
+ $stats['errors'][] = $versionsResult['message'];
+ } else {
+ $versions = $this->sortByDateDesc($versionsResult['data'], ['created_at', 'createdAt', 'created_at_figma']);
+ foreach ($versions as $version) {
+ if ($sinceIso !== null && !$this->isRecent($version['created_at'] ?? $version['createdAt'] ?? null, $sinceIso)) {
+ continue;
+ }
+
+ if ($this->upsertVersion($fileId, $version)) {
+ $stats['versions_synced']++;
+ }
+ }
+
+ if (!empty($versions)) {
+ $latestVersion = $versions[0];
+ $this->filesModel->update($fileId, [
+ 'name' => (string) (env('FIGMA_FILE_NAME') ?: 'Figma File'),
+ 'version' => $latestVersion['label'] ?? $latestVersion['version'] ?? $latestVersion['id'] ?? null,
+ 'last_modified' => $this->normalizeDate($latestVersion['created_at'] ?? $latestVersion['createdAt'] ?? null),
+ 'last_synced_at' => date('Y-m-d H:i:s'),
+ ]);
+ }
+ }
+
+ $commentsResult = $this->fetchAllCollection('/files/' . $this->fileKey . '/comments', ['comments', 'data', 'items']);
+ if ($commentsResult['success'] === false) {
+ $stats['errors'][] = $commentsResult['message'];
+ } else {
+ $comments = $this->sortByDateDesc($commentsResult['data'], ['created_at', 'createdAt']);
+ foreach ($comments as $comment) {
+ if ($sinceIso !== null && !$this->isRecent($comment['created_at'] ?? $comment['createdAt'] ?? null, $sinceIso)) {
+ continue;
+ }
+
+ if ($this->upsertComment($fileId, $comment)) {
+ $stats['comments_synced']++;
+ }
+ }
+ }
+
+ $this->filesModel->update($fileId, [
+ 'last_synced_at' => date('Y-m-d H:i:s'),
+ ]);
+
+ $message = $sinceIso === null
+ ? 'Figma full sync completed'
+ : 'Figma incremental sync completed (last ' . ($days ?? 1) . ' day)';
+
+ return [
+ 'success' => true,
+ 'message' => $message,
+ 'stats' => $stats,
+ ];
+ }
+
+ private function upsertFile(array $file): ?int
+ {
+ $fileKey = (string) ($file['key'] ?? $this->fileKey);
+ if ($fileKey === '') {
+ return null;
+ }
+
+ $data = [
+ 'file_key' => $fileKey,
+ 'name' => (string) ($file['name'] ?? 'Figma File'),
+ 'version' => $file['version'] ?? null,
+ 'last_modified' => $this->normalizeDate($file['lastModified'] ?? null),
+ 'editor_type' => $this->normalizeEditorType($file['editorType'] ?? null),
+ 'last_synced_at' => date('Y-m-d H:i:s'),
+ ];
+
+ $existing = $this->filesModel->where('file_key', $fileKey)->first();
+ if ($existing) {
+ $this->filesModel->update((int) $existing['id'], $data);
+ return (int) $existing['id'];
+ }
+
+ $this->filesModel->insert($data);
+ return (int) $this->filesModel->getInsertID();
+ }
+
+ private function upsertVersion(int $fileId, array $version): bool
+ {
+ $versionId = (string) ($version['id'] ?? $version['version'] ?? '');
+ if ($versionId === '') {
+ $versionId = sha1((string) json_encode($version));
+ }
+
+ $label = $version['label'] ?? $version['name'] ?? $version['version'] ?? $versionId;
+ $description = $version['description'] ?? $version['notes'] ?? $version['message'] ?? null;
+ $createdAt = $this->normalizeDate($version['created_at'] ?? $version['createdAt'] ?? null);
+
+ $data = [
+ 'file_id' => $fileId,
+ 'figma_version_id' => $versionId,
+ 'version' => is_scalar($label) ? (string) $label : $versionId,
+ 'label' => is_scalar($label) ? (string) $label : $versionId,
+ 'description' => is_scalar($description) ? (string) $description : null,
+ 'name' => (string) (env('FIGMA_FILE_NAME') ?: 'Figma File'),
+ 'editor_type' => $this->normalizeEditorType($version['editorType'] ?? null),
+ 'last_modified_figma' => $createdAt,
+ 'created_at_figma' => $createdAt,
+ ];
+
+ $existing = $this->versionsModel
+ ->where('file_id', $fileId)
+ ->where('figma_version_id', $versionId)
+ ->first();
+
+ if ($existing) {
+ $this->versionsModel->update((int) $existing['id'], $data);
+ return false;
+ }
+
+ $this->versionsModel->insert($data);
+ return true;
+ }
+
+ private function upsertComment(int $fileId, array $comment): bool
+ {
+ $commentId = (string) ($comment['id'] ?? '');
+ if ($commentId === '') {
+ return false;
+ }
+
+ $data = [
+ 'file_id' => $fileId,
+ 'figma_comment_id' => $commentId,
+ 'user_name' => $comment['user']['handle'] ?? $comment['user']['name'] ?? $comment['user_name'] ?? null,
+ 'message' => $comment['message'] ?? $comment['text'] ?? null,
+ 'is_resolved' => !empty($comment['resolved_at']) ? 1 : (int) (!empty($comment['resolved']) ? 1 : 0),
+ 'resolved_at' => $this->normalizeDate($comment['resolved_at'] ?? null),
+ 'created_at_figma' => $this->normalizeDate($comment['created_at'] ?? $comment['createdAt'] ?? null),
+ 'client_meta_json' => isset($comment['client_meta']) ? json_encode($comment['client_meta']) : (isset($comment['client_meta_json']) ? (string) $comment['client_meta_json'] : null),
+ ];
+
+ $existing = $this->commentsModel->where('figma_comment_id', $commentId)->first();
+ if ($existing) {
+ $this->commentsModel->update((int) $existing['id'], $data);
+ return false;
+ }
+
+ $this->commentsModel->insert($data);
+ return true;
+ }
+
+ private function fetchAllCollection(string $endpoint, array $listKeys): array
+ {
+ $allItems = [];
+ $seenSignatures = [];
+ $page = 1;
+ $limit = 100;
+ $maxPages = 100;
+
+ while ($page <= $maxPages) {
+ $response = $this->request('GET', $endpoint, [
+ 'page' => $page,
+ 'limit' => $limit,
+ 'per_page' => $limit,
+ ]);
+
+ if ($response['success'] === false) {
+ return $response;
+ }
+
+ $items = $this->extractList($response['data'], $listKeys);
+ if (empty($items)) {
+ break;
+ }
+
+ $newItems = [];
+ foreach ($items as $item) {
+ $signature = $this->itemSignature($item);
+ if ($signature !== '' && isset($seenSignatures[$signature])) {
+ continue;
+ }
+
+ if ($signature !== '') {
+ $seenSignatures[$signature] = true;
+ }
+
+ $newItems[] = $item;
+ }
+
+ if (empty($newItems)) {
+ break;
+ }
+
+ $allItems = array_merge($allItems, $newItems);
+
+ if (count($items) < $limit) {
+ break;
+ }
+
+ $page++;
+ }
+
+ return [
+ 'success' => true,
+ 'message' => 'ok',
+ 'data' => $allItems,
+ ];
+ }
+
+ private function extractList($data, array $listKeys): array
+ {
+ if (is_array($data) && array_is_list($data)) {
+ return $data;
+ }
+
+ if (!is_array($data)) {
+ return [];
+ }
+
+ foreach ($listKeys as $key) {
+ if (isset($data[$key]) && is_array($data[$key])) {
+ return $data[$key];
+ }
+ }
+
+ return [];
+ }
+
+ private function sortByDateDesc(array $items, array $dateKeys): array
+ {
+ usort($items, function (array $left, array $right) use ($dateKeys): int {
+ $leftDate = $this->dateValue($left, $dateKeys);
+ $rightDate = $this->dateValue($right, $dateKeys);
+
+ return $rightDate <=> $leftDate;
+ });
+
+ return $items;
+ }
+
+ private function dateValue(array $row, array $dateKeys): int
+ {
+ foreach ($dateKeys as $key) {
+ if (!empty($row[$key])) {
+ try {
+ return (new \DateTimeImmutable((string) $row[$key]))->getTimestamp();
+ } catch (\Throwable $e) {
+ return 0;
+ }
+ }
+ }
+
+ return 0;
+ }
+
+ private function itemSignature(array $item): string
+ {
+ foreach (['id', 'version', 'created_at', 'createdAt', 'created_at_figma', 'figma_version_id', 'figma_comment_id'] as $key) {
+ if (!empty($item[$key])) {
+ return (string) $item[$key];
+ }
+ }
+
+ return sha1((string) json_encode($item));
+ }
+
+ private function isRecent(?string $value, string $sinceIso): bool
+ {
+ if ($value === null || $value === '') {
+ return true;
+ }
+
+ try {
+ return new \DateTimeImmutable($value) >= new \DateTimeImmutable($sinceIso);
+ } catch (\Throwable $e) {
+ return true;
+ }
+ }
+
+ private function request(string $method, string $endpoint, array $query = []): array
+ {
+ $url = $this->baseUrl . $endpoint;
+ if (!empty($query)) {
+ $url .= '?' . http_build_query($query);
+ }
+
+ $client = \Config\Services::curlrequest();
+
+ try {
+ $response = $client->request($method, $url, [
+ 'headers' => [
+ 'X-Figma-Token' => $this->token,
+ 'Accept' => 'application/json',
+ ],
+ 'http_errors' => false,
+ 'timeout' => 120,
+ ]);
+
+ $statusCode = $response->getStatusCode();
+ $body = json_decode($response->getBody(), true);
+
+ if ($statusCode < 200 || $statusCode >= 300) {
+ return [
+ 'success' => false,
+ 'message' => 'Figma request failed [' . $statusCode . '] ' . $endpoint,
+ 'data' => $body,
+ ];
+ }
+
+ return [
+ 'success' => true,
+ 'message' => 'ok',
+ 'data' => $body,
+ ];
+ } catch (\Throwable $e) {
+ return [
+ 'success' => false,
+ 'message' => 'Figma request exception: ' . $e->getMessage(),
+ 'data' => null,
+ ];
+ }
+ }
+
+ private function normalizeDate(?string $value): ?string
+ {
+ if ($value === null || $value === '') {
+ return null;
+ }
+
+ try {
+ return (new \DateTime($value))->format('Y-m-d H:i:s');
+ } catch (\Throwable $e) {
+ return null;
+ }
+ }
+
+ private function normalizeEditorType($value): ?string
+ {
+ if ($value === null) {
+ return null;
+ }
+
+ if (is_array($value)) {
+ return implode(',', array_map('strval', $value));
+ }
+
+ return (string) $value;
+ }
+
+}
diff --git a/app/Models/FigmaCommentsModel.php b/app/Models/FigmaCommentsModel.php
new file mode 100644
index 0000000..12ab7f2
--- /dev/null
+++ b/app/Models/FigmaCommentsModel.php
@@ -0,0 +1,28 @@
+extend('layouts/main.php') ?>
+
+= $this->section('content') ?>
+
+
+
+
+
Figma Dashboard (DB)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Latest File Snapshots
+
Total Snapshots: 0
+
+
+
+
+
+ | Date |
+ Label |
+ Description |
+ Version |
+ Editor |
+
+
+
+
+
+
+
+
Page 0 of 0
+
+
+
+
+
+
+
+
+
+
+
Latest Comments
+
Total Comments:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+= $this->endSection() ?>
+
+= $this->section('script') ?>
+
+= $this->endSection() ?>
diff --git a/app/Views/layouts/_sidebar.php b/app/Views/layouts/_sidebar.php
index b4e30bd..28d0148 100644
--- a/app/Views/layouts/_sidebar.php
+++ b/app/Views/layouts/_sidebar.php
@@ -114,6 +114,7 @@
+