- = htmlspecialchars($item['title']) ?> (= $item['rating'] ?>)
+ = htmlspecialchars($item['title']) ?> (= $item['rating'] ?>)
- via
+  via 
= $item['author']['name'] ?>
From b71ddd91feda99113d7cbbbec466b9c65a1cc109 Mon Sep 17 00:00:00 2001
From: Cory Dransfeldt
Date: Tue, 22 Apr 2025 15:54:51 -0700
Subject: [PATCH 6/8] fix(contact.css): properly center success container
contents
---
package-lock.json | 4 ++--
package.json | 2 +-
src/assets/styles/pages/contact.css | 6 ++++--
3 files changed, 7 insertions(+), 5 deletions(-)
diff --git a/package-lock.json b/package-lock.json
index 33183a9..e4c737c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "coryd.dev",
- "version": "3.0.3",
+ "version": "3.0.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "coryd.dev",
- "version": "3.0.3",
+ "version": "3.0.4",
"license": "MIT",
"dependencies": {
"html-minifier-terser": "7.2.0",
diff --git a/package.json b/package.json
index 1dbb96f..cda8652 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "coryd.dev",
- "version": "3.0.3",
+ "version": "3.0.4",
"description": "The source for my personal site. Built using 11ty (and other tools).",
"type": "module",
"engines": {
diff --git a/src/assets/styles/pages/contact.css b/src/assets/styles/pages/contact.css
index b426634..7140d6c 100644
--- a/src/assets/styles/pages/contact.css
+++ b/src/assets/styles/pages/contact.css
@@ -28,9 +28,11 @@
}
.contact-success-wrapper {
- text-align: center;
-
h2 {
margin: 0;
}
+
+ * {
+ text-align: center;
+ }
}
From 4bad005e5864db20165f5554cc300bf23b3f369a Mon Sep 17 00:00:00 2001
From: Cory Dransfeldt
Date: Tue, 22 Apr 2025 12:39:42 -0700
Subject: [PATCH 7/8] feat(*.php, *.psql): deduplicate API code + performance
improvements
---
api/Classes/ApiHandler.php | 65 +--------
api/Classes/BaseHandler.php | 123 ++++++----------
api/artist-import.php | 79 ++++-------
api/book-import.php | 55 +++-----
api/contact.php | 26 ++--
api/mastodon.php | 171 ++++++++---------------
api/playing.php | 2 +-
api/proxy.php | 90 ++++++++++++
api/scrobble.php | 124 +++++++---------
api/search.php | 8 +-
api/seasons-import.php | 89 ++++--------
api/watching-import.php | 136 ++++++++----------
package-lock.json | 24 ++--
package.json | 2 +-
queries/views/feeds/recent_activity.psql | 7 +-
queries/views/media/music/concerts.psql | 2 -
server/utils/strings.php | 2 -
src/assets/scripts/index.js | 97 ++++++++-----
src/assets/styles/base/fonts.css | 16 ---
src/assets/styles/base/index.css | 2 +-
src/assets/styles/base/vars.css | 3 +-
src/assets/styles/components/dialog.css | 2 +-
src/includes/blocks/dialog.liquid | 8 +-
src/includes/home/recent-activity.liquid | 7 +-
src/layouts/base.liquid | 2 -
src/pages/dynamic/artist.php.liquid | 2 +-
src/pages/dynamic/book.php.liquid | 2 +-
src/pages/dynamic/genre.php.liquid | 4 +-
src/pages/dynamic/show.php.liquid | 5 +-
src/pages/dynamic/tags.php.liquid | 6 +-
src/pages/media/music/concerts.html | 7 +-
31 files changed, 502 insertions(+), 666 deletions(-)
create mode 100644 api/proxy.php
diff --git a/api/Classes/ApiHandler.php b/api/Classes/ApiHandler.php
index 9f42b30..5524427 100644
--- a/api/Classes/ApiHandler.php
+++ b/api/Classes/ApiHandler.php
@@ -1,72 +1,15 @@
loadEnvironment();
- }
-
- private function loadEnvironment(): void
- {
- $this->postgrestUrl =
- $_ENV["POSTGREST_URL"] ?? getenv("POSTGREST_URL") ?: "";
- $this->postgrestApiKey =
- $_ENV["POSTGREST_API_KEY"] ?? getenv("POSTGREST_API_KEY") ?: "";
- }
-
protected function ensureCliAccess(): void
{
- if (php_sapi_name() !== "cli" && $_SERVER["REQUEST_METHOD"] !== "POST") {
- $this->redirectNotFound();
+ if (php_sapi_name() !== 'cli' && $_SERVER['REQUEST_METHOD'] !== 'POST') {
+ $this->sendErrorResponse("Not Found", 404);
}
}
-
- protected function redirectNotFound(): void
- {
- header("Location: /404", true, 302);
- exit();
- }
-
- protected function fetchFromPostgREST(
- string $endpoint,
- string $query = "",
- string $method = "GET",
- ?array $body = null
- ): array {
- $url = "{$this->postgrestUrl}/{$endpoint}?{$query}";
- $options = [
- "headers" => [
- "Content-Type" => "application/json",
- "Authorization" => "Bearer {$this->postgrestApiKey}",
- ],
- ];
-
- if ($method === "POST" && $body) $options["json"] = $body;
-
- $response = (new Client())->request($method, $url, $options);
-
- return json_decode($response->getBody(), true) ?? [];
- }
-
- protected function sendResponse(string $message, int $statusCode): void
- {
- http_response_code($statusCode);
- header("Content-Type: application/json");
- echo json_encode(["message" => $message]);
- exit();
- }
-
- protected function sendErrorResponse(string $message, int $statusCode): void
- {
- $this->sendResponse($message, $statusCode);
- }
}
diff --git a/api/Classes/BaseHandler.php b/api/Classes/BaseHandler.php
index d69989d..b526846 100644
--- a/api/Classes/BaseHandler.php
+++ b/api/Classes/BaseHandler.php
@@ -16,60 +16,71 @@ abstract class BaseHandler
public function __construct()
{
$this->loadEnvironment();
+ $this->initializeCache();
}
private function loadEnvironment(): void
{
- $this->postgrestUrl =
- $_ENV["POSTGREST_URL"] ?? getenv("POSTGREST_URL") ?: "";
- $this->postgrestApiKey =
- $_ENV["POSTGREST_API_KEY"] ?? getenv("POSTGREST_API_KEY") ?: "";
+ $this->postgrestUrl = $_ENV["POSTGREST_URL"] ?? getenv("POSTGREST_URL") ?? "";
+ $this->postgrestApiKey = $_ENV["POSTGREST_API_KEY"] ?? getenv("POSTGREST_API_KEY") ?? "";
}
- protected function makeRequest(
- string $method,
- string $endpoint,
- array $options = []
- ): array {
+ protected function initializeCache(): void
+ {
+ if (class_exists("Redis")) {
+ try {
+ $redis = new \Redis();
+ $redis->connect("127.0.0.1", 6379);
+ $this->cache = $redis;
+ } catch (\Exception $e) {
+ error_log("Redis connection failed: " . $e->getMessage());
+ $this->cache = null;
+ }
+ } else {
+ error_log("Redis extension not found — caching disabled.");
+ $this->cache = null;
+ }
+ }
+
+ protected function makeRequest(string $method, string $endpoint, array $options = []): array
+ {
$client = new Client();
$url = rtrim($this->postgrestUrl, "/") . "/" . ltrim($endpoint, "/");
try {
- $response = $client->request(
- $method,
- $url,
- array_merge($options, [
- "headers" => [
- "Authorization" => "Bearer {$this->postgrestApiKey}",
- "Content-Type" => "application/json",
- ],
- ])
- );
+ $response = $client->request($method, $url, array_merge_recursive([
+ "headers" => [
+ "Authorization" => "Bearer {$this->postgrestApiKey}",
+ "Content-Type" => "application/json",
+ ]
+ ], $options));
$responseBody = $response->getBody()->getContents();
-
if (empty($responseBody)) return [];
- $responseData = json_decode($responseBody, true);
+ $data = json_decode($responseBody, true);
+ if (json_last_error() !== JSON_ERROR_NONE) {
+ throw new \Exception("Invalid JSON: " . json_last_error_msg());
+ }
- if (json_last_error() !== JSON_ERROR_NONE) throw new \Exception("Invalid JSON response: {$responseBody}");
-
- return $responseData;
+ return $data;
} catch (RequestException $e) {
$response = $e->getResponse();
- $statusCode = $response ? $response->getStatusCode() : "N/A";
- $responseBody = $response
- ? $response->getBody()->getContents()
- : "No response body";
+ $statusCode = $response ? $response->getStatusCode() : 'N/A';
+ $responseBody = $response ? $response->getBody()->getContents() : 'No response';
- throw new \Exception(
- "Request to {$url} failed with status {$statusCode}. Response: {$responseBody}"
- );
+ throw new \Exception("HTTP {$method} {$url} failed with status {$statusCode}: {$responseBody}");
} catch (\Exception $e) {
- throw new \Exception("Request to {$url} failed: " . $e->getMessage());
+ throw new \Exception("Request error: " . $e->getMessage());
}
}
+ protected function fetchFromApi(string $endpoint, string $query = ""): array
+ {
+ $url = $endpoint . ($query ? "?{$query}" : "");
+ return $this->makeRequest("GET", $url);
+ }
+
protected function sendResponse(array $data, int $statusCode = 200): void
{
http_response_code($statusCode);
@@ -78,52 +89,8 @@ abstract class BaseHandler
exit();
}
- protected function sendErrorResponse(
- string $message,
- int $statusCode = 500
- ): void {
+ protected function sendErrorResponse(string $message, int $statusCode = 500): void
+ {
$this->sendResponse(["error" => $message], $statusCode);
}
-
- protected function fetchFromApi(string $endpoint, string $query): array
- {
- $client = new Client();
- $url =
- rtrim($this->postgrestUrl, "/") .
- "/" .
- ltrim($endpoint, "/") .
- "?" .
- $query;
-
- try {
- $response = $client->request("GET", $url, [
- "headers" => [
- "Content-Type" => "application/json",
- "Authorization" => "Bearer {$this->postgrestApiKey}",
- ],
- ]);
-
- if ($response->getStatusCode() !== 200) throw new Exception("API call to {$url} failed with status code " . $response->getStatusCode());
-
- return json_decode($response->getBody(), true);
- } catch (RequestException $e) {
- throw new Exception("Error fetching from API: " . $e->getMessage());
- }
- }
-
- protected function initializeCache(): void
- {
- if (class_exists("Redis")) {
- $redis = new \Redis();
- try {
- $redis->connect("127.0.0.1", 6379);
- $this->cache = $redis;
- } catch (Exception $e) {
- error_log("Redis connection failed: " . $e->getMessage());
- $this->cache = null;
- }
- } else {
- $this->cache = null;
- }
- }
}
diff --git a/api/artist-import.php b/api/artist-import.php
index b3141a5..70a5934 100644
--- a/api/artist-import.php
+++ b/api/artist-import.php
@@ -8,9 +8,6 @@ use GuzzleHttp\Client;
class ArtistImportHandler extends ApiHandler
{
- protected string $postgrestUrl;
- protected string $postgrestApiKey;
-
private string $artistImportToken;
private string $placeholderImageId = "4cef75db-831f-4f5d-9333-79eaa5bb55ee";
private string $navidromeApiUrl;
@@ -20,13 +17,7 @@ class ArtistImportHandler extends ApiHandler
{
parent::__construct();
$this->ensureCliAccess();
- $this->loadEnvironment();
- }
- private function loadEnvironment(): void
- {
- $this->postgrestUrl = getenv("POSTGREST_URL");
- $this->postgrestApiKey = getenv("POSTGREST_API_KEY");
$this->artistImportToken = getenv("ARTIST_IMPORT_TOKEN");
$this->navidromeApiUrl = getenv("NAVIDROME_API_URL");
$this->navidromeAuthToken = getenv("NAVIDROME_API_TOKEN");
@@ -41,11 +32,13 @@ class ArtistImportHandler extends ApiHandler
$providedToken = $input["token"] ?? null;
$artistId = $input["artistId"] ?? null;
- if (!$providedToken || $providedToken !== $this->artistImportToken) {
+ if ($providedToken !== $this->artistImportToken) {
$this->sendJsonResponse("error", "Unauthorized access", 401);
}
- if (!$artistId) $this->sendJsonResponse("error", "Artist ID is required", 400);
+ if (!$artistId) {
+ $this->sendJsonResponse("error", "Artist ID is required", 400);
+ }
try {
$artistData = $this->fetchNavidromeArtist($artistId);
@@ -54,7 +47,7 @@ class ArtistImportHandler extends ApiHandler
if ($artistExists) $this->processAlbums($artistId, $artistData->name);
$this->sendJsonResponse("message", "Artist and albums synced successfully", 200);
- } catch (Exception $e) {
+ } catch (\Exception $e) {
$this->sendJsonResponse("error", "Error: " . $e->getMessage(), 500);
}
}
@@ -67,7 +60,7 @@ class ArtistImportHandler extends ApiHandler
exit();
}
- private function fetchNavidromeArtist(string $artistId)
+ private function fetchNavidromeArtist(string $artistId): object
{
$client = new Client();
$response = $client->get("{$this->navidromeApiUrl}/api/artist/{$artistId}", [
@@ -77,7 +70,7 @@ class ArtistImportHandler extends ApiHandler
]
]);
- return json_decode($response->getBody(), false);
+ return json_decode($response->getBody());
}
private function fetchNavidromeAlbums(string $artistId): array
@@ -103,16 +96,14 @@ class ArtistImportHandler extends ApiHandler
private function processArtist(object $artistData): bool
{
$artistName = $artistData->name ?? "";
-
- if (!$artistName) throw new Exception("Artist name is missing from Navidrome data.");
+ if (!$artistName) throw new \Exception("Artist name is missing.");
$existingArtist = $this->getArtistByName($artistName);
-
if ($existingArtist) return true;
$artistKey = sanitizeMediaString($artistName);
$slug = "/music/artists/{$artistKey}";
- $description = strip_tags($artistData->biography) ?? "";
+ $description = strip_tags($artistData->biography ?? "");
$genre = $this->resolveGenreId($artistData->genres[0]->name ?? "");
$starred = $artistData->starred ?? false;
@@ -127,35 +118,34 @@ class ArtistImportHandler extends ApiHandler
"genres" => $genre,
];
- $this->saveArtist($artistPayload);
-
+ $this->makeRequest("POST", "artists", ["json" => $artistPayload]);
return true;
}
private function processAlbums(string $artistId, string $artistName): void
{
$artist = $this->getArtistByName($artistName);
-
- if (!$artist) throw new Exception("Artist not found in the database.");
+ if (!$artist) throw new \Exception("Artist not found after insert.");
$existingAlbums = $this->getExistingAlbums($artist["id"]);
$existingAlbumKeys = array_column($existingAlbums, "key");
+
$navidromeAlbums = $this->fetchNavidromeAlbums($artistId);
foreach ($navidromeAlbums as $album) {
- $albumName = $album["name"];
+ $albumName = $album["name"] ?? "";
$releaseYearRaw = $album["date"] ?? null;
$releaseYear = null;
- if ($releaseYearRaw) {
- if (preg_match('/^\d{4}/', $releaseYearRaw, $matches)) $releaseYear = (int)$matches[0];
+ if ($releaseYearRaw && preg_match('/^\d{4}/', $releaseYearRaw, $matches)) {
+ $releaseYear = (int)$matches[0];
}
$artistKey = sanitizeMediaString($artistName);
- $albumKey = $artistKey . "-" . sanitizeMediaString($albumName);
+ $albumKey = "{$artistKey}-" . sanitizeMediaString($albumName);
if (in_array($albumKey, $existingAlbumKeys)) {
- error_log("Skipping existing album: " . $albumName);
+ error_log("Skipping existing album: {$albumName}");
continue;
}
@@ -170,8 +160,8 @@ class ArtistImportHandler extends ApiHandler
"tentative" => true,
];
- $this->saveAlbum($albumPayload);
- } catch (Exception $e) {
+ $this->makeRequest("POST", "albums", ["json" => $albumPayload]);
+ } catch (\Exception $e) {
error_log("Error adding album '{$albumName}': " . $e->getMessage());
}
}
@@ -179,34 +169,19 @@ class ArtistImportHandler extends ApiHandler
private function getArtistByName(string $nameString): ?array
{
- $query = "name_string=eq." . urlencode($nameString);
- $response = $this->fetchFromPostgREST("artists", $query, "GET");
-
+ $response = $this->fetchFromApi("artists", "name_string=eq." . urlencode($nameString));
return $response[0] ?? null;
}
- private function saveArtist(array $artistPayload): void
- {
- $this->fetchFromPostgREST("artists", "", "POST", $artistPayload);
- }
-
- private function saveAlbum(array $albumPayload): void
- {
- $this->fetchFromPostgREST("albums", "", "POST", $albumPayload);
- }
-
- private function resolveGenreId(string $genreName): ?string
- {
- $genres = $this->fetchFromPostgREST("genres", "name=eq." . urlencode(strtolower($genreName)), "GET");
-
- if (!empty($genres)) return $genres[0]["id"];
-
- return null;
- }
-
private function getExistingAlbums(string $artistId): array
{
- return $this->fetchFromPostgREST("albums", "artist=eq." . urlencode($artistId), "GET");
+ return $this->fetchFromApi("albums", "artist=eq." . urlencode($artistId));
+ }
+
+ private function resolveGenreId(string $genreName): ?string
+ {
+ $genres = $this->fetchFromApi("genres", "name=eq." . urlencode(strtolower($genreName)));
+ return $genres[0]["id"] ?? null;
}
}
diff --git a/api/book-import.php b/api/book-import.php
index 026c19e..4d52b0f 100644
--- a/api/book-import.php
+++ b/api/book-import.php
@@ -7,46 +7,39 @@ use GuzzleHttp\Client;
class BookImportHandler extends ApiHandler
{
- protected string $postgrestUrl;
- protected string $postgrestApiKey;
-
private string $bookImportToken;
public function __construct()
{
parent::__construct();
$this->ensureCliAccess();
- $this->loadEnvironment();
- }
-
- private function loadEnvironment(): void
- {
- $this->postgrestUrl = $_ENV["POSTGREST_URL"] ?? getenv("POSTGREST_URL");
- $this->postgrestApiKey =
- $_ENV["POSTGREST_API_KEY"] ?? getenv("POSTGREST_API_KEY");
- $this->bookImportToken =
- $_ENV["BOOK_IMPORT_TOKEN"] ?? getenv("BOOK_IMPORT_TOKEN");
+ $this->bookImportToken = $_ENV["BOOK_IMPORT_TOKEN"] ?? getenv("BOOK_IMPORT_TOKEN");
}
public function handleRequest(): void
{
$input = json_decode(file_get_contents("php://input"), true);
- if (!$input) $this->sendErrorResponse("Invalid or missing JSON body", 400);
+ if (!$input) {
+ $this->sendErrorResponse("Invalid or missing JSON body", 400);
+ }
$providedToken = $input["token"] ?? null;
$isbn = $input["isbn"] ?? null;
- if (!$providedToken || $providedToken !== $this->bookImportToken) $this->sendErrorResponse("Unauthorized access", 401);
+ if ($providedToken !== $this->bookImportToken) {
+ $this->sendErrorResponse("Unauthorized access", 401);
+ }
- if (!$isbn) $this->sendErrorResponse("isbn parameter is required", 400);
+ if (!$isbn) {
+ $this->sendErrorResponse("isbn parameter is required", 400);
+ }
try {
$bookData = $this->fetchBookData($isbn);
$this->processBook($bookData);
-
- $this->sendResponse("Book imported successfully", 200);
- } catch (Exception $e) {
+ $this->sendResponse(["message" => "Book imported successfully"], 200);
+ } catch (\Exception $e) {
$this->sendErrorResponse("Error: " . $e->getMessage(), 500);
}
}
@@ -66,7 +59,9 @@ class BookImportHandler extends ApiHandler
$data = json_decode($response->getBody(), true);
$bookKey = "ISBN:{$isbn}";
- if (empty($data[$bookKey])) throw new Exception("Book data not found for ISBN: {$isbn}");
+ if (empty($data[$bookKey])) {
+ throw new \Exception("Book data not found for ISBN: {$isbn}");
+ }
return $data[$bookKey];
}
@@ -80,11 +75,14 @@ class BookImportHandler extends ApiHandler
$author = $bookData["authors"][0]["name"] ?? null;
$description = $bookData["description"] ?? ($bookData["notes"] ?? "");
- if (!$isbn || !$title || !$author) throw new Exception("Missing essential book data (title, author, or ISBN).");
+ if (!$isbn || !$title || !$author) {
+ throw new \Exception("Missing essential book data (title, author, or ISBN).");
+ }
$existingBook = $this->getBookByISBN($isbn);
-
- if ($existingBook) throw new Exception("Book with ISBN {$isbn} already exists.");
+ if ($existingBook) {
+ throw new \Exception("Book with ISBN {$isbn} already exists.");
+ }
$bookPayload = [
"isbn" => $isbn,
@@ -95,19 +93,12 @@ class BookImportHandler extends ApiHandler
"slug" => "/books/" . $isbn,
];
- $this->saveBook($bookPayload);
- }
-
- private function saveBook(array $bookPayload): void
- {
- $this->fetchFromPostgREST("books", "", "POST", $bookPayload);
+ $this->makeRequest("POST", "books", ["json" => $bookPayload]);
}
private function getBookByISBN(string $isbn): ?array
{
- $query = "isbn=eq." . urlencode($isbn);
- $response = $this->fetchFromPostgREST("books", $query, "GET");
-
+ $response = $this->fetchFromApi("books", "isbn=eq." . urlencode($isbn));
return $response[0] ?? null;
}
}
diff --git a/api/contact.php b/api/contact.php
index dc0b682..b440ad0 100644
--- a/api/contact.php
+++ b/api/contact.php
@@ -15,17 +15,9 @@ class ContactHandler extends BaseHandler
public function __construct(?Client $httpClient = null)
{
+ parent::__construct();
$this->httpClient = $httpClient ?? new Client();
- $this->loadEnvironment();
- }
-
- private function loadEnvironment(): void
- {
- $this->postgrestUrl = $_ENV["POSTGREST_URL"] ?? getenv("POSTGREST_URL");
- $this->postgrestApiKey =
- $_ENV["POSTGREST_API_KEY"] ?? getenv("POSTGREST_API_KEY");
- $this->forwardEmailApiKey =
- $_ENV["FORWARDEMAIL_API_KEY"] ?? getenv("FORWARDEMAIL_API_KEY");
+ $this->forwardEmailApiKey = $_ENV["FORWARDEMAIL_API_KEY"] ?? getenv("FORWARDEMAIL_API_KEY");
}
public function handleRequest(): void
@@ -42,7 +34,7 @@ class ContactHandler extends BaseHandler
$rawBody = file_get_contents("php://input");
$formData = json_decode($rawBody, true);
if (!$formData || !isset($formData["data"])) {
- throw new Exception("Invalid JSON payload.");
+ throw new \Exception("Invalid JSON payload.");
}
$formData = $formData["data"];
} elseif (
@@ -93,7 +85,7 @@ class ContactHandler extends BaseHandler
$this->saveToDatabase($contactData);
$this->sendNotificationEmail($contactData);
$this->sendRedirect("/contact/success");
- } catch (Exception $e) {
+ } catch (\Exception $e) {
error_log("Error handling contact form submission: " . $e->getMessage());
$this->sendErrorResponse($e->getMessage(), 400);
}
@@ -103,7 +95,7 @@ class ContactHandler extends BaseHandler
{
$referer = $_SERVER["HTTP_REFERER"] ?? "";
$allowedDomain = "coryd.dev";
- if (!str_contains($referer, $allowedDomain)) throw new Exception("Invalid submission origin.");
+ if (!str_contains($referer, $allowedDomain)) throw new \Exception("Invalid submission origin.");
}
private function checkRateLimit(): void
@@ -132,7 +124,7 @@ class ContactHandler extends BaseHandler
private function enforceHttps(): void
{
- if (empty($_SERVER["HTTPS"]) || $_SERVER["HTTPS"] !== "on") throw new Exception("Secure connection required. Use HTTPS.");
+ if (empty($_SERVER["HTTPS"]) || $_SERVER["HTTPS"] !== "on") throw new \Exception("Secure connection required. Use HTTPS.");
}
private function isBlockedDomain(string $email): bool
@@ -171,7 +163,7 @@ class ContactHandler extends BaseHandler
if ($response->getStatusCode() >= 400) {
$errorResponse = json_decode($response->getBody(), true);
- throw new Exception(
+ throw new \Exception(
"PostgREST error: " . ($errorResponse["message"] ?? "Unknown error")
);
}
@@ -204,7 +196,7 @@ class ContactHandler extends BaseHandler
]
);
- if ($response->getStatusCode() >= 400) throw new Exception("Failed to send email notification.");
+ if ($response->getStatusCode() >= 400) throw new \Exception("Failed to send email notification.");
}
private function sendRedirect(string $path): void
@@ -221,7 +213,7 @@ class ContactHandler extends BaseHandler
try {
$handler = new ContactHandler();
$handler->handleRequest();
-} catch (Exception $e) {
+} catch (\Exception $e) {
error_log("Contact form error: " . $e->getMessage());
echo json_encode(["error" => $e->getMessage()]);
http_response_code(500);
diff --git a/api/mastodon.php b/api/mastodon.php
index b1ace34..6019dd7 100644
--- a/api/mastodon.php
+++ b/api/mastodon.php
@@ -7,12 +7,9 @@ use GuzzleHttp\Client;
class MastodonPostHandler extends ApiHandler
{
- protected string $postgrestUrl;
- protected string $postgrestApiKey;
-
private string $mastodonAccessToken;
- private string $rssFeedUrl;
- private string $baseUrl;
+ private string $rssFeedUrl = "https://www.coryd.dev/feeds/syndication.xml";
+ private string $baseUrl = "https://www.coryd.dev";
private const MASTODON_API_STATUS = "https://follow.coryd.dev/api/v1/statuses";
@@ -22,21 +19,11 @@ class MastodonPostHandler extends ApiHandler
{
parent::__construct();
$this->ensureCliAccess();
- $this->loadEnvironment();
- $this->validateAuthorization();
- $this->httpClient = $httpClient ?: new Client();
- }
- private function loadEnvironment(): void
- {
- $this->postgrestUrl =
- getenv("POSTGREST_URL") ?: $_ENV["POSTGREST_URL"] ?? "";
- $this->postgrestApiKey =
- getenv("POSTGREST_API_KEY") ?: $_ENV["POSTGREST_API_KEY"] ?? "";
- $this->mastodonAccessToken =
- getenv("MASTODON_ACCESS_TOKEN") ?: $_ENV["MASTODON_ACCESS_TOKEN"] ?? "";
- $this->rssFeedUrl = "https://www.coryd.dev/feeds/syndication.xml";
- $this->baseUrl = "https://www.coryd.dev";
+ $this->mastodonAccessToken = getenv("MASTODON_ACCESS_TOKEN") ?: $_ENV["MASTODON_ACCESS_TOKEN"] ?? "";
+ $this->httpClient = $httpClient ?: new Client();
+
+ $this->validateAuthorization();
}
private function validateAuthorization(): void
@@ -46,7 +33,7 @@ class MastodonPostHandler extends ApiHandler
if ($authHeader !== $expectedToken) {
http_response_code(401);
- echo json_encode(["error" => "Unauthorized."]);
+ echo json_encode(["error" => "Unauthorized"]);
exit();
}
}
@@ -61,16 +48,16 @@ class MastodonPostHandler extends ApiHandler
$latestItems = $this->fetchRSSFeed($this->rssFeedUrl);
foreach (array_reverse($latestItems) as $item) {
- $existingPost = $this->fetchFromPostgREST("mastodon_posts", "link=eq." . urlencode($item["link"]));
-
- if (!empty($existingPost)) continue;
+ $existing = $this->fetchFromApi("mastodon_posts", "link=eq." . urlencode($item["link"]));
+ if (!empty($existing)) continue;
$content = $this->truncateContent(
$item["title"],
- str_replace(array("\n", "\r"), '', strip_tags($item["description"])),
+ strip_tags($item["description"]),
$item["link"],
500
);
+
$timestamp = date("Y-m-d H:i:s");
if (!$this->storeInDatabase($item["link"], $timestamp)) {
@@ -78,15 +65,11 @@ class MastodonPostHandler extends ApiHandler
continue;
}
- $mastodonPostUrl = $this->postToMastodon($content, $item["image"] ?? null);
-
- if ($mastodonPostUrl) {
- if (strpos($item["link"], $this->baseUrl . "/posts") !== false) {
- $slug = str_replace($this->baseUrl, "", $item["link"]);
- echo "Posted and stored URL: {$item["link"]}\n";
- }
+ $postedUrl = $this->postToMastodon($content, $item["image"] ?? null);
+ if ($postedUrl) {
+ echo "Posted: {$postedUrl}\n";
} else {
- echo "Failed to post to Mastodon. Skipping database update.\n";
+ echo "Failed to post to Mastodon for: {$item["link"]}\n";
}
}
@@ -96,16 +79,16 @@ class MastodonPostHandler extends ApiHandler
private function fetchRSSFeed(string $rssFeedUrl): array
{
$rssText = file_get_contents($rssFeedUrl);
-
- if (!$rssText) throw new Exception("Failed to fetch RSS feed.");
+ if (!$rssText) throw new \Exception("Failed to fetch RSS feed.");
$rss = new \SimpleXMLElement($rssText);
$items = [];
foreach ($rss->channel->item as $item) {
$imageUrl = null;
-
- if ($item->enclosure && isset($item->enclosure['url'])) $imageUrl = (string) $item->enclosure['url'];
+ if ($item->enclosure && isset($item->enclosure['url'])) {
+ $imageUrl = (string) $item->enclosure['url'];
+ }
$items[] = [
"title" => (string) $item->title,
@@ -120,15 +103,13 @@ class MastodonPostHandler extends ApiHandler
private function uploadImageToMastodon(string $imageUrl): ?string
{
- $headers = [
- "Authorization" => "Bearer {$this->mastodonAccessToken}"
- ];
-
$tempFile = tempnam(sys_get_temp_dir(), "mastodon_img");
file_put_contents($tempFile, file_get_contents($imageUrl));
$response = $this->httpClient->request("POST", "https://follow.coryd.dev/api/v2/media", [
- "headers" => $headers,
+ "headers" => [
+ "Authorization" => "Bearer {$this->mastodonAccessToken}"
+ ],
"multipart" => [
[
"name" => "file",
@@ -140,13 +121,12 @@ class MastodonPostHandler extends ApiHandler
unlink($tempFile);
- $statusCode = $response->getStatusCode();
+ if ($response->getStatusCode() !== 200) {
+ throw new \Exception("Image upload failed with status {$response->getStatusCode()}");
+ }
- if ($statusCode !== 200) throw new Exception("Image upload failed with status $statusCode.");
-
- $responseBody = json_decode($response->getBody()->getContents(), true);
-
- return $responseBody["id"] ?? null;
+ $json = json_decode($response->getBody(), true);
+ return $json["id"] ?? null;
}
private function postToMastodon(string $content, ?string $imageUrl = null): ?string
@@ -156,43 +136,42 @@ class MastodonPostHandler extends ApiHandler
"Content-Type" => "application/json",
];
- $mediaIds = [];
+ $postData = ["status" => $content];
if ($imageUrl) {
try {
$mediaId = $this->uploadImageToMastodon($imageUrl);
- if ($mediaId) $mediaIds[] = $mediaId;
- } catch (Exception $e) {
- echo "Image upload failed: " . $e->getMessage() . "\n";
+ if ($mediaId) $postData["media_ids"] = [$mediaId];
+ } catch (\Exception $e) {
+ echo "Image upload failed: {$e->getMessage()}\n";
}
}
- $postData = ["status" => $content];
+ $response = $this->httpClient->request("POST", self::MASTODON_API_STATUS, [
+ "headers" => $headers,
+ "json" => $postData
+ ]);
- if (!empty($mediaIds)) $postData["media_ids"] = $mediaIds;
+ if ($response->getStatusCode() >= 400) {
+ throw new \Exception("Mastodon post failed: {$response->getBody()}");
+ }
- $response = $this->httpRequest(
- self::MASTODON_API_STATUS,
- "POST",
- $headers,
- $postData
- );
-
- return $response["url"] ?? null;
+ $body = json_decode($response->getBody()->getContents(), true);
+ return $body["url"] ?? null;
}
private function storeInDatabase(string $link, string $timestamp): bool
{
- $data = [
- "link" => $link,
- "created_at" => $timestamp,
- ];
-
try {
- $this->fetchFromPostgREST("mastodon_posts", "", "POST", $data);
+ $this->makeRequest("POST", "mastodon_posts", [
+ "json" => [
+ "link" => $link,
+ "created_at" => $timestamp
+ ]
+ ]);
return true;
- } catch (Exception $e) {
- echo "Error storing post in database: " . $e->getMessage() . "\n";
+ } catch (\Exception $e) {
+ echo "Error storing post in DB: " . $e->getMessage() . "\n";
return false;
}
}
@@ -200,70 +179,32 @@ class MastodonPostHandler extends ApiHandler
private function isDatabaseAvailable(): bool
{
try {
- $response = $this->fetchFromPostgREST("mastodon_posts", "limit=1");
+ $response = $this->fetchFromApi("mastodon_posts", "limit=1");
return is_array($response);
- } catch (Exception $e) {
+ } catch (\Exception $e) {
echo "Database check failed: " . $e->getMessage() . "\n";
return false;
}
}
- private function truncateContent(
- string $title,
- string $description,
- string $link,
- int $maxLength
- ): string {
+ private function truncateContent(string $title, string $description, string $link, int $maxLength): string
+ {
$baseLength = strlen("$title\n\n$link");
- $availableSpace = $maxLength - $baseLength - 4;
+ $available = $maxLength - $baseLength - 4;
- if (strlen($description) > $availableSpace) {
- $description = substr($description, 0, $availableSpace);
+ if (strlen($description) > $available) {
+ $description = substr($description, 0, $available);
$description = preg_replace('/\s+\S*$/', "", $description) . "...";
}
return "$title\n\n$description\n\n$link";
}
-
- private function httpRequest(
- string $url,
- string $method = "GET",
- array $headers = [],
- ?array $data = null
- ): array {
- $options = ["headers" => $headers];
-
- if ($data) $options["json"] = $data;
-
- $response = $this->httpClient->request($method, $url, $options);
- $statusCode = $response->getStatusCode();
-
- if ($statusCode >= 400) throw new Exception("HTTP error $statusCode: " . $response->getBody());
-
- $responseBody = $response->getBody()->getContents();
-
- if (empty($responseBody)) return [];
-
- $decodedResponse = json_decode($responseBody, true);
-
- if (!is_array($decodedResponse)) return [];
-
- return $decodedResponse;
- }
-
- private function getPostgRESTHeaders(): array
- {
- return [
- "Authorization" => "Bearer {$this->postgrestApiKey}",
- "Content-Type" => "application/json",
- ];
- }
}
try {
$handler = new MastodonPostHandler();
$handler->handlePost();
-} catch (Exception $e) {
+} catch (\Exception $e) {
http_response_code(500);
echo json_encode(["error" => $e->getMessage()]);
}
diff --git a/api/playing.php b/api/playing.php
index 2e41507..7cb8506 100644
--- a/api/playing.php
+++ b/api/playing.php
@@ -41,7 +41,7 @@ class LatestListenHandler extends BaseHandler
);
$this->sendResponse($latestListen);
- } catch (Exception $e) {
+ } catch (\Exception $e) {
error_log("LatestListenHandler Error: " . $e->getMessage());
$this->sendErrorResponse(
"Internal Server Error: " . $e->getMessage(),
diff --git a/api/proxy.php b/api/proxy.php
new file mode 100644
index 0000000..090e65a
--- /dev/null
+++ b/api/proxy.php
@@ -0,0 +1,90 @@
+ensureAllowedOrigin();
+ }
+
+ protected function ensureAllowedOrigin(): void
+ {
+ $allowedHosts = ['coryd.dev', 'www.coryd.dev'];
+ $origin = $_SERVER['HTTP_ORIGIN'] ?? '';
+ $referer = $_SERVER['HTTP_REFERER'] ?? '';
+
+ $hostAllowed = fn($url) => in_array(parse_url($url, PHP_URL_HOST), $allowedHosts, true);
+
+ if (!$hostAllowed($origin) && !$hostAllowed($referer)) $this->sendErrorResponse("Forbidden — invalid origin", 403);
+
+ $allowedSource = $origin ?: $referer;
+ $scheme = parse_url($allowedSource, PHP_URL_SCHEME) ?? 'https';
+ $host = parse_url($allowedSource, PHP_URL_HOST);
+
+ header("Access-Control-Allow-Origin: {$scheme}://{$host}");
+ header("Access-Control-Allow-Headers: Content-Type");
+ header("Access-Control-Allow-Methods: GET, POST");
+ }
+
+ public function handleRequest(): void
+ {
+ $data = $_GET['data'] ?? null;
+ $id = $_GET['id'] ?? null;
+ $cacheDuration = intval($_GET['cacheDuration'] ?? 3600);
+
+ if (!$data) $this->sendErrorResponse("Missing 'data' parameter", 400);
+
+ $cacheKey = $this->buildCacheKey($data, $id);
+
+ if ($this->cache) {
+ $cached = $this->cache->get($cacheKey);
+ if ($cached) {
+ header('Content-Type: application/json');
+ echo $cached;
+ exit();
+ }
+ }
+
+ $query = $id ? "id=eq.$id" : "";
+
+ try {
+ $response = $this->fetchFromApi($data, $query);
+ $markdownFields = $_GET['markdown'] ?? [];
+ $markdownFields = is_array($markdownFields)
+ ? $markdownFields
+ : explode(',', $markdownFields);
+ $markdownFields = array_map('trim', array_filter($markdownFields));
+
+ if (!empty($response) && !empty($markdownFields)) {
+ foreach ($markdownFields as $field) {
+ if (!empty($response[0][$field])) $response[0]["{$field}_html"] = parseMarkdown($response[0][$field]);
+ }
+ }
+
+ $json = json_encode($response);
+
+ if ($this->cache) {
+ $this->cache->setex($cacheKey, $cacheDuration, $json);
+ }
+
+ header('Content-Type: application/json');
+ echo $json;
+ } catch (\Exception $e) {
+ $this->sendErrorResponse("PostgREST fetch failed: " . $e->getMessage(), 500);
+ }
+ }
+
+ private function buildCacheKey(string $data, ?string $id): string
+ {
+ return "proxy_{$data}" . ($id ? "_{$id}" : "");
+ }
+}
+
+$handler = new ProxyHandler();
+$handler->handleRequest();
diff --git a/api/scrobble.php b/api/scrobble.php
index 497fa0b..80353a2 100644
--- a/api/scrobble.php
+++ b/api/scrobble.php
@@ -10,13 +10,8 @@ use GuzzleHttp\Client;
header("Content-Type: application/json");
-$authHeader = $_SERVER["HTTP_AUTHORIZATION"] ?? "";
-$expectedToken = "Bearer " . getenv("NAVIDROME_SCROBBLE_TOKEN");
-
class NavidromeScrobbleHandler extends ApiHandler
{
- private string $postgrestApiUrl;
- private string $postgrestApiToken;
private string $navidromeApiUrl;
private string $navidromeAuthToken;
private string $forwardEmailApiKey;
@@ -28,14 +23,12 @@ class NavidromeScrobbleHandler extends ApiHandler
{
parent::__construct();
$this->ensureCliAccess();
- $this->loadEnvironment();
+ $this->loadExternalServiceKeys();
$this->validateAuthorization();
}
- private function loadEnvironment(): void
+ private function loadExternalServiceKeys(): void
{
- $this->postgrestApiUrl = getenv("POSTGREST_URL");
- $this->postgrestApiToken = getenv("POSTGREST_API_KEY");
$this->navidromeApiUrl = getenv("NAVIDROME_API_URL");
$this->navidromeAuthToken = getenv("NAVIDROME_API_TOKEN");
$this->forwardEmailApiKey = getenv("FORWARDEMAIL_API_KEY");
@@ -95,7 +88,7 @@ class NavidromeScrobbleHandler extends ApiHandler
private function isTrackAlreadyScrobbled(array $track): bool
{
$playDate = strtotime($track["playDate"]);
- $existingListen = $this->fetchFromPostgREST("listens", "listened_at=eq.{$playDate}&limit=1");
+ $existingListen = $this->fetchFromApi("listens", "listened_at=eq.{$playDate}&limit=1");
return !empty($existingListen);
}
@@ -121,61 +114,52 @@ class NavidromeScrobbleHandler extends ApiHandler
private function getOrCreateArtist(string $artistName): array
{
- if (!$this->isDatabaseAvailable()) {
- error_log("Skipping artist insert: database is unavailable.");
- return [];
- }
+ if (!$this->isDatabaseAvailable()) return [];
if (isset($this->artistCache[$artistName])) return $this->artistCache[$artistName];
$encodedArtist = rawurlencode($artistName);
- $existingArtist = $this->fetchFromPostgREST("artists", "name_string=eq.{$encodedArtist}&limit=1");
+ $existingArtist = $this->fetchFromApi("artists", "name_string=eq.{$encodedArtist}&limit=1");
if (!empty($existingArtist)) {
- $this->artistCache[$artistName] = $existingArtist[0];
- return $existingArtist[0];
+ return $this->artistCache[$artistName] = $existingArtist[0];
}
- $this->fetchFromPostgREST("artists", "", "POST", [
- "mbid" => "",
- "art" => "4cef75db-831f-4f5d-9333-79eaa5bb55ee",
- "name_string" => $artistName,
- "slug" => "/music",
- "country" => "",
- "description" => "",
- "tentative" => true,
- "favorite" => false,
- "tattoo" => false,
- "total_plays" => 0
+ $this->makeRequest("POST", "artists", [
+ "json" => [
+ "mbid" => "",
+ "art" => "4cef75db-831f-4f5d-9333-79eaa5bb55ee",
+ "name_string" => $artistName,
+ "slug" => "/music",
+ "country" => "",
+ "description" => "",
+ "tentative" => true,
+ "favorite" => false,
+ "tattoo" => false,
+ "total_plays" => 0
+ ]
]);
-
$this->sendFailureEmail("New tentative artist record", "A new tentative artist record was inserted for: $artistName");
- $artistData = $this->fetchFromPostgREST("artists", "name_string=eq.{$encodedArtist}&limit=1");
+ $artistData = $this->fetchFromApi("artists", "name_string=eq.{$encodedArtist}&limit=1");
- $this->artistCache[$artistName] = $artistData[0] ?? [];
-
- return $this->artistCache[$artistName];
+ return $this->artistCache[$artistName] = $artistData[0] ?? [];
}
private function getOrCreateAlbum(string $albumName, array $artistData): array
{
- if (!$this->isDatabaseAvailable()) {
- error_log("Skipping album insert: database is unavailable.");
- return [];
- }
+ if (!$this->isDatabaseAvailable()) return [];
$albumKey = $this->generateAlbumKey($artistData["name_string"], $albumName);
if (isset($this->albumCache[$albumKey])) return $this->albumCache[$albumKey];
$encodedAlbumKey = rawurlencode($albumKey);
- $existingAlbum = $this->fetchFromPostgREST("albums", "key=eq.{$encodedAlbumKey}&limit=1");
+ $existingAlbum = $this->fetchFromApi("albums", "key=eq.{$encodedAlbumKey}&limit=1");
if (!empty($existingAlbum)) {
- $this->albumCache[$albumKey] = $existingAlbum[0];
- return $existingAlbum[0];
+ return $this->albumCache[$albumKey] = $existingAlbum[0];
}
$artistId = $artistData["id"] ?? null;
@@ -185,35 +169,37 @@ class NavidromeScrobbleHandler extends ApiHandler
return [];
}
- $this->fetchFromPostgREST("albums", "", "POST", [
- "mbid" => null,
- "art" => "4cef75db-831f-4f5d-9333-79eaa5bb55ee",
- "key" => $albumKey,
- "name" => $albumName,
- "tentative" => true,
- "total_plays" => 0,
- "artist" => $artistId
+ $this->makeRequest("POST", "albums", [
+ "json" => [
+ "mbid" => null,
+ "art" => "4cef75db-831f-4f5d-9333-79eaa5bb55ee",
+ "key" => $albumKey,
+ "name" => $albumName,
+ "tentative" => true,
+ "total_plays" => 0,
+ "artist" => $artistId
+ ]
]);
$this->sendFailureEmail("New tentative album record", "A new tentative album record was inserted:\n\nAlbum: $albumName\nKey: $albumKey");
- $albumData = $this->fetchFromPostgREST("albums", "key=eq.{$encodedAlbumKey}&limit=1");
+ $albumData = $this->fetchFromApi("albums", "key=eq.{$encodedAlbumKey}&limit=1");
- $this->albumCache[$albumKey] = $albumData[0] ?? [];
-
- return $this->albumCache[$albumKey];
+ return $this->albumCache[$albumKey] = $albumData[0] ?? [];
}
private function insertListen(array $track, string $albumKey): void
{
$playDate = strtotime($track["playDate"]);
- $this->fetchFromPostgREST("listens", "", "POST", [
- "artist_name" => $track["artist"],
- "album_name" => $track["album"],
- "track_name" => $track["title"],
- "listened_at" => $playDate,
- "album_key" => $albumKey
+ $this->makeRequest("POST", "listens", [
+ "json" => [
+ "artist_name" => $track["artist"],
+ "album_name" => $track["album"],
+ "track_name" => $track["title"],
+ "listened_at" => $playDate,
+ "album_key" => $albumKey
+ ]
]);
}
@@ -221,24 +207,18 @@ class NavidromeScrobbleHandler extends ApiHandler
{
$artistKey = sanitizeMediaString($artistName);
$albumKey = sanitizeMediaString($albumName);
-
return "{$artistKey}-{$albumKey}";
}
private function sendFailureEmail(string $subject, string $message): void
{
- if (!$this->isDatabaseAvailable()) {
- error_log("Skipping email: database is unavailable.");
- return;
- }
+ if (!$this->isDatabaseAvailable()) return;
$authHeader = "Basic " . base64_encode($this->forwardEmailApiKey . ":");
- $client = new Client([
- "base_uri" => "https://api.forwardemail.net/",
- ]);
+ $client = new Client(["base_uri" => "https://api.forwardemail.net/"]);
try {
- $response = $client->post("v1/emails", [
+ $client->post("v1/emails", [
"headers" => [
"Authorization" => $authHeader,
"Content-Type" => "application/x-www-form-urlencoded",
@@ -250,12 +230,10 @@ class NavidromeScrobbleHandler extends ApiHandler
"text" => $message,
],
]);
-
} catch (\GuzzleHttp\Exception\RequestException $e) {
error_log("Request Exception: " . $e->getMessage());
if ($e->hasResponse()) {
- $errorResponse = (string) $e->getResponse()->getBody();
- error_log("Error Response: " . $errorResponse);
+ error_log("Error Response: " . (string) $e->getResponse()->getBody());
}
} catch (\Exception $e) {
error_log("General Exception: " . $e->getMessage());
@@ -265,9 +243,9 @@ class NavidromeScrobbleHandler extends ApiHandler
private function isDatabaseAvailable(): bool
{
try {
- $response = $this->fetchFromPostgREST("listens", "limit=1");
+ $response = $this->fetchFromApi("listens", "limit=1");
return is_array($response);
- } catch (Exception $e) {
+ } catch (\Exception $e) {
error_log("Database check failed: " . $e->getMessage());
return false;
}
@@ -277,7 +255,7 @@ class NavidromeScrobbleHandler extends ApiHandler
try {
$handler = new NavidromeScrobbleHandler();
$handler->runScrobbleCheck();
-} catch (Exception $e) {
+} catch (\Exception $e) {
http_response_code(500);
echo json_encode(["error" => $e->getMessage()]);
}
diff --git a/api/search.php b/api/search.php
index 42b77e6..c313955 100644
--- a/api/search.php
+++ b/api/search.php
@@ -47,7 +47,7 @@ class SearchHandler extends BaseHandler
],
200
);
- } catch (Exception $e) {
+ } catch (\Exception $e) {
error_log("Search API Error: " . $e->getMessage());
$this->sendErrorResponse("Invalid request. Please check your query and try again.", 400);
}
@@ -55,15 +55,15 @@ class SearchHandler extends BaseHandler
private function validateAndSanitizeQuery(?string $query): string
{
- if (empty($query) || !is_string($query)) throw new Exception("Invalid 'q' parameter. Must be a non-empty string.");
+ if (empty($query) || !is_string($query)) throw new \Exception("Invalid 'q' parameter. Must be a non-empty string.");
$query = trim($query);
- if (strlen($query) > 255) throw new Exception(
+ if (strlen($query) > 255) throw new \Exception(
"Invalid 'q' parameter. Exceeds maximum length of 255 characters."
);
- if (!preg_match('/^[a-zA-Z0-9\s\-_\'"]+$/', $query)) throw new Exception(
+ if (!preg_match('/^[a-zA-Z0-9\s\-_\'"]+$/', $query)) throw new \Exception(
"Invalid 'q' parameter. Contains unsupported characters."
);
diff --git a/api/seasons-import.php b/api/seasons-import.php
index cb3b733..2e18bd4 100644
--- a/api/seasons-import.php
+++ b/api/seasons-import.php
@@ -7,9 +7,6 @@ use GuzzleHttp\Client;
class SeasonImportHandler extends ApiHandler
{
- protected string $postgrestUrl;
- protected string $postgrestApiKey;
-
private string $tmdbApiKey;
private string $seasonsImportToken;
@@ -17,62 +14,43 @@ class SeasonImportHandler extends ApiHandler
{
parent::__construct();
$this->ensureCliAccess();
- $this->loadEnvironment();
- $this->authenticateRequest();
- }
- private function loadEnvironment(): void
- {
- $this->postgrestUrl = getenv("POSTGREST_URL") ?: $_ENV["POSTGREST_URL"];
- $this->postgrestApiKey = getenv("POSTGREST_API_KEY") ?: $_ENV["POSTGREST_API_KEY"];
$this->tmdbApiKey = getenv("TMDB_API_KEY") ?: $_ENV["TMDB_API_KEY"];
$this->seasonsImportToken = getenv("SEASONS_IMPORT_TOKEN") ?: $_ENV["SEASONS_IMPORT_TOKEN"];
+
+ $this->authenticateRequest();
}
private function authenticateRequest(): void
{
if ($_SERVER["REQUEST_METHOD"] !== "POST") {
- http_response_code(405);
- echo json_encode(["error" => "Method Not Allowed"]);
- exit();
+ $this->sendErrorResponse("Method Not Allowed", 405);
}
$authHeader = $_SERVER["HTTP_AUTHORIZATION"] ?? "";
if (!preg_match('/Bearer\s+(.+)/', $authHeader, $matches)) {
- http_response_code(401);
- echo json_encode(["error" => "Unauthorized"]);
- exit();
+ $this->sendErrorResponse("Unauthorized", 401);
}
$providedToken = trim($matches[1]);
if ($providedToken !== $this->seasonsImportToken) {
- http_response_code(403);
- echo json_encode(["error" => "Forbidden"]);
- exit();
+ $this->sendErrorResponse("Forbidden", 403);
}
}
public function importSeasons(): void
{
- $ongoingShows = $this->fetchOngoingShows();
+ $ongoingShows = $this->fetchFromApi("optimized_shows", "ongoing=eq.true");
if (empty($ongoingShows)) {
- http_response_code(200);
- echo json_encode(["message" => "No ongoing shows to update"]);
- return;
+ $this->sendResponse(["message" => "No ongoing shows to update"], 200);
}
foreach ($ongoingShows as $show) {
$this->processShowSeasons($show);
}
- http_response_code(200);
- echo json_encode(["message" => "Season import completed"]);
- }
-
- private function fetchOngoingShows(): array
- {
- return $this->fetchFromPostgREST("optimized_shows", "ongoing=eq.true", "GET");
+ $this->sendResponse(["message" => "Season import completed"], 200);
}
private function processShowSeasons(array $show): void
@@ -98,8 +76,7 @@ class SeasonImportHandler extends ApiHandler
private function shouldKeepOngoing(string $status): bool
{
- $validStatuses = ["Returning Series", "In Production"];
- return in_array($status, $validStatuses);
+ return in_array($status, ["Returning Series", "In Production"]);
}
private function fetchShowDetails(string $tmdbId): array
@@ -117,49 +94,40 @@ class SeasonImportHandler extends ApiHandler
private function fetchWatchedEpisodes(int $showId): array
{
- $watchedEpisodes = $this->fetchFromPostgREST(
- "optimized_last_watched_episodes",
- "show_id=eq.{$showId}&order=last_watched_at.desc&limit=1",
- "GET"
- );
+ $episodes = $this->fetchFromApi("optimized_last_watched_episodes", "show_id=eq.{$showId}&order=last_watched_at.desc&limit=1");
- if (empty($watchedEpisodes)) return [];
+ if (empty($episodes)) return [];
- $lastWatched = $watchedEpisodes[0] ?? null;
-
- if ($lastWatched) return [
- "season_number" => (int) $lastWatched["season_number"],
- "episode_number" => (int) $lastWatched["episode_number"]
- ];
-
- return [];
+ return [
+ "season_number" => (int) $episodes[0]["season_number"],
+ "episode_number" => (int) $episodes[0]["episode_number"],
+ ];
}
private function processSeasonEpisodes(int $showId, string $tmdbId, array $season): void
{
$seasonNumber = $season["season_number"] ?? null;
-
if ($seasonNumber === null || $seasonNumber == 0) return;
$episodes = $this->fetchSeasonEpisodes($tmdbId, $seasonNumber);
-
if (empty($episodes)) return;
- $watchedEpisodes = $this->fetchWatchedEpisodes($showId);
- $lastWatchedSeason = $watchedEpisodes["season_number"] ?? null;
- $lastWatchedEpisode = $watchedEpisodes["episode_number"] ?? null;
- $scheduledEpisodes = $this->fetchFromPostgREST(
- "optimized_scheduled_episodes",
- "show_id=eq.{$showId}&season_number=eq.{$seasonNumber}",
- "GET"
+ $watched = $this->fetchWatchedEpisodes($showId);
+ $lastWatchedSeason = $watched["season_number"] ?? null;
+ $lastWatchedEpisode = $watched["episode_number"] ?? null;
+
+ $scheduled = $this->fetchFromApi(
+ "optimized_scheduled_episodes",
+ "show_id=eq.{$showId}&season_number=eq.{$seasonNumber}"
);
- $scheduledEpisodeNumbers = array_column($scheduledEpisodes, "episode_number");
+
+ $scheduledEpisodeNumbers = array_column($scheduled, "episode_number");
foreach ($episodes as $episode) {
$episodeNumber = $episode["episode_number"] ?? null;
-
if ($episodeNumber === null) continue;
if (in_array($episodeNumber, $scheduledEpisodeNumbers)) continue;
+
if ($lastWatchedSeason !== null && $seasonNumber < $lastWatchedSeason) return;
if ($seasonNumber == $lastWatchedSeason && $episodeNumber <= $lastWatchedEpisode) continue;
@@ -183,11 +151,10 @@ class SeasonImportHandler extends ApiHandler
private function addEpisodeToSchedule(int $showId, int $seasonNumber, array $episode): void
{
$airDate = $episode["air_date"] ?? null;
-
if (!$airDate) return;
- $currentDate = date("Y-m-d");
- $status = ($airDate && $airDate < $currentDate) ? "aired" : "upcoming";
+ $today = date("Y-m-d");
+ $status = ($airDate < $today) ? "aired" : "upcoming";
$payload = [
"show_id" => $showId,
@@ -197,7 +164,7 @@ class SeasonImportHandler extends ApiHandler
"status" => $status,
];
- $this->fetchFromPostgREST("scheduled_episodes", "", "POST", $payload);
+ $this->makeRequest("POST", "scheduled_episodes", ["json" => $payload]);
}
}
diff --git a/api/watching-import.php b/api/watching-import.php
index 1c3ea06..dc9283f 100644
--- a/api/watching-import.php
+++ b/api/watching-import.php
@@ -7,9 +7,6 @@ use GuzzleHttp\Client;
class WatchingImportHandler extends ApiHandler
{
- protected string $postgrestUrl;
- protected string $postgrestApiKey;
-
private string $tmdbApiKey;
private string $tmdbImportToken;
@@ -17,17 +14,9 @@ class WatchingImportHandler extends ApiHandler
{
parent::__construct();
$this->ensureCliAccess();
- $this->loadEnvironment();
- }
- private function loadEnvironment(): void
- {
- $this->postgrestUrl = $_ENV["POSTGREST_URL"] ?? getenv("POSTGREST_URL");
- $this->postgrestApiKey =
- $_ENV["POSTGREST_API_KEY"] ?? getenv("POSTGREST_API_KEY");
$this->tmdbApiKey = $_ENV["TMDB_API_KEY"] ?? getenv("TMDB_API_KEY");
- $this->tmdbImportToken =
- $_ENV["WATCHING_IMPORT_TOKEN"] ?? getenv("WATCHING_IMPORT_TOKEN");
+ $this->tmdbImportToken = $_ENV["WATCHING_IMPORT_TOKEN"] ?? getenv("WATCHING_IMPORT_TOKEN");
}
public function handleRequest(): void
@@ -37,19 +26,22 @@ class WatchingImportHandler extends ApiHandler
if (!$input) $this->sendErrorResponse("Invalid or missing JSON body", 400);
$providedToken = $input["token"] ?? null;
-
- if (!$providedToken || $providedToken !== $this->tmdbImportToken) $this->sendErrorResponse("Unauthorized access", 401);
-
$tmdbId = $input["tmdb_id"] ?? null;
$mediaType = $input["media_type"] ?? null;
- if (!$tmdbId || !$mediaType) $this->sendErrorResponse("tmdb_id and media_type are required", 400);
+ if ($providedToken !== $this->tmdbImportToken) {
+ $this->sendErrorResponse("Unauthorized access", 401);
+ }
+
+ if (!$tmdbId || !$mediaType) {
+ $this->sendErrorResponse("tmdb_id and media_type are required", 400);
+ }
try {
$mediaData = $this->fetchTMDBData($tmdbId, $mediaType);
$this->processMedia($mediaData, $mediaType);
- $this->sendResponse("Media imported successfully", 200);
- } catch (Exception $e) {
+ $this->sendResponse(["message" => "Media imported successfully"], 200);
+ } catch (\Exception $e) {
$this->sendErrorResponse("Error: " . $e->getMessage(), 500);
}
}
@@ -65,8 +57,7 @@ class WatchingImportHandler extends ApiHandler
]);
$data = json_decode($response->getBody(), true);
-
- if (empty($data)) throw new Exception("No data found for TMDB ID: {$tmdbId}");
+ if (empty($data)) throw new \Exception("No data found for TMDB ID: {$tmdbId}");
return $data;
}
@@ -75,18 +66,19 @@ class WatchingImportHandler extends ApiHandler
{
$id = $mediaData["id"];
$title = $mediaType === "movie" ? $mediaData["title"] : $mediaData["name"];
- $year =
- $mediaData["release_date"] ?? ($mediaData["first_air_date"] ?? null);
+ $year = $mediaData["release_date"] ?? $mediaData["first_air_date"] ?? null;
$year = $year ? substr($year, 0, 4) : null;
$description = $mediaData["overview"] ?? "";
+
$tags = array_map(
fn($genre) => strtolower(trim($genre["name"])),
- $mediaData["genres"]
+ $mediaData["genres"] ?? []
);
- $slug =
- $mediaType === "movie"
- ? "/watching/movies/{$id}"
- : "/watching/shows/{$id}";
+
+ $slug = $mediaType === "movie"
+ ? "/watching/movies/{$id}"
+ : "/watching/shows/{$id}";
+
$payload = [
"title" => $title,
"year" => $year,
@@ -94,80 +86,64 @@ class WatchingImportHandler extends ApiHandler
"tmdb_id" => $id,
"slug" => $slug,
];
- $response = $this->fetchFromPostgREST(
- $mediaType === "movie" ? "movies" : "shows",
- "",
- "POST",
- $payload
- );
- if (empty($response["id"])) {
- $queryResponse = $this->fetchFromPostgREST(
- $mediaType === "movie" ? "movies" : "shows",
- "tmdb_id=eq.{$id}",
- "GET"
- );
- $response = $queryResponse[0] ?? [];
+ $table = $mediaType === "movie" ? "movies" : "shows";
+
+ try {
+ $response = $this->makeRequest("POST", $table, ["json" => $payload]);
+ } catch (\Exception $e) {
+ $response = $this->fetchFromApi($table, "tmdb_id=eq.{$id}")[0] ?? [];
}
if (!empty($response["id"])) {
$mediaId = $response["id"];
$existingTagMap = $this->getTagIds($tags);
$updatedTagMap = $this->insertMissingTags($tags, $existingTagMap);
- $this->associateTagsWithMedia(
- $mediaType,
- $mediaId,
- array_values($updatedTagMap)
- );
+ $this->associateTagsWithMedia($mediaType, $mediaId, array_values($updatedTagMap));
}
}
private function getTagIds(array $tags): array
{
- $existingTagMap = [];
+ $map = [];
+
foreach ($tags as $tag) {
- $query = "name=ilike." . urlencode($tag);
- $existingTags = $this->fetchFromPostgREST("tags", $query, "GET");
-
- if (!empty($existingTags[0]["id"])) $existingTagMap[strtolower($tag)] = $existingTags[0]["id"];
- }
- return $existingTagMap;
- }
-
- private function insertMissingTags(array $tags, array $existingTagMap): array
- {
- $newTags = array_diff($tags, array_keys($existingTagMap));
- foreach ($newTags as $newTag) {
- try {
- $response = $this->fetchFromPostgREST("tags", "", "POST", [
- "name" => $newTag,
- ]);
- if (!empty($response["id"])) $existingTagMap[$newTag] = $response["id"];
- } catch (Exception $e) {
- $queryResponse = $this->fetchFromPostgREST(
- "tags",
- "name=eq.{$newTag}",
- "GET"
- );
- if (!empty($queryResponse[0]["id"])) $existingTagMap[$newTag] = $queryResponse[0]["id"];
+ $response = $this->fetchFromApi("tags", "name=ilike." . urlencode($tag));
+ if (!empty($response[0]["id"])) {
+ $map[strtolower($tag)] = $response[0]["id"];
}
}
- return $existingTagMap;
+
+ return $map;
}
- private function associateTagsWithMedia(
- string $mediaType,
- int $mediaId,
- array $tagIds
- ): void {
- $junctionTable = $mediaType === "movie" ? "movies_tags" : "shows_tags";
+ private function insertMissingTags(array $tags, array $existingMap): array
+ {
+ $newTags = array_diff($tags, array_keys($existingMap));
+
+ foreach ($newTags as $tag) {
+ try {
+ $created = $this->makeRequest("POST", "tags", ["json" => ["name" => $tag]]);
+ if (!empty($created["id"])) $existingMap[$tag] = $created["id"];
+ } catch (\Exception $e) {
+ $fallback = $this->fetchFromApi("tags", "name=eq." . urlencode($tag));
+ if (!empty($fallback[0]["id"])) $existingMap[$tag] = $fallback[0]["id"];
+ }
+ }
+
+ return $existingMap;
+ }
+
+ private function associateTagsWithMedia(string $mediaType, int $mediaId, array $tagIds): void
+ {
+ $junction = $mediaType === "movie" ? "movies_tags" : "shows_tags";
$mediaColumn = $mediaType === "movie" ? "movies_id" : "shows_id";
foreach ($tagIds as $tagId) {
- $this->fetchFromPostgREST($junctionTable, "", "POST", [
+ $this->makeRequest("POST", $junction, ["json" => [
$mediaColumn => $mediaId,
- "tags_id" => $tagId,
- ]);
+ "tags_id" => $tagId
+ ]]);
}
}
}
diff --git a/package-lock.json b/package-lock.json
index 3f581ad..33183a9 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "coryd.dev",
- "version": "2.1.4",
+ "version": "3.0.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "coryd.dev",
- "version": "2.1.4",
+ "version": "3.0.3",
"license": "MIT",
"dependencies": {
"html-minifier-terser": "7.2.0",
@@ -184,9 +184,9 @@
}
},
"node_modules/@11ty/eleventy-plugin-bundle": {
- "version": "3.0.4",
- "resolved": "https://registry.npmjs.org/@11ty/eleventy-plugin-bundle/-/eleventy-plugin-bundle-3.0.4.tgz",
- "integrity": "sha512-9Y9aLB5kwK7dkTC+Pfbt4EEs58TMQjuo1+EJ18dA/XKDxczHj2fAUZcETMgNQ17AmrMDj5HxJ0ezFNGpMcD7Vw==",
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/@11ty/eleventy-plugin-bundle/-/eleventy-plugin-bundle-3.0.5.tgz",
+ "integrity": "sha512-LfcXr5pvvFjA6k1u8o0vqxbFVY8elpxIeICvdJti9FWUbHyJlS6ydRkyUnijpa+NTsj7DrlcrD1r1uBrANHYeA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -3191,13 +3191,13 @@
"license": "MIT"
},
"node_modules/parse5": {
- "version": "7.2.1",
- "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.2.1.tgz",
- "integrity": "sha512-BuBYQYlv1ckiPdQi/ohiivi9Sagc9JG+Ozs0r7b/0iK3sKmrb0b9FdWdBbOdx6hBCM/F9Ir82ofnBhtZOjCRPQ==",
+ "version": "7.3.0",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
+ "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "entities": "^4.5.0"
+ "entities": "^6.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
@@ -3234,9 +3234,9 @@
}
},
"node_modules/parse5/node_modules/entities": {
- "version": "4.5.0",
- "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
- "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.0.tgz",
+ "integrity": "sha512-aKstq2TDOndCn4diEyp9Uq/Flu2i1GlLkc6XIDQSDMuaFE3OPW5OphLCyQ5SpSJZTb4reN+kTcYru5yIfXoRPw==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
diff --git a/package.json b/package.json
index 8538385..1dbb96f 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "coryd.dev",
- "version": "2.1.4",
+ "version": "3.0.3",
"description": "The source for my personal site. Built using 11ty (and other tools).",
"type": "module",
"engines": {
diff --git a/queries/views/feeds/recent_activity.psql b/queries/views/feeds/recent_activity.psql
index 2edfdbb..4475c61 100644
--- a/queries/views/feeds/recent_activity.psql
+++ b/queries/views/feeds/recent_activity.psql
@@ -1,6 +1,7 @@
CREATE OR REPLACE VIEW optimized_recent_activity AS
WITH activity_data AS (
SELECT
+ NULL::bigint AS id,
p.date AS content_date,
p.title,
p.content AS description,
@@ -22,6 +23,7 @@ WITH activity_data AS (
UNION ALL
SELECT
+ NULL::bigint AS id,
l.date AS content_date,
l.title,
l.description,
@@ -43,6 +45,7 @@ WITH activity_data AS (
UNION ALL
SELECT
+ NULL::bigint AS id,
b.date_finished AS content_date,
CONCAT(b.title,
CASE WHEN b.rating IS NOT NULL THEN CONCAT(' (', b.rating, ')') ELSE '' END
@@ -67,6 +70,7 @@ WITH activity_data AS (
UNION ALL
SELECT
+ NULL::bigint AS id,
m.last_watched AS content_date,
CONCAT(m.title,
CASE WHEN m.rating IS NOT NULL THEN CONCAT(' (', m.rating, ')') ELSE '' END
@@ -91,6 +95,7 @@ WITH activity_data AS (
UNION ALL
SELECT
+ c.id,
c.date AS content_date,
CONCAT(c.artist->>'name', ' at ', c.venue->>'name_short') AS title,
c.concert_notes AS description,
@@ -104,7 +109,7 @@ WITH activity_data AS (
c.venue->>'latitude' AS venue_lat,
c.venue->>'longitude' AS venue_lon,
c.venue->>'name_short' AS venue_name,
- c.notes AS notes,
+ c.concert_notes AS notes,
'concerts' AS type,
'Concert' AS label
FROM optimized_concerts c
diff --git a/queries/views/media/music/concerts.psql b/queries/views/media/music/concerts.psql
index 4111299..c577821 100644
--- a/queries/views/media/music/concerts.psql
+++ b/queries/views/media/music/concerts.psql
@@ -2,7 +2,6 @@ CREATE OR REPLACE VIEW optimized_concerts AS
SELECT
c.id,
c.date,
- c.notes,
CASE WHEN c.artist IS NOT NULL THEN
json_build_object('name', a.name_string, 'url', a.slug)
ELSE
@@ -16,4 +15,3 @@ FROM
LEFT JOIN venues v ON c.venue = v.id
ORDER BY
c.date DESC;
-
diff --git a/server/utils/strings.php b/server/utils/strings.php
index 536306f..fc7f85c 100644
--- a/server/utils/strings.php
+++ b/server/utils/strings.php
@@ -1,7 +1,5 @@
{
// dialog controls
(() => {
- if (document.querySelectorAll(".dialog-open").length) {
- document.querySelectorAll(".dialog-open").forEach((button) => {
- const dialogId = button.getAttribute("data-dialog-trigger");
- const dialog = document.getElementById(`dialog-${dialogId}`);
+ const dialogButtons = document.querySelectorAll(".dialog-open");
+ if (!dialogButtons.length) return;
- if (!dialog) return;
+ dialogButtons.forEach((button) => {
+ const dialogId = button.getAttribute("data-dialog-trigger");
+ const dialog = document.getElementById(`dialog-${dialogId}`);
+ if (!dialog) return;
- const closeButton = dialog.querySelector(".dialog-close");
+ const closeButton = dialog.querySelector(".dialog-close");
- button.addEventListener("click", () => {
- dialog.showModal();
- dialog.classList.remove("closing");
- });
+ button.addEventListener("click", async () => {
+ const isDynamic = dialog.dataset.dynamic;
+ const isLoaded = dialog.dataset.loaded;
- if (closeButton)
- closeButton.addEventListener("click", () => {
- dialog.classList.add("closing");
- setTimeout(() => dialog.close(), 200);
- });
+ if (isDynamic && !isLoaded) {
+ const markdownFields = dialog.dataset.markdown || "";
+ try {
+ const res = await fetch(`/api/proxy.php?data=${isDynamic}&id=${dialogId}&markdown=${encodeURIComponent(markdownFields)}`);
+ const [data] = await res.json();
+ const firstField = markdownFields.split(",")[0]?.trim();
+ const html = data?.[`${firstField}_html`] || "No notes available.
";
- dialog.addEventListener("click", (event) => {
- const rect = dialog.getBoundingClientRect();
+ dialog.querySelectorAll(".dialog-dynamic").forEach((el) => el.remove());
- if (
- event.clientX < rect.left ||
- event.clientX > rect.right ||
- event.clientY < rect.top ||
- event.clientY > rect.bottom
- ) {
- dialog.classList.add("closing");
- setTimeout(() => dialog.close(), 200);
+ const container = document.createElement("div");
+
+ container.classList.add("dialog-dynamic");
+ container.innerHTML = html;
+ dialog.appendChild(container);
+ dialog.dataset.loaded = "true";
+ } catch (err) {
+ dialog.querySelectorAll(".dialog-dynamic").forEach((el) => el.remove());
+
+ const errorNode = document.createElement("div");
+
+ errorNode.classList.add("dialog-dynamic");
+ errorNode.textContent = "Failed to load content.";
+ dialog.appendChild(errorNode);
+
+ console.warn("Dialog content load error:", err);
}
- });
+ }
+ dialog.showModal();
+ dialog.classList.remove("closing");
+ });
- dialog.addEventListener("cancel", (event) => {
- event.preventDefault();
+ if (closeButton) {
+ closeButton.addEventListener("click", () => {
dialog.classList.add("closing");
setTimeout(() => dialog.close(), 200);
});
+ }
+
+ dialog.addEventListener("click", (event) => {
+ const rect = dialog.getBoundingClientRect();
+ const outsideClick =
+ event.clientX < rect.left ||
+ event.clientX > rect.right ||
+ event.clientY < rect.top ||
+ event.clientY > rect.bottom;
+
+ if (outsideClick) {
+ dialog.classList.add("closing");
+ setTimeout(() => dialog.close(), 200);
+ }
});
- }
+
+ dialog.addEventListener("cancel", (event) => {
+ event.preventDefault();
+ dialog.classList.add("closing");
+ setTimeout(() => dialog.close(), 200);
+ });
+ });
})();
// text toggle for media pages
@@ -51,12 +83,9 @@ window.addEventListener("load", () => {
const content = document.querySelector("[data-toggle-content]");
const text = document.querySelectorAll("[data-toggle-content] p");
const minHeight = 500; // this needs to match the height set on [data-toggle-content].text-toggle-hidden in text-toggle.css
- const interiorHeight = Array.from(text).reduce(
- (acc, node) => acc + node.scrollHeight,
- 0,
- );
+ const interiorHeight = Array.from(text).reduce((acc, node) => acc + node.scrollHeight, 0);
- if (!button || !content || !text) return;
+ if (!button || !content || !text.length) return;
if (interiorHeight < minHeight) {
content.classList.remove("text-toggle-hidden");
diff --git a/src/assets/styles/base/fonts.css b/src/assets/styles/base/fonts.css
index 7ddfa7f..7038e93 100644
--- a/src/assets/styles/base/fonts.css
+++ b/src/assets/styles/base/fonts.css
@@ -6,22 +6,6 @@
font-display: swap;
}
-@font-face {
- font-family: 'Lexend';
- src: url('/assets/fonts/ll.woff2') format('woff2');
- font-weight: 300;
- font-style: normal;
- font-display: swap;
-}
-
-@font-face {
- font-family: 'Lexend';
- src: url('/assets/fonts/lb.woff2') format('woff2');
- font-weight: 700;
- font-style: normal;
- font-display: swap;
-}
-
@font-face {
font-family: "Space Grotesk";
src: url("/assets/fonts/sg.woff2") format("woff2");
diff --git a/src/assets/styles/base/index.css b/src/assets/styles/base/index.css
index e240f97..8770e81 100644
--- a/src/assets/styles/base/index.css
+++ b/src/assets/styles/base/index.css
@@ -1,7 +1,7 @@
html,
body {
font-family: var(--font-body);
- font-weight: var(--font-weight-light);
+ font-weight: var(--font-weight-regular);
color: var(--text-color);
background: var(--background-color);
}
diff --git a/src/assets/styles/base/vars.css b/src/assets/styles/base/vars.css
index a13e31d..e10b7d8 100644
--- a/src/assets/styles/base/vars.css
+++ b/src/assets/styles/base/vars.css
@@ -71,7 +71,7 @@
--border-gray: 1px solid var(--gray-light);
/* fonts */
- --font-body: "Lexend", -apple-system, BlinkMacSystemFont, avenir next, avenir, segoe ui, helvetica neue, Cantarell, Ubuntu, roboto, noto, helvetica, arial, sans-serif;
+ --font-body: Helvetica Neue, Helvetica, Arial, sans-serif;
--font-heading: "Space Grotesk", "Arial Black", "Arial Bold", Gadget, sans-serif;
--font-code: "MonoLisa", SFMono-Regular, Menlo, Consolas, Monaco, Liberation Mono, Lucida Console, monospace;
@@ -84,7 +84,6 @@
--font-size-2xl: 1.45rem;
--font-size-3xl: 1.6rem;
- --font-weight-light: 300;
--font-weight-regular: 400;
--font-weight-bold: 700;
diff --git a/src/assets/styles/components/dialog.css b/src/assets/styles/components/dialog.css
index 58df183..73dcf97 100644
--- a/src/assets/styles/components/dialog.css
+++ b/src/assets/styles/components/dialog.css
@@ -54,7 +54,7 @@ dialog {
font-size: var(--font-size-lg);
}
- h1, h2, h3 {
+ * {
margin-top: 0;
}
diff --git a/src/includes/blocks/dialog.liquid b/src/includes/blocks/dialog.liquid
index cf649d4..8da70f1 100644
--- a/src/includes/blocks/dialog.liquid
+++ b/src/includes/blocks/dialog.liquid
@@ -9,9 +9,15 @@
-
+
+ {%- unless dynamic -%}
{{ content }}
+ {%- endunless -%}
diff --git a/src/includes/home/recent-activity.liquid b/src/includes/home/recent-activity.liquid
index d52078f..868854f 100644
--- a/src/includes/home/recent-activity.liquid
+++ b/src/includes/home/recent-activity.liquid
@@ -15,12 +15,13 @@
• {{ item.label }}
{%- if item.notes -%}
- {% assign notes = item.notes | prepend: "### Notes\n" | markdown %}
+ {% assign notes = item.notes | markdown %}
{% render "blocks/dialog.liquid",
icon:"info-circle",
label:"View info about this concert"
- content:notes,
- id:item.content_date
+ dynamic:"optimized_concerts",
+ markdown:"concert_notes",
+ id:item.id
%}
{%- endif -%}
diff --git a/src/layouts/base.liquid b/src/layouts/base.liquid
index ea200a2..8149ab7 100644
--- a/src/layouts/base.liquid
+++ b/src/layouts/base.liquid
@@ -3,8 +3,6 @@
-
-
';
}
echo implode(', ', $artistLinks);
- ?>. I've listened to = $genre["total_plays"] . ' ' . pluralize($genre["total_plays"], "play") ?> tracks from this genre.
+ ?>. I've listened to = $genre["total_plays"] . ' ' . pluralize($genre["total_plays"], "play") ?> tracks from this genre.
{% tablericon "needle" %} I have a tattoo inspired by this show!
-
+
diff --git a/src/pages/dynamic/tags.php.liquid b/src/pages/dynamic/tags.php.liquid
index 077cd91..9567abe 100644
--- a/src/pages/dynamic/tags.php.liquid
+++ b/src/pages/dynamic/tags.php.liquid
@@ -5,7 +5,7 @@ schema: tags
---
{% tablericon "arrow-left" %} Back to tags
#= htmlspecialchars($tag) ?>
-
Date: Tue, 22 Apr 2025 12:39:42 -0700 Subject: [PATCH 7/8] feat(*.php, *.psql): deduplicate API code + performance improvements --- api/Classes/ApiHandler.php | 65 +-------- api/Classes/BaseHandler.php | 123 ++++++---------- api/artist-import.php | 79 ++++------- api/book-import.php | 55 +++----- api/contact.php | 26 ++-- api/mastodon.php | 171 ++++++++--------------- api/playing.php | 2 +- api/proxy.php | 90 ++++++++++++ api/scrobble.php | 124 +++++++--------- api/search.php | 8 +- api/seasons-import.php | 89 ++++-------- api/watching-import.php | 136 ++++++++---------- package-lock.json | 24 ++-- package.json | 2 +- queries/views/feeds/recent_activity.psql | 7 +- queries/views/media/music/concerts.psql | 2 - server/utils/strings.php | 2 - src/assets/scripts/index.js | 97 ++++++++----- src/assets/styles/base/fonts.css | 16 --- src/assets/styles/base/index.css | 2 +- src/assets/styles/base/vars.css | 3 +- src/assets/styles/components/dialog.css | 2 +- src/includes/blocks/dialog.liquid | 8 +- src/includes/home/recent-activity.liquid | 7 +- src/layouts/base.liquid | 2 - src/pages/dynamic/artist.php.liquid | 2 +- src/pages/dynamic/book.php.liquid | 2 +- src/pages/dynamic/genre.php.liquid | 4 +- src/pages/dynamic/show.php.liquid | 5 +- src/pages/dynamic/tags.php.liquid | 6 +- src/pages/media/music/concerts.html | 7 +- 31 files changed, 502 insertions(+), 666 deletions(-) create mode 100644 api/proxy.php diff --git a/api/Classes/ApiHandler.php b/api/Classes/ApiHandler.php index 9f42b30..5524427 100644 --- a/api/Classes/ApiHandler.php +++ b/api/Classes/ApiHandler.php @@ -1,72 +1,15 @@ loadEnvironment(); - } - - private function loadEnvironment(): void - { - $this->postgrestUrl = - $_ENV["POSTGREST_URL"] ?? getenv("POSTGREST_URL") ?: ""; - $this->postgrestApiKey = - $_ENV["POSTGREST_API_KEY"] ?? getenv("POSTGREST_API_KEY") ?: ""; - } - protected function ensureCliAccess(): void { - if (php_sapi_name() !== "cli" && $_SERVER["REQUEST_METHOD"] !== "POST") { - $this->redirectNotFound(); + if (php_sapi_name() !== 'cli' && $_SERVER['REQUEST_METHOD'] !== 'POST') { + $this->sendErrorResponse("Not Found", 404); } } - - protected function redirectNotFound(): void - { - header("Location: /404", true, 302); - exit(); - } - - protected function fetchFromPostgREST( - string $endpoint, - string $query = "", - string $method = "GET", - ?array $body = null - ): array { - $url = "{$this->postgrestUrl}/{$endpoint}?{$query}"; - $options = [ - "headers" => [ - "Content-Type" => "application/json", - "Authorization" => "Bearer {$this->postgrestApiKey}", - ], - ]; - - if ($method === "POST" && $body) $options["json"] = $body; - - $response = (new Client())->request($method, $url, $options); - - return json_decode($response->getBody(), true) ?? []; - } - - protected function sendResponse(string $message, int $statusCode): void - { - http_response_code($statusCode); - header("Content-Type: application/json"); - echo json_encode(["message" => $message]); - exit(); - } - - protected function sendErrorResponse(string $message, int $statusCode): void - { - $this->sendResponse($message, $statusCode); - } } diff --git a/api/Classes/BaseHandler.php b/api/Classes/BaseHandler.php index d69989d..b526846 100644 --- a/api/Classes/BaseHandler.php +++ b/api/Classes/BaseHandler.php @@ -16,60 +16,71 @@ abstract class BaseHandler public function __construct() { $this->loadEnvironment(); + $this->initializeCache(); } private function loadEnvironment(): void { - $this->postgrestUrl = - $_ENV["POSTGREST_URL"] ?? getenv("POSTGREST_URL") ?: ""; - $this->postgrestApiKey = - $_ENV["POSTGREST_API_KEY"] ?? getenv("POSTGREST_API_KEY") ?: ""; + $this->postgrestUrl = $_ENV["POSTGREST_URL"] ?? getenv("POSTGREST_URL") ?? ""; + $this->postgrestApiKey = $_ENV["POSTGREST_API_KEY"] ?? getenv("POSTGREST_API_KEY") ?? ""; } - protected function makeRequest( - string $method, - string $endpoint, - array $options = [] - ): array { + protected function initializeCache(): void + { + if (class_exists("Redis")) { + try { + $redis = new \Redis(); + $redis->connect("127.0.0.1", 6379); + $this->cache = $redis; + } catch (\Exception $e) { + error_log("Redis connection failed: " . $e->getMessage()); + $this->cache = null; + } + } else { + error_log("Redis extension not found — caching disabled."); + $this->cache = null; + } + } + + protected function makeRequest(string $method, string $endpoint, array $options = []): array + { $client = new Client(); $url = rtrim($this->postgrestUrl, "/") . "/" . ltrim($endpoint, "/"); try { - $response = $client->request( - $method, - $url, - array_merge($options, [ - "headers" => [ - "Authorization" => "Bearer {$this->postgrestApiKey}", - "Content-Type" => "application/json", - ], - ]) - ); + $response = $client->request($method, $url, array_merge_recursive([ + "headers" => [ + "Authorization" => "Bearer {$this->postgrestApiKey}", + "Content-Type" => "application/json", + ] + ], $options)); $responseBody = $response->getBody()->getContents(); - if (empty($responseBody)) return []; - $responseData = json_decode($responseBody, true); + $data = json_decode($responseBody, true); + if (json_last_error() !== JSON_ERROR_NONE) { + throw new \Exception("Invalid JSON: " . json_last_error_msg()); + } - if (json_last_error() !== JSON_ERROR_NONE) throw new \Exception("Invalid JSON response: {$responseBody}"); - - return $responseData; + return $data; } catch (RequestException $e) { $response = $e->getResponse(); - $statusCode = $response ? $response->getStatusCode() : "N/A"; - $responseBody = $response - ? $response->getBody()->getContents() - : "No response body"; + $statusCode = $response ? $response->getStatusCode() : 'N/A'; + $responseBody = $response ? $response->getBody()->getContents() : 'No response'; - throw new \Exception( - "Request to {$url} failed with status {$statusCode}. Response: {$responseBody}" - ); + throw new \Exception("HTTP {$method} {$url} failed with status {$statusCode}: {$responseBody}"); } catch (\Exception $e) { - throw new \Exception("Request to {$url} failed: " . $e->getMessage()); + throw new \Exception("Request error: " . $e->getMessage()); } } + protected function fetchFromApi(string $endpoint, string $query = ""): array + { + $url = $endpoint . ($query ? "?{$query}" : ""); + return $this->makeRequest("GET", $url); + } + protected function sendResponse(array $data, int $statusCode = 200): void { http_response_code($statusCode); @@ -78,52 +89,8 @@ abstract class BaseHandler exit(); } - protected function sendErrorResponse( - string $message, - int $statusCode = 500 - ): void { + protected function sendErrorResponse(string $message, int $statusCode = 500): void + { $this->sendResponse(["error" => $message], $statusCode); } - - protected function fetchFromApi(string $endpoint, string $query): array - { - $client = new Client(); - $url = - rtrim($this->postgrestUrl, "/") . - "/" . - ltrim($endpoint, "/") . - "?" . - $query; - - try { - $response = $client->request("GET", $url, [ - "headers" => [ - "Content-Type" => "application/json", - "Authorization" => "Bearer {$this->postgrestApiKey}", - ], - ]); - - if ($response->getStatusCode() !== 200) throw new Exception("API call to {$url} failed with status code " . $response->getStatusCode()); - - return json_decode($response->getBody(), true); - } catch (RequestException $e) { - throw new Exception("Error fetching from API: " . $e->getMessage()); - } - } - - protected function initializeCache(): void - { - if (class_exists("Redis")) { - $redis = new \Redis(); - try { - $redis->connect("127.0.0.1", 6379); - $this->cache = $redis; - } catch (Exception $e) { - error_log("Redis connection failed: " . $e->getMessage()); - $this->cache = null; - } - } else { - $this->cache = null; - } - } } diff --git a/api/artist-import.php b/api/artist-import.php index b3141a5..70a5934 100644 --- a/api/artist-import.php +++ b/api/artist-import.php @@ -8,9 +8,6 @@ use GuzzleHttp\Client; class ArtistImportHandler extends ApiHandler { - protected string $postgrestUrl; - protected string $postgrestApiKey; - private string $artistImportToken; private string $placeholderImageId = "4cef75db-831f-4f5d-9333-79eaa5bb55ee"; private string $navidromeApiUrl; @@ -20,13 +17,7 @@ class ArtistImportHandler extends ApiHandler { parent::__construct(); $this->ensureCliAccess(); - $this->loadEnvironment(); - } - private function loadEnvironment(): void - { - $this->postgrestUrl = getenv("POSTGREST_URL"); - $this->postgrestApiKey = getenv("POSTGREST_API_KEY"); $this->artistImportToken = getenv("ARTIST_IMPORT_TOKEN"); $this->navidromeApiUrl = getenv("NAVIDROME_API_URL"); $this->navidromeAuthToken = getenv("NAVIDROME_API_TOKEN"); @@ -41,11 +32,13 @@ class ArtistImportHandler extends ApiHandler $providedToken = $input["token"] ?? null; $artistId = $input["artistId"] ?? null; - if (!$providedToken || $providedToken !== $this->artistImportToken) { + if ($providedToken !== $this->artistImportToken) { $this->sendJsonResponse("error", "Unauthorized access", 401); } - if (!$artistId) $this->sendJsonResponse("error", "Artist ID is required", 400); + if (!$artistId) { + $this->sendJsonResponse("error", "Artist ID is required", 400); + } try { $artistData = $this->fetchNavidromeArtist($artistId); @@ -54,7 +47,7 @@ class ArtistImportHandler extends ApiHandler if ($artistExists) $this->processAlbums($artistId, $artistData->name); $this->sendJsonResponse("message", "Artist and albums synced successfully", 200); - } catch (Exception $e) { + } catch (\Exception $e) { $this->sendJsonResponse("error", "Error: " . $e->getMessage(), 500); } } @@ -67,7 +60,7 @@ class ArtistImportHandler extends ApiHandler exit(); } - private function fetchNavidromeArtist(string $artistId) + private function fetchNavidromeArtist(string $artistId): object { $client = new Client(); $response = $client->get("{$this->navidromeApiUrl}/api/artist/{$artistId}", [ @@ -77,7 +70,7 @@ class ArtistImportHandler extends ApiHandler ] ]); - return json_decode($response->getBody(), false); + return json_decode($response->getBody()); } private function fetchNavidromeAlbums(string $artistId): array @@ -103,16 +96,14 @@ class ArtistImportHandler extends ApiHandler private function processArtist(object $artistData): bool { $artistName = $artistData->name ?? ""; - - if (!$artistName) throw new Exception("Artist name is missing from Navidrome data."); + if (!$artistName) throw new \Exception("Artist name is missing."); $existingArtist = $this->getArtistByName($artistName); - if ($existingArtist) return true; $artistKey = sanitizeMediaString($artistName); $slug = "/music/artists/{$artistKey}"; - $description = strip_tags($artistData->biography) ?? ""; + $description = strip_tags($artistData->biography ?? ""); $genre = $this->resolveGenreId($artistData->genres[0]->name ?? ""); $starred = $artistData->starred ?? false; @@ -127,35 +118,34 @@ class ArtistImportHandler extends ApiHandler "genres" => $genre, ]; - $this->saveArtist($artistPayload); - + $this->makeRequest("POST", "artists", ["json" => $artistPayload]); return true; } private function processAlbums(string $artistId, string $artistName): void { $artist = $this->getArtistByName($artistName); - - if (!$artist) throw new Exception("Artist not found in the database."); + if (!$artist) throw new \Exception("Artist not found after insert."); $existingAlbums = $this->getExistingAlbums($artist["id"]); $existingAlbumKeys = array_column($existingAlbums, "key"); + $navidromeAlbums = $this->fetchNavidromeAlbums($artistId); foreach ($navidromeAlbums as $album) { - $albumName = $album["name"]; + $albumName = $album["name"] ?? ""; $releaseYearRaw = $album["date"] ?? null; $releaseYear = null; - if ($releaseYearRaw) { - if (preg_match('/^\d{4}/', $releaseYearRaw, $matches)) $releaseYear = (int)$matches[0]; + if ($releaseYearRaw && preg_match('/^\d{4}/', $releaseYearRaw, $matches)) { + $releaseYear = (int)$matches[0]; } $artistKey = sanitizeMediaString($artistName); - $albumKey = $artistKey . "-" . sanitizeMediaString($albumName); + $albumKey = "{$artistKey}-" . sanitizeMediaString($albumName); if (in_array($albumKey, $existingAlbumKeys)) { - error_log("Skipping existing album: " . $albumName); + error_log("Skipping existing album: {$albumName}"); continue; } @@ -170,8 +160,8 @@ class ArtistImportHandler extends ApiHandler "tentative" => true, ]; - $this->saveAlbum($albumPayload); - } catch (Exception $e) { + $this->makeRequest("POST", "albums", ["json" => $albumPayload]); + } catch (\Exception $e) { error_log("Error adding album '{$albumName}': " . $e->getMessage()); } } @@ -179,34 +169,19 @@ class ArtistImportHandler extends ApiHandler private function getArtistByName(string $nameString): ?array { - $query = "name_string=eq." . urlencode($nameString); - $response = $this->fetchFromPostgREST("artists", $query, "GET"); - + $response = $this->fetchFromApi("artists", "name_string=eq." . urlencode($nameString)); return $response[0] ?? null; } - private function saveArtist(array $artistPayload): void - { - $this->fetchFromPostgREST("artists", "", "POST", $artistPayload); - } - - private function saveAlbum(array $albumPayload): void - { - $this->fetchFromPostgREST("albums", "", "POST", $albumPayload); - } - - private function resolveGenreId(string $genreName): ?string - { - $genres = $this->fetchFromPostgREST("genres", "name=eq." . urlencode(strtolower($genreName)), "GET"); - - if (!empty($genres)) return $genres[0]["id"]; - - return null; - } - private function getExistingAlbums(string $artistId): array { - return $this->fetchFromPostgREST("albums", "artist=eq." . urlencode($artistId), "GET"); + return $this->fetchFromApi("albums", "artist=eq." . urlencode($artistId)); + } + + private function resolveGenreId(string $genreName): ?string + { + $genres = $this->fetchFromApi("genres", "name=eq." . urlencode(strtolower($genreName))); + return $genres[0]["id"] ?? null; } } diff --git a/api/book-import.php b/api/book-import.php index 026c19e..4d52b0f 100644 --- a/api/book-import.php +++ b/api/book-import.php @@ -7,46 +7,39 @@ use GuzzleHttp\Client; class BookImportHandler extends ApiHandler { - protected string $postgrestUrl; - protected string $postgrestApiKey; - private string $bookImportToken; public function __construct() { parent::__construct(); $this->ensureCliAccess(); - $this->loadEnvironment(); - } - - private function loadEnvironment(): void - { - $this->postgrestUrl = $_ENV["POSTGREST_URL"] ?? getenv("POSTGREST_URL"); - $this->postgrestApiKey = - $_ENV["POSTGREST_API_KEY"] ?? getenv("POSTGREST_API_KEY"); - $this->bookImportToken = - $_ENV["BOOK_IMPORT_TOKEN"] ?? getenv("BOOK_IMPORT_TOKEN"); + $this->bookImportToken = $_ENV["BOOK_IMPORT_TOKEN"] ?? getenv("BOOK_IMPORT_TOKEN"); } public function handleRequest(): void { $input = json_decode(file_get_contents("php://input"), true); - if (!$input) $this->sendErrorResponse("Invalid or missing JSON body", 400); + if (!$input) { + $this->sendErrorResponse("Invalid or missing JSON body", 400); + } $providedToken = $input["token"] ?? null; $isbn = $input["isbn"] ?? null; - if (!$providedToken || $providedToken !== $this->bookImportToken) $this->sendErrorResponse("Unauthorized access", 401); + if ($providedToken !== $this->bookImportToken) { + $this->sendErrorResponse("Unauthorized access", 401); + } - if (!$isbn) $this->sendErrorResponse("isbn parameter is required", 400); + if (!$isbn) { + $this->sendErrorResponse("isbn parameter is required", 400); + } try { $bookData = $this->fetchBookData($isbn); $this->processBook($bookData); - - $this->sendResponse("Book imported successfully", 200); - } catch (Exception $e) { + $this->sendResponse(["message" => "Book imported successfully"], 200); + } catch (\Exception $e) { $this->sendErrorResponse("Error: " . $e->getMessage(), 500); } } @@ -66,7 +59,9 @@ class BookImportHandler extends ApiHandler $data = json_decode($response->getBody(), true); $bookKey = "ISBN:{$isbn}"; - if (empty($data[$bookKey])) throw new Exception("Book data not found for ISBN: {$isbn}"); + if (empty($data[$bookKey])) { + throw new \Exception("Book data not found for ISBN: {$isbn}"); + } return $data[$bookKey]; } @@ -80,11 +75,14 @@ class BookImportHandler extends ApiHandler $author = $bookData["authors"][0]["name"] ?? null; $description = $bookData["description"] ?? ($bookData["notes"] ?? ""); - if (!$isbn || !$title || !$author) throw new Exception("Missing essential book data (title, author, or ISBN)."); + if (!$isbn || !$title || !$author) { + throw new \Exception("Missing essential book data (title, author, or ISBN)."); + } $existingBook = $this->getBookByISBN($isbn); - - if ($existingBook) throw new Exception("Book with ISBN {$isbn} already exists."); + if ($existingBook) { + throw new \Exception("Book with ISBN {$isbn} already exists."); + } $bookPayload = [ "isbn" => $isbn, @@ -95,19 +93,12 @@ class BookImportHandler extends ApiHandler "slug" => "/books/" . $isbn, ]; - $this->saveBook($bookPayload); - } - - private function saveBook(array $bookPayload): void - { - $this->fetchFromPostgREST("books", "", "POST", $bookPayload); + $this->makeRequest("POST", "books", ["json" => $bookPayload]); } private function getBookByISBN(string $isbn): ?array { - $query = "isbn=eq." . urlencode($isbn); - $response = $this->fetchFromPostgREST("books", $query, "GET"); - + $response = $this->fetchFromApi("books", "isbn=eq." . urlencode($isbn)); return $response[0] ?? null; } } diff --git a/api/contact.php b/api/contact.php index dc0b682..b440ad0 100644 --- a/api/contact.php +++ b/api/contact.php @@ -15,17 +15,9 @@ class ContactHandler extends BaseHandler public function __construct(?Client $httpClient = null) { + parent::__construct(); $this->httpClient = $httpClient ?? new Client(); - $this->loadEnvironment(); - } - - private function loadEnvironment(): void - { - $this->postgrestUrl = $_ENV["POSTGREST_URL"] ?? getenv("POSTGREST_URL"); - $this->postgrestApiKey = - $_ENV["POSTGREST_API_KEY"] ?? getenv("POSTGREST_API_KEY"); - $this->forwardEmailApiKey = - $_ENV["FORWARDEMAIL_API_KEY"] ?? getenv("FORWARDEMAIL_API_KEY"); + $this->forwardEmailApiKey = $_ENV["FORWARDEMAIL_API_KEY"] ?? getenv("FORWARDEMAIL_API_KEY"); } public function handleRequest(): void @@ -42,7 +34,7 @@ class ContactHandler extends BaseHandler $rawBody = file_get_contents("php://input"); $formData = json_decode($rawBody, true); if (!$formData || !isset($formData["data"])) { - throw new Exception("Invalid JSON payload."); + throw new \Exception("Invalid JSON payload."); } $formData = $formData["data"]; } elseif ( @@ -93,7 +85,7 @@ class ContactHandler extends BaseHandler $this->saveToDatabase($contactData); $this->sendNotificationEmail($contactData); $this->sendRedirect("/contact/success"); - } catch (Exception $e) { + } catch (\Exception $e) { error_log("Error handling contact form submission: " . $e->getMessage()); $this->sendErrorResponse($e->getMessage(), 400); } @@ -103,7 +95,7 @@ class ContactHandler extends BaseHandler { $referer = $_SERVER["HTTP_REFERER"] ?? ""; $allowedDomain = "coryd.dev"; - if (!str_contains($referer, $allowedDomain)) throw new Exception("Invalid submission origin."); + if (!str_contains($referer, $allowedDomain)) throw new \Exception("Invalid submission origin."); } private function checkRateLimit(): void @@ -132,7 +124,7 @@ class ContactHandler extends BaseHandler private function enforceHttps(): void { - if (empty($_SERVER["HTTPS"]) || $_SERVER["HTTPS"] !== "on") throw new Exception("Secure connection required. Use HTTPS."); + if (empty($_SERVER["HTTPS"]) || $_SERVER["HTTPS"] !== "on") throw new \Exception("Secure connection required. Use HTTPS."); } private function isBlockedDomain(string $email): bool @@ -171,7 +163,7 @@ class ContactHandler extends BaseHandler if ($response->getStatusCode() >= 400) { $errorResponse = json_decode($response->getBody(), true); - throw new Exception( + throw new \Exception( "PostgREST error: " . ($errorResponse["message"] ?? "Unknown error") ); } @@ -204,7 +196,7 @@ class ContactHandler extends BaseHandler ] ); - if ($response->getStatusCode() >= 400) throw new Exception("Failed to send email notification."); + if ($response->getStatusCode() >= 400) throw new \Exception("Failed to send email notification."); } private function sendRedirect(string $path): void @@ -221,7 +213,7 @@ class ContactHandler extends BaseHandler try { $handler = new ContactHandler(); $handler->handleRequest(); -} catch (Exception $e) { +} catch (\Exception $e) { error_log("Contact form error: " . $e->getMessage()); echo json_encode(["error" => $e->getMessage()]); http_response_code(500); diff --git a/api/mastodon.php b/api/mastodon.php index b1ace34..6019dd7 100644 --- a/api/mastodon.php +++ b/api/mastodon.php @@ -7,12 +7,9 @@ use GuzzleHttp\Client; class MastodonPostHandler extends ApiHandler { - protected string $postgrestUrl; - protected string $postgrestApiKey; - private string $mastodonAccessToken; - private string $rssFeedUrl; - private string $baseUrl; + private string $rssFeedUrl = "https://www.coryd.dev/feeds/syndication.xml"; + private string $baseUrl = "https://www.coryd.dev"; private const MASTODON_API_STATUS = "https://follow.coryd.dev/api/v1/statuses"; @@ -22,21 +19,11 @@ class MastodonPostHandler extends ApiHandler { parent::__construct(); $this->ensureCliAccess(); - $this->loadEnvironment(); - $this->validateAuthorization(); - $this->httpClient = $httpClient ?: new Client(); - } - private function loadEnvironment(): void - { - $this->postgrestUrl = - getenv("POSTGREST_URL") ?: $_ENV["POSTGREST_URL"] ?? ""; - $this->postgrestApiKey = - getenv("POSTGREST_API_KEY") ?: $_ENV["POSTGREST_API_KEY"] ?? ""; - $this->mastodonAccessToken = - getenv("MASTODON_ACCESS_TOKEN") ?: $_ENV["MASTODON_ACCESS_TOKEN"] ?? ""; - $this->rssFeedUrl = "https://www.coryd.dev/feeds/syndication.xml"; - $this->baseUrl = "https://www.coryd.dev"; + $this->mastodonAccessToken = getenv("MASTODON_ACCESS_TOKEN") ?: $_ENV["MASTODON_ACCESS_TOKEN"] ?? ""; + $this->httpClient = $httpClient ?: new Client(); + + $this->validateAuthorization(); } private function validateAuthorization(): void @@ -46,7 +33,7 @@ class MastodonPostHandler extends ApiHandler if ($authHeader !== $expectedToken) { http_response_code(401); - echo json_encode(["error" => "Unauthorized."]); + echo json_encode(["error" => "Unauthorized"]); exit(); } } @@ -61,16 +48,16 @@ class MastodonPostHandler extends ApiHandler $latestItems = $this->fetchRSSFeed($this->rssFeedUrl); foreach (array_reverse($latestItems) as $item) { - $existingPost = $this->fetchFromPostgREST("mastodon_posts", "link=eq." . urlencode($item["link"])); - - if (!empty($existingPost)) continue; + $existing = $this->fetchFromApi("mastodon_posts", "link=eq." . urlencode($item["link"])); + if (!empty($existing)) continue; $content = $this->truncateContent( $item["title"], - str_replace(array("\n", "\r"), '', strip_tags($item["description"])), + strip_tags($item["description"]), $item["link"], 500 ); + $timestamp = date("Y-m-d H:i:s"); if (!$this->storeInDatabase($item["link"], $timestamp)) { @@ -78,15 +65,11 @@ class MastodonPostHandler extends ApiHandler continue; } - $mastodonPostUrl = $this->postToMastodon($content, $item["image"] ?? null); - - if ($mastodonPostUrl) { - if (strpos($item["link"], $this->baseUrl . "/posts") !== false) { - $slug = str_replace($this->baseUrl, "", $item["link"]); - echo "Posted and stored URL: {$item["link"]}\n"; - } + $postedUrl = $this->postToMastodon($content, $item["image"] ?? null); + if ($postedUrl) { + echo "Posted: {$postedUrl}\n"; } else { - echo "Failed to post to Mastodon. Skipping database update.\n"; + echo "Failed to post to Mastodon for: {$item["link"]}\n"; } } @@ -96,16 +79,16 @@ class MastodonPostHandler extends ApiHandler private function fetchRSSFeed(string $rssFeedUrl): array { $rssText = file_get_contents($rssFeedUrl); - - if (!$rssText) throw new Exception("Failed to fetch RSS feed."); + if (!$rssText) throw new \Exception("Failed to fetch RSS feed."); $rss = new \SimpleXMLElement($rssText); $items = []; foreach ($rss->channel->item as $item) { $imageUrl = null; - - if ($item->enclosure && isset($item->enclosure['url'])) $imageUrl = (string) $item->enclosure['url']; + if ($item->enclosure && isset($item->enclosure['url'])) { + $imageUrl = (string) $item->enclosure['url']; + } $items[] = [ "title" => (string) $item->title, @@ -120,15 +103,13 @@ class MastodonPostHandler extends ApiHandler private function uploadImageToMastodon(string $imageUrl): ?string { - $headers = [ - "Authorization" => "Bearer {$this->mastodonAccessToken}" - ]; - $tempFile = tempnam(sys_get_temp_dir(), "mastodon_img"); file_put_contents($tempFile, file_get_contents($imageUrl)); $response = $this->httpClient->request("POST", "https://follow.coryd.dev/api/v2/media", [ - "headers" => $headers, + "headers" => [ + "Authorization" => "Bearer {$this->mastodonAccessToken}" + ], "multipart" => [ [ "name" => "file", @@ -140,13 +121,12 @@ class MastodonPostHandler extends ApiHandler unlink($tempFile); - $statusCode = $response->getStatusCode(); + if ($response->getStatusCode() !== 200) { + throw new \Exception("Image upload failed with status {$response->getStatusCode()}"); + } - if ($statusCode !== 200) throw new Exception("Image upload failed with status $statusCode."); - - $responseBody = json_decode($response->getBody()->getContents(), true); - - return $responseBody["id"] ?? null; + $json = json_decode($response->getBody(), true); + return $json["id"] ?? null; } private function postToMastodon(string $content, ?string $imageUrl = null): ?string @@ -156,43 +136,42 @@ class MastodonPostHandler extends ApiHandler "Content-Type" => "application/json", ]; - $mediaIds = []; + $postData = ["status" => $content]; if ($imageUrl) { try { $mediaId = $this->uploadImageToMastodon($imageUrl); - if ($mediaId) $mediaIds[] = $mediaId; - } catch (Exception $e) { - echo "Image upload failed: " . $e->getMessage() . "\n"; + if ($mediaId) $postData["media_ids"] = [$mediaId]; + } catch (\Exception $e) { + echo "Image upload failed: {$e->getMessage()}\n"; } } - $postData = ["status" => $content]; + $response = $this->httpClient->request("POST", self::MASTODON_API_STATUS, [ + "headers" => $headers, + "json" => $postData + ]); - if (!empty($mediaIds)) $postData["media_ids"] = $mediaIds; + if ($response->getStatusCode() >= 400) { + throw new \Exception("Mastodon post failed: {$response->getBody()}"); + } - $response = $this->httpRequest( - self::MASTODON_API_STATUS, - "POST", - $headers, - $postData - ); - - return $response["url"] ?? null; + $body = json_decode($response->getBody()->getContents(), true); + return $body["url"] ?? null; } private function storeInDatabase(string $link, string $timestamp): bool { - $data = [ - "link" => $link, - "created_at" => $timestamp, - ]; - try { - $this->fetchFromPostgREST("mastodon_posts", "", "POST", $data); + $this->makeRequest("POST", "mastodon_posts", [ + "json" => [ + "link" => $link, + "created_at" => $timestamp + ] + ]); return true; - } catch (Exception $e) { - echo "Error storing post in database: " . $e->getMessage() . "\n"; + } catch (\Exception $e) { + echo "Error storing post in DB: " . $e->getMessage() . "\n"; return false; } } @@ -200,70 +179,32 @@ class MastodonPostHandler extends ApiHandler private function isDatabaseAvailable(): bool { try { - $response = $this->fetchFromPostgREST("mastodon_posts", "limit=1"); + $response = $this->fetchFromApi("mastodon_posts", "limit=1"); return is_array($response); - } catch (Exception $e) { + } catch (\Exception $e) { echo "Database check failed: " . $e->getMessage() . "\n"; return false; } } - private function truncateContent( - string $title, - string $description, - string $link, - int $maxLength - ): string { + private function truncateContent(string $title, string $description, string $link, int $maxLength): string + { $baseLength = strlen("$title\n\n$link"); - $availableSpace = $maxLength - $baseLength - 4; + $available = $maxLength - $baseLength - 4; - if (strlen($description) > $availableSpace) { - $description = substr($description, 0, $availableSpace); + if (strlen($description) > $available) { + $description = substr($description, 0, $available); $description = preg_replace('/\s+\S*$/', "", $description) . "..."; } return "$title\n\n$description\n\n$link"; } - - private function httpRequest( - string $url, - string $method = "GET", - array $headers = [], - ?array $data = null - ): array { - $options = ["headers" => $headers]; - - if ($data) $options["json"] = $data; - - $response = $this->httpClient->request($method, $url, $options); - $statusCode = $response->getStatusCode(); - - if ($statusCode >= 400) throw new Exception("HTTP error $statusCode: " . $response->getBody()); - - $responseBody = $response->getBody()->getContents(); - - if (empty($responseBody)) return []; - - $decodedResponse = json_decode($responseBody, true); - - if (!is_array($decodedResponse)) return []; - - return $decodedResponse; - } - - private function getPostgRESTHeaders(): array - { - return [ - "Authorization" => "Bearer {$this->postgrestApiKey}", - "Content-Type" => "application/json", - ]; - } } try { $handler = new MastodonPostHandler(); $handler->handlePost(); -} catch (Exception $e) { +} catch (\Exception $e) { http_response_code(500); echo json_encode(["error" => $e->getMessage()]); } diff --git a/api/playing.php b/api/playing.php index 2e41507..7cb8506 100644 --- a/api/playing.php +++ b/api/playing.php @@ -41,7 +41,7 @@ class LatestListenHandler extends BaseHandler ); $this->sendResponse($latestListen); - } catch (Exception $e) { + } catch (\Exception $e) { error_log("LatestListenHandler Error: " . $e->getMessage()); $this->sendErrorResponse( "Internal Server Error: " . $e->getMessage(), diff --git a/api/proxy.php b/api/proxy.php new file mode 100644 index 0000000..090e65a --- /dev/null +++ b/api/proxy.php @@ -0,0 +1,90 @@ +ensureAllowedOrigin(); + } + + protected function ensureAllowedOrigin(): void + { + $allowedHosts = ['coryd.dev', 'www.coryd.dev']; + $origin = $_SERVER['HTTP_ORIGIN'] ?? ''; + $referer = $_SERVER['HTTP_REFERER'] ?? ''; + + $hostAllowed = fn($url) => in_array(parse_url($url, PHP_URL_HOST), $allowedHosts, true); + + if (!$hostAllowed($origin) && !$hostAllowed($referer)) $this->sendErrorResponse("Forbidden — invalid origin", 403); + + $allowedSource = $origin ?: $referer; + $scheme = parse_url($allowedSource, PHP_URL_SCHEME) ?? 'https'; + $host = parse_url($allowedSource, PHP_URL_HOST); + + header("Access-Control-Allow-Origin: {$scheme}://{$host}"); + header("Access-Control-Allow-Headers: Content-Type"); + header("Access-Control-Allow-Methods: GET, POST"); + } + + public function handleRequest(): void + { + $data = $_GET['data'] ?? null; + $id = $_GET['id'] ?? null; + $cacheDuration = intval($_GET['cacheDuration'] ?? 3600); + + if (!$data) $this->sendErrorResponse("Missing 'data' parameter", 400); + + $cacheKey = $this->buildCacheKey($data, $id); + + if ($this->cache) { + $cached = $this->cache->get($cacheKey); + if ($cached) { + header('Content-Type: application/json'); + echo $cached; + exit(); + } + } + + $query = $id ? "id=eq.$id" : ""; + + try { + $response = $this->fetchFromApi($data, $query); + $markdownFields = $_GET['markdown'] ?? []; + $markdownFields = is_array($markdownFields) + ? $markdownFields + : explode(',', $markdownFields); + $markdownFields = array_map('trim', array_filter($markdownFields)); + + if (!empty($response) && !empty($markdownFields)) { + foreach ($markdownFields as $field) { + if (!empty($response[0][$field])) $response[0]["{$field}_html"] = parseMarkdown($response[0][$field]); + } + } + + $json = json_encode($response); + + if ($this->cache) { + $this->cache->setex($cacheKey, $cacheDuration, $json); + } + + header('Content-Type: application/json'); + echo $json; + } catch (\Exception $e) { + $this->sendErrorResponse("PostgREST fetch failed: " . $e->getMessage(), 500); + } + } + + private function buildCacheKey(string $data, ?string $id): string + { + return "proxy_{$data}" . ($id ? "_{$id}" : ""); + } +} + +$handler = new ProxyHandler(); +$handler->handleRequest(); diff --git a/api/scrobble.php b/api/scrobble.php index 497fa0b..80353a2 100644 --- a/api/scrobble.php +++ b/api/scrobble.php @@ -10,13 +10,8 @@ use GuzzleHttp\Client; header("Content-Type: application/json"); -$authHeader = $_SERVER["HTTP_AUTHORIZATION"] ?? ""; -$expectedToken = "Bearer " . getenv("NAVIDROME_SCROBBLE_TOKEN"); - class NavidromeScrobbleHandler extends ApiHandler { - private string $postgrestApiUrl; - private string $postgrestApiToken; private string $navidromeApiUrl; private string $navidromeAuthToken; private string $forwardEmailApiKey; @@ -28,14 +23,12 @@ class NavidromeScrobbleHandler extends ApiHandler { parent::__construct(); $this->ensureCliAccess(); - $this->loadEnvironment(); + $this->loadExternalServiceKeys(); $this->validateAuthorization(); } - private function loadEnvironment(): void + private function loadExternalServiceKeys(): void { - $this->postgrestApiUrl = getenv("POSTGREST_URL"); - $this->postgrestApiToken = getenv("POSTGREST_API_KEY"); $this->navidromeApiUrl = getenv("NAVIDROME_API_URL"); $this->navidromeAuthToken = getenv("NAVIDROME_API_TOKEN"); $this->forwardEmailApiKey = getenv("FORWARDEMAIL_API_KEY"); @@ -95,7 +88,7 @@ class NavidromeScrobbleHandler extends ApiHandler private function isTrackAlreadyScrobbled(array $track): bool { $playDate = strtotime($track["playDate"]); - $existingListen = $this->fetchFromPostgREST("listens", "listened_at=eq.{$playDate}&limit=1"); + $existingListen = $this->fetchFromApi("listens", "listened_at=eq.{$playDate}&limit=1"); return !empty($existingListen); } @@ -121,61 +114,52 @@ class NavidromeScrobbleHandler extends ApiHandler private function getOrCreateArtist(string $artistName): array { - if (!$this->isDatabaseAvailable()) { - error_log("Skipping artist insert: database is unavailable."); - return []; - } + if (!$this->isDatabaseAvailable()) return []; if (isset($this->artistCache[$artistName])) return $this->artistCache[$artistName]; $encodedArtist = rawurlencode($artistName); - $existingArtist = $this->fetchFromPostgREST("artists", "name_string=eq.{$encodedArtist}&limit=1"); + $existingArtist = $this->fetchFromApi("artists", "name_string=eq.{$encodedArtist}&limit=1"); if (!empty($existingArtist)) { - $this->artistCache[$artistName] = $existingArtist[0]; - return $existingArtist[0]; + return $this->artistCache[$artistName] = $existingArtist[0]; } - $this->fetchFromPostgREST("artists", "", "POST", [ - "mbid" => "", - "art" => "4cef75db-831f-4f5d-9333-79eaa5bb55ee", - "name_string" => $artistName, - "slug" => "/music", - "country" => "", - "description" => "", - "tentative" => true, - "favorite" => false, - "tattoo" => false, - "total_plays" => 0 + $this->makeRequest("POST", "artists", [ + "json" => [ + "mbid" => "", + "art" => "4cef75db-831f-4f5d-9333-79eaa5bb55ee", + "name_string" => $artistName, + "slug" => "/music", + "country" => "", + "description" => "", + "tentative" => true, + "favorite" => false, + "tattoo" => false, + "total_plays" => 0 + ] ]); - $this->sendFailureEmail("New tentative artist record", "A new tentative artist record was inserted for: $artistName"); - $artistData = $this->fetchFromPostgREST("artists", "name_string=eq.{$encodedArtist}&limit=1"); + $artistData = $this->fetchFromApi("artists", "name_string=eq.{$encodedArtist}&limit=1"); - $this->artistCache[$artistName] = $artistData[0] ?? []; - - return $this->artistCache[$artistName]; + return $this->artistCache[$artistName] = $artistData[0] ?? []; } private function getOrCreateAlbum(string $albumName, array $artistData): array { - if (!$this->isDatabaseAvailable()) { - error_log("Skipping album insert: database is unavailable."); - return []; - } + if (!$this->isDatabaseAvailable()) return []; $albumKey = $this->generateAlbumKey($artistData["name_string"], $albumName); if (isset($this->albumCache[$albumKey])) return $this->albumCache[$albumKey]; $encodedAlbumKey = rawurlencode($albumKey); - $existingAlbum = $this->fetchFromPostgREST("albums", "key=eq.{$encodedAlbumKey}&limit=1"); + $existingAlbum = $this->fetchFromApi("albums", "key=eq.{$encodedAlbumKey}&limit=1"); if (!empty($existingAlbum)) { - $this->albumCache[$albumKey] = $existingAlbum[0]; - return $existingAlbum[0]; + return $this->albumCache[$albumKey] = $existingAlbum[0]; } $artistId = $artistData["id"] ?? null; @@ -185,35 +169,37 @@ class NavidromeScrobbleHandler extends ApiHandler return []; } - $this->fetchFromPostgREST("albums", "", "POST", [ - "mbid" => null, - "art" => "4cef75db-831f-4f5d-9333-79eaa5bb55ee", - "key" => $albumKey, - "name" => $albumName, - "tentative" => true, - "total_plays" => 0, - "artist" => $artistId + $this->makeRequest("POST", "albums", [ + "json" => [ + "mbid" => null, + "art" => "4cef75db-831f-4f5d-9333-79eaa5bb55ee", + "key" => $albumKey, + "name" => $albumName, + "tentative" => true, + "total_plays" => 0, + "artist" => $artistId + ] ]); $this->sendFailureEmail("New tentative album record", "A new tentative album record was inserted:\n\nAlbum: $albumName\nKey: $albumKey"); - $albumData = $this->fetchFromPostgREST("albums", "key=eq.{$encodedAlbumKey}&limit=1"); + $albumData = $this->fetchFromApi("albums", "key=eq.{$encodedAlbumKey}&limit=1"); - $this->albumCache[$albumKey] = $albumData[0] ?? []; - - return $this->albumCache[$albumKey]; + return $this->albumCache[$albumKey] = $albumData[0] ?? []; } private function insertListen(array $track, string $albumKey): void { $playDate = strtotime($track["playDate"]); - $this->fetchFromPostgREST("listens", "", "POST", [ - "artist_name" => $track["artist"], - "album_name" => $track["album"], - "track_name" => $track["title"], - "listened_at" => $playDate, - "album_key" => $albumKey + $this->makeRequest("POST", "listens", [ + "json" => [ + "artist_name" => $track["artist"], + "album_name" => $track["album"], + "track_name" => $track["title"], + "listened_at" => $playDate, + "album_key" => $albumKey + ] ]); } @@ -221,24 +207,18 @@ class NavidromeScrobbleHandler extends ApiHandler { $artistKey = sanitizeMediaString($artistName); $albumKey = sanitizeMediaString($albumName); - return "{$artistKey}-{$albumKey}"; } private function sendFailureEmail(string $subject, string $message): void { - if (!$this->isDatabaseAvailable()) { - error_log("Skipping email: database is unavailable."); - return; - } + if (!$this->isDatabaseAvailable()) return; $authHeader = "Basic " . base64_encode($this->forwardEmailApiKey . ":"); - $client = new Client([ - "base_uri" => "https://api.forwardemail.net/", - ]); + $client = new Client(["base_uri" => "https://api.forwardemail.net/"]); try { - $response = $client->post("v1/emails", [ + $client->post("v1/emails", [ "headers" => [ "Authorization" => $authHeader, "Content-Type" => "application/x-www-form-urlencoded", @@ -250,12 +230,10 @@ class NavidromeScrobbleHandler extends ApiHandler "text" => $message, ], ]); - } catch (\GuzzleHttp\Exception\RequestException $e) { error_log("Request Exception: " . $e->getMessage()); if ($e->hasResponse()) { - $errorResponse = (string) $e->getResponse()->getBody(); - error_log("Error Response: " . $errorResponse); + error_log("Error Response: " . (string) $e->getResponse()->getBody()); } } catch (\Exception $e) { error_log("General Exception: " . $e->getMessage()); @@ -265,9 +243,9 @@ class NavidromeScrobbleHandler extends ApiHandler private function isDatabaseAvailable(): bool { try { - $response = $this->fetchFromPostgREST("listens", "limit=1"); + $response = $this->fetchFromApi("listens", "limit=1"); return is_array($response); - } catch (Exception $e) { + } catch (\Exception $e) { error_log("Database check failed: " . $e->getMessage()); return false; } @@ -277,7 +255,7 @@ class NavidromeScrobbleHandler extends ApiHandler try { $handler = new NavidromeScrobbleHandler(); $handler->runScrobbleCheck(); -} catch (Exception $e) { +} catch (\Exception $e) { http_response_code(500); echo json_encode(["error" => $e->getMessage()]); } diff --git a/api/search.php b/api/search.php index 42b77e6..c313955 100644 --- a/api/search.php +++ b/api/search.php @@ -47,7 +47,7 @@ class SearchHandler extends BaseHandler ], 200 ); - } catch (Exception $e) { + } catch (\Exception $e) { error_log("Search API Error: " . $e->getMessage()); $this->sendErrorResponse("Invalid request. Please check your query and try again.", 400); } @@ -55,15 +55,15 @@ class SearchHandler extends BaseHandler private function validateAndSanitizeQuery(?string $query): string { - if (empty($query) || !is_string($query)) throw new Exception("Invalid 'q' parameter. Must be a non-empty string."); + if (empty($query) || !is_string($query)) throw new \Exception("Invalid 'q' parameter. Must be a non-empty string."); $query = trim($query); - if (strlen($query) > 255) throw new Exception( + if (strlen($query) > 255) throw new \Exception( "Invalid 'q' parameter. Exceeds maximum length of 255 characters." ); - if (!preg_match('/^[a-zA-Z0-9\s\-_\'"]+$/', $query)) throw new Exception( + if (!preg_match('/^[a-zA-Z0-9\s\-_\'"]+$/', $query)) throw new \Exception( "Invalid 'q' parameter. Contains unsupported characters." ); diff --git a/api/seasons-import.php b/api/seasons-import.php index cb3b733..2e18bd4 100644 --- a/api/seasons-import.php +++ b/api/seasons-import.php @@ -7,9 +7,6 @@ use GuzzleHttp\Client; class SeasonImportHandler extends ApiHandler { - protected string $postgrestUrl; - protected string $postgrestApiKey; - private string $tmdbApiKey; private string $seasonsImportToken; @@ -17,62 +14,43 @@ class SeasonImportHandler extends ApiHandler { parent::__construct(); $this->ensureCliAccess(); - $this->loadEnvironment(); - $this->authenticateRequest(); - } - private function loadEnvironment(): void - { - $this->postgrestUrl = getenv("POSTGREST_URL") ?: $_ENV["POSTGREST_URL"]; - $this->postgrestApiKey = getenv("POSTGREST_API_KEY") ?: $_ENV["POSTGREST_API_KEY"]; $this->tmdbApiKey = getenv("TMDB_API_KEY") ?: $_ENV["TMDB_API_KEY"]; $this->seasonsImportToken = getenv("SEASONS_IMPORT_TOKEN") ?: $_ENV["SEASONS_IMPORT_TOKEN"]; + + $this->authenticateRequest(); } private function authenticateRequest(): void { if ($_SERVER["REQUEST_METHOD"] !== "POST") { - http_response_code(405); - echo json_encode(["error" => "Method Not Allowed"]); - exit(); + $this->sendErrorResponse("Method Not Allowed", 405); } $authHeader = $_SERVER["HTTP_AUTHORIZATION"] ?? ""; if (!preg_match('/Bearer\s+(.+)/', $authHeader, $matches)) { - http_response_code(401); - echo json_encode(["error" => "Unauthorized"]); - exit(); + $this->sendErrorResponse("Unauthorized", 401); } $providedToken = trim($matches[1]); if ($providedToken !== $this->seasonsImportToken) { - http_response_code(403); - echo json_encode(["error" => "Forbidden"]); - exit(); + $this->sendErrorResponse("Forbidden", 403); } } public function importSeasons(): void { - $ongoingShows = $this->fetchOngoingShows(); + $ongoingShows = $this->fetchFromApi("optimized_shows", "ongoing=eq.true"); if (empty($ongoingShows)) { - http_response_code(200); - echo json_encode(["message" => "No ongoing shows to update"]); - return; + $this->sendResponse(["message" => "No ongoing shows to update"], 200); } foreach ($ongoingShows as $show) { $this->processShowSeasons($show); } - http_response_code(200); - echo json_encode(["message" => "Season import completed"]); - } - - private function fetchOngoingShows(): array - { - return $this->fetchFromPostgREST("optimized_shows", "ongoing=eq.true", "GET"); + $this->sendResponse(["message" => "Season import completed"], 200); } private function processShowSeasons(array $show): void @@ -98,8 +76,7 @@ class SeasonImportHandler extends ApiHandler private function shouldKeepOngoing(string $status): bool { - $validStatuses = ["Returning Series", "In Production"]; - return in_array($status, $validStatuses); + return in_array($status, ["Returning Series", "In Production"]); } private function fetchShowDetails(string $tmdbId): array @@ -117,49 +94,40 @@ class SeasonImportHandler extends ApiHandler private function fetchWatchedEpisodes(int $showId): array { - $watchedEpisodes = $this->fetchFromPostgREST( - "optimized_last_watched_episodes", - "show_id=eq.{$showId}&order=last_watched_at.desc&limit=1", - "GET" - ); + $episodes = $this->fetchFromApi("optimized_last_watched_episodes", "show_id=eq.{$showId}&order=last_watched_at.desc&limit=1"); - if (empty($watchedEpisodes)) return []; + if (empty($episodes)) return []; - $lastWatched = $watchedEpisodes[0] ?? null; - - if ($lastWatched) return [ - "season_number" => (int) $lastWatched["season_number"], - "episode_number" => (int) $lastWatched["episode_number"] - ]; - - return []; + return [ + "season_number" => (int) $episodes[0]["season_number"], + "episode_number" => (int) $episodes[0]["episode_number"], + ]; } private function processSeasonEpisodes(int $showId, string $tmdbId, array $season): void { $seasonNumber = $season["season_number"] ?? null; - if ($seasonNumber === null || $seasonNumber == 0) return; $episodes = $this->fetchSeasonEpisodes($tmdbId, $seasonNumber); - if (empty($episodes)) return; - $watchedEpisodes = $this->fetchWatchedEpisodes($showId); - $lastWatchedSeason = $watchedEpisodes["season_number"] ?? null; - $lastWatchedEpisode = $watchedEpisodes["episode_number"] ?? null; - $scheduledEpisodes = $this->fetchFromPostgREST( - "optimized_scheduled_episodes", - "show_id=eq.{$showId}&season_number=eq.{$seasonNumber}", - "GET" + $watched = $this->fetchWatchedEpisodes($showId); + $lastWatchedSeason = $watched["season_number"] ?? null; + $lastWatchedEpisode = $watched["episode_number"] ?? null; + + $scheduled = $this->fetchFromApi( + "optimized_scheduled_episodes", + "show_id=eq.{$showId}&season_number=eq.{$seasonNumber}" ); - $scheduledEpisodeNumbers = array_column($scheduledEpisodes, "episode_number"); + + $scheduledEpisodeNumbers = array_column($scheduled, "episode_number"); foreach ($episodes as $episode) { $episodeNumber = $episode["episode_number"] ?? null; - if ($episodeNumber === null) continue; if (in_array($episodeNumber, $scheduledEpisodeNumbers)) continue; + if ($lastWatchedSeason !== null && $seasonNumber < $lastWatchedSeason) return; if ($seasonNumber == $lastWatchedSeason && $episodeNumber <= $lastWatchedEpisode) continue; @@ -183,11 +151,10 @@ class SeasonImportHandler extends ApiHandler private function addEpisodeToSchedule(int $showId, int $seasonNumber, array $episode): void { $airDate = $episode["air_date"] ?? null; - if (!$airDate) return; - $currentDate = date("Y-m-d"); - $status = ($airDate && $airDate < $currentDate) ? "aired" : "upcoming"; + $today = date("Y-m-d"); + $status = ($airDate < $today) ? "aired" : "upcoming"; $payload = [ "show_id" => $showId, @@ -197,7 +164,7 @@ class SeasonImportHandler extends ApiHandler "status" => $status, ]; - $this->fetchFromPostgREST("scheduled_episodes", "", "POST", $payload); + $this->makeRequest("POST", "scheduled_episodes", ["json" => $payload]); } } diff --git a/api/watching-import.php b/api/watching-import.php index 1c3ea06..dc9283f 100644 --- a/api/watching-import.php +++ b/api/watching-import.php @@ -7,9 +7,6 @@ use GuzzleHttp\Client; class WatchingImportHandler extends ApiHandler { - protected string $postgrestUrl; - protected string $postgrestApiKey; - private string $tmdbApiKey; private string $tmdbImportToken; @@ -17,17 +14,9 @@ class WatchingImportHandler extends ApiHandler { parent::__construct(); $this->ensureCliAccess(); - $this->loadEnvironment(); - } - private function loadEnvironment(): void - { - $this->postgrestUrl = $_ENV["POSTGREST_URL"] ?? getenv("POSTGREST_URL"); - $this->postgrestApiKey = - $_ENV["POSTGREST_API_KEY"] ?? getenv("POSTGREST_API_KEY"); $this->tmdbApiKey = $_ENV["TMDB_API_KEY"] ?? getenv("TMDB_API_KEY"); - $this->tmdbImportToken = - $_ENV["WATCHING_IMPORT_TOKEN"] ?? getenv("WATCHING_IMPORT_TOKEN"); + $this->tmdbImportToken = $_ENV["WATCHING_IMPORT_TOKEN"] ?? getenv("WATCHING_IMPORT_TOKEN"); } public function handleRequest(): void @@ -37,19 +26,22 @@ class WatchingImportHandler extends ApiHandler if (!$input) $this->sendErrorResponse("Invalid or missing JSON body", 400); $providedToken = $input["token"] ?? null; - - if (!$providedToken || $providedToken !== $this->tmdbImportToken) $this->sendErrorResponse("Unauthorized access", 401); - $tmdbId = $input["tmdb_id"] ?? null; $mediaType = $input["media_type"] ?? null; - if (!$tmdbId || !$mediaType) $this->sendErrorResponse("tmdb_id and media_type are required", 400); + if ($providedToken !== $this->tmdbImportToken) { + $this->sendErrorResponse("Unauthorized access", 401); + } + + if (!$tmdbId || !$mediaType) { + $this->sendErrorResponse("tmdb_id and media_type are required", 400); + } try { $mediaData = $this->fetchTMDBData($tmdbId, $mediaType); $this->processMedia($mediaData, $mediaType); - $this->sendResponse("Media imported successfully", 200); - } catch (Exception $e) { + $this->sendResponse(["message" => "Media imported successfully"], 200); + } catch (\Exception $e) { $this->sendErrorResponse("Error: " . $e->getMessage(), 500); } } @@ -65,8 +57,7 @@ class WatchingImportHandler extends ApiHandler ]); $data = json_decode($response->getBody(), true); - - if (empty($data)) throw new Exception("No data found for TMDB ID: {$tmdbId}"); + if (empty($data)) throw new \Exception("No data found for TMDB ID: {$tmdbId}"); return $data; } @@ -75,18 +66,19 @@ class WatchingImportHandler extends ApiHandler { $id = $mediaData["id"]; $title = $mediaType === "movie" ? $mediaData["title"] : $mediaData["name"]; - $year = - $mediaData["release_date"] ?? ($mediaData["first_air_date"] ?? null); + $year = $mediaData["release_date"] ?? $mediaData["first_air_date"] ?? null; $year = $year ? substr($year, 0, 4) : null; $description = $mediaData["overview"] ?? ""; + $tags = array_map( fn($genre) => strtolower(trim($genre["name"])), - $mediaData["genres"] + $mediaData["genres"] ?? [] ); - $slug = - $mediaType === "movie" - ? "/watching/movies/{$id}" - : "/watching/shows/{$id}"; + + $slug = $mediaType === "movie" + ? "/watching/movies/{$id}" + : "/watching/shows/{$id}"; + $payload = [ "title" => $title, "year" => $year, @@ -94,80 +86,64 @@ class WatchingImportHandler extends ApiHandler "tmdb_id" => $id, "slug" => $slug, ]; - $response = $this->fetchFromPostgREST( - $mediaType === "movie" ? "movies" : "shows", - "", - "POST", - $payload - ); - if (empty($response["id"])) { - $queryResponse = $this->fetchFromPostgREST( - $mediaType === "movie" ? "movies" : "shows", - "tmdb_id=eq.{$id}", - "GET" - ); - $response = $queryResponse[0] ?? []; + $table = $mediaType === "movie" ? "movies" : "shows"; + + try { + $response = $this->makeRequest("POST", $table, ["json" => $payload]); + } catch (\Exception $e) { + $response = $this->fetchFromApi($table, "tmdb_id=eq.{$id}")[0] ?? []; } if (!empty($response["id"])) { $mediaId = $response["id"]; $existingTagMap = $this->getTagIds($tags); $updatedTagMap = $this->insertMissingTags($tags, $existingTagMap); - $this->associateTagsWithMedia( - $mediaType, - $mediaId, - array_values($updatedTagMap) - ); + $this->associateTagsWithMedia($mediaType, $mediaId, array_values($updatedTagMap)); } } private function getTagIds(array $tags): array { - $existingTagMap = []; + $map = []; + foreach ($tags as $tag) { - $query = "name=ilike." . urlencode($tag); - $existingTags = $this->fetchFromPostgREST("tags", $query, "GET"); - - if (!empty($existingTags[0]["id"])) $existingTagMap[strtolower($tag)] = $existingTags[0]["id"]; - } - return $existingTagMap; - } - - private function insertMissingTags(array $tags, array $existingTagMap): array - { - $newTags = array_diff($tags, array_keys($existingTagMap)); - foreach ($newTags as $newTag) { - try { - $response = $this->fetchFromPostgREST("tags", "", "POST", [ - "name" => $newTag, - ]); - if (!empty($response["id"])) $existingTagMap[$newTag] = $response["id"]; - } catch (Exception $e) { - $queryResponse = $this->fetchFromPostgREST( - "tags", - "name=eq.{$newTag}", - "GET" - ); - if (!empty($queryResponse[0]["id"])) $existingTagMap[$newTag] = $queryResponse[0]["id"]; + $response = $this->fetchFromApi("tags", "name=ilike." . urlencode($tag)); + if (!empty($response[0]["id"])) { + $map[strtolower($tag)] = $response[0]["id"]; } } - return $existingTagMap; + + return $map; } - private function associateTagsWithMedia( - string $mediaType, - int $mediaId, - array $tagIds - ): void { - $junctionTable = $mediaType === "movie" ? "movies_tags" : "shows_tags"; + private function insertMissingTags(array $tags, array $existingMap): array + { + $newTags = array_diff($tags, array_keys($existingMap)); + + foreach ($newTags as $tag) { + try { + $created = $this->makeRequest("POST", "tags", ["json" => ["name" => $tag]]); + if (!empty($created["id"])) $existingMap[$tag] = $created["id"]; + } catch (\Exception $e) { + $fallback = $this->fetchFromApi("tags", "name=eq." . urlencode($tag)); + if (!empty($fallback[0]["id"])) $existingMap[$tag] = $fallback[0]["id"]; + } + } + + return $existingMap; + } + + private function associateTagsWithMedia(string $mediaType, int $mediaId, array $tagIds): void + { + $junction = $mediaType === "movie" ? "movies_tags" : "shows_tags"; $mediaColumn = $mediaType === "movie" ? "movies_id" : "shows_id"; foreach ($tagIds as $tagId) { - $this->fetchFromPostgREST($junctionTable, "", "POST", [ + $this->makeRequest("POST", $junction, ["json" => [ $mediaColumn => $mediaId, - "tags_id" => $tagId, - ]); + "tags_id" => $tagId + ]]); } } } diff --git a/package-lock.json b/package-lock.json index 3f581ad..33183a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "coryd.dev", - "version": "2.1.4", + "version": "3.0.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "coryd.dev", - "version": "2.1.4", + "version": "3.0.3", "license": "MIT", "dependencies": { "html-minifier-terser": "7.2.0", @@ -184,9 +184,9 @@ } }, "node_modules/@11ty/eleventy-plugin-bundle": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@11ty/eleventy-plugin-bundle/-/eleventy-plugin-bundle-3.0.4.tgz", - "integrity": "sha512-9Y9aLB5kwK7dkTC+Pfbt4EEs58TMQjuo1+EJ18dA/XKDxczHj2fAUZcETMgNQ17AmrMDj5HxJ0ezFNGpMcD7Vw==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@11ty/eleventy-plugin-bundle/-/eleventy-plugin-bundle-3.0.5.tgz", + "integrity": "sha512-LfcXr5pvvFjA6k1u8o0vqxbFVY8elpxIeICvdJti9FWUbHyJlS6ydRkyUnijpa+NTsj7DrlcrD1r1uBrANHYeA==", "dev": true, "license": "MIT", "dependencies": { @@ -3191,13 +3191,13 @@ "license": "MIT" }, "node_modules/parse5": { - "version": "7.2.1", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.2.1.tgz", - "integrity": "sha512-BuBYQYlv1ckiPdQi/ohiivi9Sagc9JG+Ozs0r7b/0iK3sKmrb0b9FdWdBbOdx6hBCM/F9Ir82ofnBhtZOjCRPQ==", + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", "dev": true, "license": "MIT", "dependencies": { - "entities": "^4.5.0" + "entities": "^6.0.0" }, "funding": { "url": "https://github.com/inikulin/parse5?sponsor=1" @@ -3234,9 +3234,9 @@ } }, "node_modules/parse5/node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.0.tgz", + "integrity": "sha512-aKstq2TDOndCn4diEyp9Uq/Flu2i1GlLkc6XIDQSDMuaFE3OPW5OphLCyQ5SpSJZTb4reN+kTcYru5yIfXoRPw==", "dev": true, "license": "BSD-2-Clause", "engines": { diff --git a/package.json b/package.json index 8538385..1dbb96f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "coryd.dev", - "version": "2.1.4", + "version": "3.0.3", "description": "The source for my personal site. Built using 11ty (and other tools).", "type": "module", "engines": { diff --git a/queries/views/feeds/recent_activity.psql b/queries/views/feeds/recent_activity.psql index 2edfdbb..4475c61 100644 --- a/queries/views/feeds/recent_activity.psql +++ b/queries/views/feeds/recent_activity.psql @@ -1,6 +1,7 @@ CREATE OR REPLACE VIEW optimized_recent_activity AS WITH activity_data AS ( SELECT + NULL::bigint AS id, p.date AS content_date, p.title, p.content AS description, @@ -22,6 +23,7 @@ WITH activity_data AS ( UNION ALL SELECT + NULL::bigint AS id, l.date AS content_date, l.title, l.description, @@ -43,6 +45,7 @@ WITH activity_data AS ( UNION ALL SELECT + NULL::bigint AS id, b.date_finished AS content_date, CONCAT(b.title, CASE WHEN b.rating IS NOT NULL THEN CONCAT(' (', b.rating, ')') ELSE '' END @@ -67,6 +70,7 @@ WITH activity_data AS ( UNION ALL SELECT + NULL::bigint AS id, m.last_watched AS content_date, CONCAT(m.title, CASE WHEN m.rating IS NOT NULL THEN CONCAT(' (', m.rating, ')') ELSE '' END @@ -91,6 +95,7 @@ WITH activity_data AS ( UNION ALL SELECT + c.id, c.date AS content_date, CONCAT(c.artist->>'name', ' at ', c.venue->>'name_short') AS title, c.concert_notes AS description, @@ -104,7 +109,7 @@ WITH activity_data AS ( c.venue->>'latitude' AS venue_lat, c.venue->>'longitude' AS venue_lon, c.venue->>'name_short' AS venue_name, - c.notes AS notes, + c.concert_notes AS notes, 'concerts' AS type, 'Concert' AS label FROM optimized_concerts c diff --git a/queries/views/media/music/concerts.psql b/queries/views/media/music/concerts.psql index 4111299..c577821 100644 --- a/queries/views/media/music/concerts.psql +++ b/queries/views/media/music/concerts.psql @@ -2,7 +2,6 @@ CREATE OR REPLACE VIEW optimized_concerts AS SELECT c.id, c.date, - c.notes, CASE WHEN c.artist IS NOT NULL THEN json_build_object('name', a.name_string, 'url', a.slug) ELSE @@ -16,4 +15,3 @@ FROM LEFT JOIN venues v ON c.venue = v.id ORDER BY c.date DESC; - diff --git a/server/utils/strings.php b/server/utils/strings.php index 536306f..fc7f85c 100644 --- a/server/utils/strings.php +++ b/server/utils/strings.php @@ -1,7 +1,5 @@ { // dialog controls (() => { - if (document.querySelectorAll(".dialog-open").length) { - document.querySelectorAll(".dialog-open").forEach((button) => { - const dialogId = button.getAttribute("data-dialog-trigger"); - const dialog = document.getElementById(`dialog-${dialogId}`); + const dialogButtons = document.querySelectorAll(".dialog-open"); + if (!dialogButtons.length) return; - if (!dialog) return; + dialogButtons.forEach((button) => { + const dialogId = button.getAttribute("data-dialog-trigger"); + const dialog = document.getElementById(`dialog-${dialogId}`); + if (!dialog) return; - const closeButton = dialog.querySelector(".dialog-close"); + const closeButton = dialog.querySelector(".dialog-close"); - button.addEventListener("click", () => { - dialog.showModal(); - dialog.classList.remove("closing"); - }); + button.addEventListener("click", async () => { + const isDynamic = dialog.dataset.dynamic; + const isLoaded = dialog.dataset.loaded; - if (closeButton) - closeButton.addEventListener("click", () => { - dialog.classList.add("closing"); - setTimeout(() => dialog.close(), 200); - }); + if (isDynamic && !isLoaded) { + const markdownFields = dialog.dataset.markdown || ""; + try { + const res = await fetch(`/api/proxy.php?data=${isDynamic}&id=${dialogId}&markdown=${encodeURIComponent(markdownFields)}`); + const [data] = await res.json(); + const firstField = markdownFields.split(",")[0]?.trim(); + const html = data?.[`${firstField}_html`] || "
+
+ {%- unless dynamic -%}
{{ content }}
+ {%- endunless -%}
diff --git a/src/includes/home/recent-activity.liquid b/src/includes/home/recent-activity.liquid
index d52078f..868854f 100644
--- a/src/includes/home/recent-activity.liquid
+++ b/src/includes/home/recent-activity.liquid
@@ -15,12 +15,13 @@
• {{ item.label }}
{%- if item.notes -%}
- {% assign notes = item.notes | prepend: "### Notes\n" | markdown %}
+ {% assign notes = item.notes | markdown %}
{% render "blocks/dialog.liquid",
icon:"info-circle",
label:"View info about this concert"
- content:notes,
- id:item.content_date
+ dynamic:"optimized_concerts",
+ markdown:"concert_notes",
+ id:item.id
%}
{%- endif -%}
diff --git a/src/layouts/base.liquid b/src/layouts/base.liquid
index ea200a2..8149ab7 100644
--- a/src/layouts/base.liquid
+++ b/src/layouts/base.liquid
@@ -3,8 +3,6 @@
-
-
No notes available.
"; - dialog.addEventListener("click", (event) => { - const rect = dialog.getBoundingClientRect(); + dialog.querySelectorAll(".dialog-dynamic").forEach((el) => el.remove()); - if ( - event.clientX < rect.left || - event.clientX > rect.right || - event.clientY < rect.top || - event.clientY > rect.bottom - ) { - dialog.classList.add("closing"); - setTimeout(() => dialog.close(), 200); + const container = document.createElement("div"); + + container.classList.add("dialog-dynamic"); + container.innerHTML = html; + dialog.appendChild(container); + dialog.dataset.loaded = "true"; + } catch (err) { + dialog.querySelectorAll(".dialog-dynamic").forEach((el) => el.remove()); + + const errorNode = document.createElement("div"); + + errorNode.classList.add("dialog-dynamic"); + errorNode.textContent = "Failed to load content."; + dialog.appendChild(errorNode); + + console.warn("Dialog content load error:", err); } - }); + } + dialog.showModal(); + dialog.classList.remove("closing"); + }); - dialog.addEventListener("cancel", (event) => { - event.preventDefault(); + if (closeButton) { + closeButton.addEventListener("click", () => { dialog.classList.add("closing"); setTimeout(() => dialog.close(), 200); }); + } + + dialog.addEventListener("click", (event) => { + const rect = dialog.getBoundingClientRect(); + const outsideClick = + event.clientX < rect.left || + event.clientX > rect.right || + event.clientY < rect.top || + event.clientY > rect.bottom; + + if (outsideClick) { + dialog.classList.add("closing"); + setTimeout(() => dialog.close(), 200); + } }); - } + + dialog.addEventListener("cancel", (event) => { + event.preventDefault(); + dialog.classList.add("closing"); + setTimeout(() => dialog.close(), 200); + }); + }); })(); // text toggle for media pages @@ -51,12 +83,9 @@ window.addEventListener("load", () => { const content = document.querySelector("[data-toggle-content]"); const text = document.querySelectorAll("[data-toggle-content] p"); const minHeight = 500; // this needs to match the height set on [data-toggle-content].text-toggle-hidden in text-toggle.css - const interiorHeight = Array.from(text).reduce( - (acc, node) => acc + node.scrollHeight, - 0, - ); + const interiorHeight = Array.from(text).reduce((acc, node) => acc + node.scrollHeight, 0); - if (!button || !content || !text) return; + if (!button || !content || !text.length) return; if (interiorHeight < minHeight) { content.classList.remove("text-toggle-hidden"); diff --git a/src/assets/styles/base/fonts.css b/src/assets/styles/base/fonts.css index 7ddfa7f..7038e93 100644 --- a/src/assets/styles/base/fonts.css +++ b/src/assets/styles/base/fonts.css @@ -6,22 +6,6 @@ font-display: swap; } -@font-face { - font-family: 'Lexend'; - src: url('/assets/fonts/ll.woff2') format('woff2'); - font-weight: 300; - font-style: normal; - font-display: swap; -} - -@font-face { - font-family: 'Lexend'; - src: url('/assets/fonts/lb.woff2') format('woff2'); - font-weight: 700; - font-style: normal; - font-display: swap; -} - @font-face { font-family: "Space Grotesk"; src: url("/assets/fonts/sg.woff2") format("woff2"); diff --git a/src/assets/styles/base/index.css b/src/assets/styles/base/index.css index e240f97..8770e81 100644 --- a/src/assets/styles/base/index.css +++ b/src/assets/styles/base/index.css @@ -1,7 +1,7 @@ html, body { font-family: var(--font-body); - font-weight: var(--font-weight-light); + font-weight: var(--font-weight-regular); color: var(--text-color); background: var(--background-color); } diff --git a/src/assets/styles/base/vars.css b/src/assets/styles/base/vars.css index a13e31d..e10b7d8 100644 --- a/src/assets/styles/base/vars.css +++ b/src/assets/styles/base/vars.css @@ -71,7 +71,7 @@ --border-gray: 1px solid var(--gray-light); /* fonts */ - --font-body: "Lexend", -apple-system, BlinkMacSystemFont, avenir next, avenir, segoe ui, helvetica neue, Cantarell, Ubuntu, roboto, noto, helvetica, arial, sans-serif; + --font-body: Helvetica Neue, Helvetica, Arial, sans-serif; --font-heading: "Space Grotesk", "Arial Black", "Arial Bold", Gadget, sans-serif; --font-code: "MonoLisa", SFMono-Regular, Menlo, Consolas, Monaco, Liberation Mono, Lucida Console, monospace; @@ -84,7 +84,6 @@ --font-size-2xl: 1.45rem; --font-size-3xl: 1.6rem; - --font-weight-light: 300; --font-weight-regular: 400; --font-weight-bold: 700; diff --git a/src/assets/styles/components/dialog.css b/src/assets/styles/components/dialog.css index 58df183..73dcf97 100644 --- a/src/assets/styles/components/dialog.css +++ b/src/assets/styles/components/dialog.css @@ -54,7 +54,7 @@ dialog { font-size: var(--font-size-lg); } - h1, h2, h3 { + * { margin-top: 0; } diff --git a/src/includes/blocks/dialog.liquid b/src/includes/blocks/dialog.liquid index cf649d4..8da70f1 100644 --- a/src/includes/blocks/dialog.liquid +++ b/src/includes/blocks/dialog.liquid @@ -9,9 +9,15 @@ -= number_format($totalCount) ?> item= $totalCount === 1 ? '' : 's' ?> items tagged with #= htmlspecialchars($tag) ?>. You can search my site as well.
+= number_format($totalCount) ?> item= $totalCount === 1 ? '' : 's' ?> items tagged with #= htmlspecialchars($tag) ?>. You can search my site as well.
- = htmlspecialchars($item['title']) ?> (= $item['rating'] ?>)
+ = htmlspecialchars($item['title']) ?> (= $item['rating'] ?>)
- via
+  via 
= $item['author']['name'] ?>
diff --git a/src/pages/media/music/concerts.html b/src/pages/media/music/concerts.html
index 9c009bb..abade31 100644
--- a/src/pages/media/music/concerts.html
+++ b/src/pages/media/music/concerts.html
@@ -33,12 +33,13 @@ permalink: "/music/concerts/{% if pagination.pageNumber > 0 %}{{ pagination.page
{{ artistName }} on {{ concert.date | date: "%B %e, %Y" }}
{% if venue %} at {{ venue }}{% endif %}
- {%- if concert.notes -%}
- {% assign notes = concert.notes | prepend: "### Notes\n" | markdown %}
+ {%- if concert.concert_notes -%}
+ {% assign notes = concert.concert_notes | markdown %}
{% render "blocks/dialog.liquid",
icon:"info-circle",
label:"View info about this concert"
- content:notes,
+ dynamic:"optimized_concerts",
+ markdown:"concert_notes",
id:concert.id
%}
{%- endif -%}
From c5a117f37a30930db9ca14b1b07104baa60ae753 Mon Sep 17 00:00:00 2001
From: Cory Dransfeldt
Date: Tue, 22 Apr 2025 15:54:51 -0700
Subject: [PATCH 8/8] feat(fonts.css): update body font to DM Sans
---
package-lock.json | 4 ++--
package.json | 2 +-
src/assets/fonts/dmb.woff2 | Bin 0 -> 11624 bytes
src/assets/fonts/dmr.woff2 | Bin 0 -> 11516 bytes
src/assets/fonts/lb.woff2 | Bin 39144 -> 0 bytes
src/assets/fonts/ll.woff2 | Bin 37396 -> 0 bytes
src/assets/styles/base/fonts.css | 32 +++++++++++++++++++++-------
src/assets/styles/base/vars.css | 2 +-
src/assets/styles/pages/contact.css | 6 ++++--
9 files changed, 32 insertions(+), 14 deletions(-)
create mode 100644 src/assets/fonts/dmb.woff2
create mode 100644 src/assets/fonts/dmr.woff2
delete mode 100644 src/assets/fonts/lb.woff2
delete mode 100644 src/assets/fonts/ll.woff2
diff --git a/package-lock.json b/package-lock.json
index 33183a9..9f5f8d2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "coryd.dev",
- "version": "3.0.3",
+ "version": "3.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "coryd.dev",
- "version": "3.0.3",
+ "version": "3.1.0",
"license": "MIT",
"dependencies": {
"html-minifier-terser": "7.2.0",
diff --git a/package.json b/package.json
index 1dbb96f..068e73c 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "coryd.dev",
- "version": "3.0.3",
+ "version": "3.1.0",
"description": "The source for my personal site. Built using 11ty (and other tools).",
"type": "module",
"engines": {
diff --git a/src/assets/fonts/dmb.woff2 b/src/assets/fonts/dmb.woff2
new file mode 100644
index 0000000000000000000000000000000000000000..de9fd612325249d62c5525d32b59ff584944d094
GIT binary patch
literal 11624
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 $`)4>{rMnifd))DB{d8!W8jE0d+9{28n^y3
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
literal 0
HcmV?d00001
diff --git a/src/assets/fonts/dmr.woff2 b/src/assets/fonts/dmr.woff2
new file mode 100644
index 0000000000000000000000000000000000000000..722c2f954ab53e715b8241321ba4f1a2ac3fc857
GIT binary patch
literal 11516
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
literal 0
HcmV?d00001
diff --git a/src/assets/fonts/lb.woff2 b/src/assets/fonts/lb.woff2
deleted file mode 100644
index dad8b9df531882b630e4e83a7f182d64cd530d95..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 39144
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 K zMJO32;jbf+`s*u!J SHuJ5soVjh( z-sj>K8u@MTCz!bZZKLPL{)Tv-p^_J~9K0-1X#}tGA}zZdUv^ZZ?IjDm6R2QhBMdhf zuVk(IW*C)V%82LaXaekPgJlhQ#LAjTvb=p3A$q&NgFz!tECR2@%D$j`hE9!G+JJo< z$YkTkmiA}w+QNvJ@X%*Ft^qR$!y+sAKrH_y&P^L02EL}E< a0+PMp zBP0W;8C`7GkPa7Qb5RYo)1!zp&~j+Hb$9%Q>m2GCRV%A{znA!xYX1+NRP9;jzimUn zVV8Tji<@nq9 `o;Eb6zlJxr6+Uq$w?KPpM2hhqJoMMJqJ5C6h8NG}e;~#Rdjyb$qD4%jHnt z$_w7Mo?{j`f
(}oh>?~^wU~|O+}Nw%>y3>x z8iiuUo%wV5kv#`g-ZqtR(MzAVF)(IJu`86_=r7w@|060-ZFY{W4~_(wrp?T@BI`Q( zK}5!!o+Sn-tSJV2HW!Lnz*s9=ZTkH0zp4b{rO8bvKN&z6ItJWG0Hfya^VBZ4f;*A` zhkIE#2t{3b#mqU}>&n=lP}WJQTElxy+^)0X%pjS-Oe47G-=1WEPq1pNLDrD4>!`Lo zbu$5w1~Mc5e1sV81p^549SFY#h9+Z%(|gqy0VxzkvIR%y5XWRo*+xu@3brsEN?3qY z`IRF_O=oHRr1~Lej&Z1#;$Qv(O#h+ni!s6=R6sH&tD$~XcxKc$5|T>9NEl|3ykwVy zr%OF440M!g5#ROsY?q2$dGmVe^SWFog)~h)#iV?DBDZH^n2!vyo%d4kU$uADNc5!M z7ou<+yBP@m=GT6RtJF&G*Kpj#-dS?VrKi>%@1vjoK|P35%DvRL?8Dyrk39fRSYglE zefSTqQ&GfV&nutGpdtiLN3Epzx6i%wK)-R2j7!_hFZAT5LY6;}<4Nj!BpzxvH5x$* z#R^G_-j!7SBgl(AB!JFTXguL%*xRz9P)JHp{E~v6IZLG(r(AEN<%&Tp&{fMDDa@)o&>_H5K zn3Y&*zi8xJm4vFrU2+Ofv4BricF2W$9-;7oVat4@2N#GAX#<+Z)?T;p0a@ABgHeX- zu>?&qqc<>6Hb0S|^Hw&)RLj=G^TFY@xv!p$IMNKfjqu-E^kP$OG&c8>Tgj^4f#~#P zoLI>4sQ`QsTo`2}93EgIBqnGoEH=1&6H1XGSgwnrM!Faor6MWgKQb_JilD>k&~^~y zV7#&!-%AInzZ&1UvXyLZ@&tqu6oxx0lH{k1LN=DN+E>8fh?GoY!f5Xl0)$a>K*MJd zNXZ3L4uV0k3=0&6v4}=hjw8Xg#3OLr*A1ISr(Cn|YFfqX#dOZ?*xN^Dt#7qqv!cF2 z`DFxQfx!b-%Z;?21rRB}P8hV>aXOw4s1z*gDBP^KB=IJ-BU%Bh`I6pqW`VGhq#U3z z*lU{8!88z`*db{ZU>ed`m>}4^OUTpKzS@RgD{ieg8u#&<^HND$Q0FyCC9%f0Pl%vl z-*QJ5Gvc+_LW&_KY)z$J`Kx)|3);Sbz&CWz5D`ewB8o;qiRI;})B@Rr_X?BJF3xy* zYV{qtF;ej+b*T4{WIn-Xa;HsKsL0tn!6ObV32ov^+wpEO`P8 zPEdw{ODukLSH?sO!*V8oDuF=ezkAHpgF7nS?HTlo5`gcMZ-U(AafpD^oYVnVQlwg? z>>N?p x@`dw| z9I0f9RI(@9fWwn*9u|#7Dm!A`M0FnSw9)V2ogO53JZQh5_y?G^cp^X|hXiAexJ$_K z1z a(;%AQA9&q9cKd@Ff@e{W~lQreyuDCLKz76@lwgk8_Qn zu}KmWht>|($1&jP5zsbA13;f&JgpHS4IP#KLb-~(-;^x#<~a(kcK`bObS(p1Hmdw% z7@z|NIIx|`MIiwKzAIhLLx|K2^@sZ&how2^K$63;A>dmy?^igTK9aqLJo%NGRXUWS zD$Fut=}qam9`!p<`&N-DR-uEFLSn*7F`ejZ`-|p<{1ML v zkc=wxh!?coU?U9sNw@8B@sG8I<0>X|>%z^weYW hk%nI#P_`W#kb=T=*hZ~XtMo#??l(Pli z6V?+RZP8>Jr?*_YKsYixD$C+DsI* ^5r_m=nZE;l^CL*V8= z-NHP9oG4n*o*1u{z@eB2kAOwExHv5?Yl>l?SoMk4>gsYkV;aj$zO_6aughK>e}Kl_ z1t`Ubl`%#i>=+pHhw_|w(TjTJF#hP}q~X)?b`h;XG=@40yHTiq-{E6;_@(;SV)S4> zXI9J`1OJ@_!1J$o4;cvw6e|x=Hr|41S#sysSp~S^@SxT4Me&v`_P>giGR;ai2DlnJ zpTm$LzU%-p5`o0IdUU5*8v9;bdiW?+wS{uu-@ch^&@8g0L1|W0tCt^q2$Jef%ge&t z9nZ(eB<-JeuiXk;YdVkTVs*By1S1q&1q&1@q@+@XLbNUY3IAYOw$0-*W}bb@+OpH6 zXj+$Y`fTM}fTRHfVJN2VQ#~p1ak-rB#zMmWm4vR_vBJ!e6xIXR?IYndol9=C&yB<- z?-A=?@4@ezs#0h0wjK18j>^EB$8uj8`lZnu2PMW05pHAu?UOY~2%-TH@~t}&NhZW5 z@<6{Q!suPaM|c=i>c)+(9n)g!tYhF*3j<@hQDz_!JQ0FGFiag2!%%cb=tN9+C!raI z6jQkqb7!jV)Y$f<%Ju~9_q6Qy80ow@jsR<<`#)&EX_P=zL>iI~ihG5LBz&>5X-DD{ zjT|iNP9-^~h++K;@wsbvWXWO1&Kw_B@NM=oI>;PQg*n6>JsT6_%wbykxgKrJrpV zYNVw!)-64|DTtdRHn`2)`vNOJG2N8pa9llmYhB{~-K3{T@ao%|cLu|cQ s5-QKm&4Dx*zh~{z6uT<#2)$1p5btRhLBA#7@9|h>oE#>rQBx z-6o#V*N1L$9Uc?gq7cq s$?>$nP^BSaH)jHq0b6;}nb6|`o9NP&$;2hwAU3aP{t_fqo#ycDl z&0o6cn3IWCJh$fc5--lcQorwoQ{QibiJiXvJif6kH4Z#vyQ^&QjI=XIHC?KoGpQ?= zGYEO^Mpt>~VLF-IB+SXQg7g}hv_PB3&=v*yX{w?qK!b==0=j|R?q)pTszEB`pjqX* zGUkEe1$h%IxQds#5J|iI-4>C62tL|>movN;-OBfV$?K8XrX-t1KCshv^0#?tyaJXo z6L#esHKC9rt-TSP_HC==p_WNf{3VPxgM3G+ 1o@6Ix6H{u1V&X4w;;9(^q?x5X%Ag|Dyzhb~+sc1{}xgSJ%|&UVdq zr3dC%yvE%^yjXLrlo+(s-zNm75{_yM0I!0ljbOBPP73G7Nldd;N|c#0a%cgwcC0YC zDyzwboH}p0l)Xfu{WyWr+OfUT(er%@mHlU(=|P;AVr8D+g9%0w=w*FL6q?3CCGh;n z*p9<^R=}7}k%gE^Cq~nUvolou(v71>c@bx-ZzCP_{oM1ax)>!br1wqnHS5^;l_+6q z@Hj$E=!CGN)&$i?SX}-x776`&u^mnDLT0|*ySrSmMRFmKz>M~hqN$P`l{1r i~e@DXl%A)v)La^MRWb=11S?%SmEV!l$!?raDvz^1ci@+ z(~?W6V>;d?s@OdHZ{QUQ6A6j2%myCObLTzC37M3X3+Yc=7LeBkQb` ;Wi5!*@ELpYvdWuYsG2?O=nxHlfpIMqGTlE!XK*qA;BdF$R!tv zWG)mdPS_>4w6eoGg;m4CoWdp#IL7Y)mdT48^rfTE93h=wQ881UdsXjZrrI~ZmS;vm zewQnP%k>zcFuQ2XGi)VH)`AfgIn)3?*i0#ZRiaC$%OscpDTKlUeTT^7+fkmy8y; z8nD%mkxn3k_)g+Yl9_SA?pu*enyrWrnV07GJVf`apRl z`i&9Ltw>}?G)$+Xp+AvtC;+=%6FT~&rN{LK5EhIvI|OoCNBVr dzoJecV!2 zkc=k$6Q?pn)>_DEE585c2jRn#T)y|@lV`AyxgTw+?BZopB9j%d7vNvwoYa>G1<)}P zpee%CEErnUc0>!uy)cd%7BUqHWX2c%I-9lpTQ9xqB-+yh-?{*n(co tn&hgta`0qy9UjNoMJrhq?e)#UG`P0)>&Tg6=rEYB7Z6 zS|VIhHxZMecSM`p0; rQGNYkw*+50i)rkS2j~S9p zJ=Q!vp{szb>dS#&ar;mo)UDk)0>%C9**^D3#MI^%``O@DJ`J}uf9y(l17-E3to8@B zGiXOf?kM$|O3Dh_eCjbENc5iRQF361*F;Dp3Y^vF-%2ov=9?Ivl7{QLEDK|0xma?op zG8g}}APj;cW( x`eCI@6z3~FsB{>M*;=Emd zU5)X!9yiPl1@!xL#Cnmde4`V0p2sF)HH3bQ0Egi$FjVpeC3aJqGv)H1L-Hb(wf;;i zJ{Lxy5a1TOm-j53)hsR!pwsTE9kMc^1IiE+fq3pB{j-*c*Vkf|m?@UTOL;%0ZTD0q z#QO Lo=xlAd|Lo zvAFj52_k-W6yG8n3(OIjyfiOiYcA#{Z8T>m9+OAoDUl=l1y_ldI`FCmkTv9{usTcj z&&x*)#Tgy<@=nY$bMn(^V2&