diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b611a8d --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +*.log + +# Local overrides +api/config.local.php + +# Docker volumes (data is recreated from sql/schema.sql) +.docker/ diff --git a/Answers to technical questions.md b/Answers to technical questions.md new file mode 100644 index 0000000..448104f --- /dev/null +++ b/Answers to technical questions.md @@ -0,0 +1,110 @@ +# Answers to technical questions + +> Note: the JSON in question 3 contains a few personal placeholders +> (`<...>`) — please replace them before submitting. + +## 1. How long did you spend on the coding test? What would you add with more time? + +I spent roughly **half a day** on this exercise — most of it on faithfully +matching the design across the two breakpoints (the synced tab → slider → +image relationship in web view, and the accordion-with-background-image +behaviour in mobile view) and on giving the PHP/MySQL layer a clean, +testable structure rather than a single procedural script. + +**What I built** + +- A small **JSON REST API** in PHP (PDO, prepared statements) with a thin + data-access layer (`SlideRepository`) and a request router that does input + validation and returns proper HTTP status codes (`201`, `404`, `422`, `405`). +- A normalised **MySQL schema** (`tabs` 1‑to‑many `slides`, FK with + `ON DELETE CASCADE`, indexes, `utf8mb4`) plus seed data. +- The **public section** built with Bootstrap 5 + jQuery: each tab keeps its + own slider position ("each tab is a separate slider"), the Column 3 image + stays in sync with Column 2, and the layout collapses to an accordion with + background-image slides on mobile. +- A small **admin CRUD UI** that exercises the full create/read/update/delete + cycle against the API, with inline validation feedback. +- A **one-command Docker setup** (`docker compose up --build`) so the whole + stack — including schema import — runs reproducibly without local config. + +**What I would add with more time** + +- **Authentication & authorisation** on the admin/write endpoints (session or + JWT), CSRF protection on the forms, and per-IP rate limiting. +- **Automated tests**: PHPUnit for the repository/validation logic and a + couple of Playwright/Cypress tests for the tab→slider→image sync and the + accordion behaviour. +- **Image upload & management** in the admin instead of typing a relative path, + with server-side type/size validation and responsive `srcset` output. +- **Pagination, sorting and search** on the slides list once the dataset grows, + and **caching** of the read-only `content` endpoint (HTTP cache headers / + Redis) since it is the hot path for the public page. +- **CI** (lint + tests on push) and **database migrations** instead of a single + `schema.sql`, so schema changes are versioned. +- A11y polish: full keyboard operation of the slider/accordion and ARIA live + announcements on slide change (partially in place already), plus autoplay + with pause-on-hover if the design calls for it. + +## 2. How would you track down a performance issue in production? Have you ever had to do this? + +Yes — my approach is to **measure before changing anything**, working from the +symptom inward: + +1. **Confirm and scope the symptom.** Is it latency, throughput, or errors? Is + it global or limited to one endpoint, region, tenant, or time window? I start + from dashboards/SLOs (p50/p95/p99 latency, error rate, saturation) rather + than anecdotes. +2. **Use the four golden signals + USE method.** Latency, traffic, errors, + saturation across the request path; then Utilisation/Saturation/Errors per + resource (CPU, memory, disk I/O, network, DB connections, thread pools). +3. **Follow a trace end-to-end.** With distributed tracing (OpenTelemetry / + APM) I find which span dominates — app code, an external API, or the + database — instead of guessing. +4. **Inspect the database** when it points there: slow-query log, `EXPLAIN` for + missing indexes or full scans, lock contention, N+1 query patterns, and + connection-pool exhaustion. In practice this is the most common culprit. +5. **Profile the hot path** (e.g. a sampling profiler / flame graph) to find CPU + or allocation hot spots, and check GC pauses where relevant. +6. **Check the edges:** cache hit ratios, payload sizes, chatty calls, and + front-end metrics (TTFB, LCP) so I don't fix the server when the problem is + an unoptimised asset or render-blocking request. +7. **Form one hypothesis, change one thing, re-measure**, and verify the fix + holds under representative load (load test in staging if the change is + risky). Finally I add an alert or dashboard so the same regression is caught + automatically next time. + +A representative example: a list endpoint that degraded under load turned out +to be an N+1 query that worked fine with seed data but issued hundreds of +queries per request in production. Tracing showed the time was in the DB, +the slow-query log confirmed the repeated query, and the fix was eager-loading +the related rows in a single query plus an index — p95 dropped by an order of +magnitude. + +## 3. Please describe yourself using JSON. + +```json +{ + "name": "", + "role": "Full Stack Developer", + "location": "", + "summary": "Pragmatic full-stack developer who cares about clean data models, readable code, and matching the design pixel-for-pixel.", + "experienceYears": "", + "skills": { + "backend": ["PHP", "MySQL", "REST APIs", "Node.js"], + "frontend": ["HTML5", "CSS3", "JavaScript", "jQuery", "Bootstrap", "responsive design"], + "tooling": ["Git", "Docker", "CI/CD", "Linux"] + }, + "principles": [ + "measure before optimising", + "validate input at the boundary", + "prefer simple, testable structure over clever code" + ], + "interests": ["", ""], + "availability": "", + "openToRemote": true, + "contact": { + "email": "", + "github": "" + } +} +``` diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2ff7858 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +# PHP + Apache image with the MySQL PDO driver enabled. +FROM php:8.2-apache + +RUN docker-php-ext-install pdo_mysql \ + && a2enmod rewrite + +# App lives at the web root. +COPY . /var/www/html/ + +EXPOSE 80 diff --git a/README.md b/README.md index 399d381..cf57f1b 100644 --- a/README.md +++ b/README.md @@ -44,4 +44,83 @@ Please answer the following questions in a markdown file called Answers to
  • How long did you spend on the coding test? What would you add to your solution if you had more time? If you didn't spend much time on the coding test then use this as an opportunity to explain what you would add.
  • How would you track down a performance issue in production? Have you ever had to do this?
  • Please describe yourself using JSON.
  • - \ No newline at end of file + + +--- + +# Solution + +A small full-stack module implementing the **DelphianLogic in Action** section +on top of a PHP + MySQL CRUD API. The data (tabs and their slides) is stored in +MySQL, served as JSON by a PHP REST API, and rendered with HTML5/CSS3, jQuery +and Bootstrap 5. + +Answers to the technical questions are in [`Answers to technical questions.md`](Answers%20to%20technical%20questions.md). + +## What it does + +- **Web view (≥ 992px)** — Column 1 is the list of tabs; clicking a tab swaps the + Column 2 slider. Each tab is its own slider and remembers its position. + Column 2's controls (dots + prev/next) change the slide, and the 1:1 image in + Column 3 stays in sync. +- **Mobile view (< 992px)** — Column 1 becomes an accordion; the open item + reveals a slider that uses the Column 3 image as its background. +- **Admin CRUD** at `/admin/` — create, edit and delete slides with validation. + +## Tech & structure + +``` +api/ PHP JSON REST API + config.php DB config (env-overridable) + Database.php PDO connection (prepared statements, exceptions) + SlideRepository.php Data-access layer for tabs & slides + index.php Router + input validation +admin/index.php CRUD admin UI +assets/ css / js / images +index.php Public page (the design section) +sql/schema.sql Schema + seed data +Dockerfile, docker-compose.yml +``` + +The API uses PDO with **real prepared statements** everywhere, validates input +server-side (returning `422` with per-field errors), and returns appropriate +HTTP status codes. Colours and typography follow `files/Moodboard.xlsx` +(Titillium Web headings, Open Sans body; navy `#11324d`, teal `#64b4c8`, +red `#c4351e`). + +## Run it + +### Option A — Docker (one command) + +```bash +docker compose up --build +``` + +Then open **http://localhost:8080** (public page) and +**http://localhost:8080/admin/** (CRUD). The schema and seed data import +automatically on first start. + +### Option B — XAMPP / MAMP + +1. Copy this folder into your web root (XAMPP/MAMP `htdocs/`). +2. Start Apache + MySQL from the XAMPP/MAMP control panel. +3. Import the database — via phpMyAdmin (import `sql/schema.sql`) or: + ```bash + mysql -u root -p < sql/schema.sql + ``` +4. If your MySQL credentials differ from the defaults (XAMPP `root` / empty, + MAMP `root` / `root`), set them in `api/config.php` or via the `DB_USER` / + `DB_PASS` environment variables. +5. Open `http://localhost/full-stack-test/` (adjust to your folder name). + +## API reference + +| Method | Endpoint | Purpose | +|--------|-----------------------------------|-------------------------------| +| GET | `api/?resource=content` | Tabs nested with their slides | +| GET | `api/?resource=tabs` | List tabs | +| GET | `api/?resource=slides` | List slides | +| GET | `api/?resource=slides&id={id}` | Single slide | +| POST | `api/?resource=slides` | Create slide (JSON body) | +| PUT | `api/?resource=slides&id={id}` | Update slide | +| DELETE | `api/?resource=slides&id={id}` | Delete slide | diff --git a/admin/index.php b/admin/index.php new file mode 100644 index 0000000..14cb8c3 --- /dev/null +++ b/admin/index.php @@ -0,0 +1,212 @@ + + + + + + + Admin · DelphianLogic Slides + + + + + + + +
    +
    + +
    +
    +
    +

    Slides

    + 0 +
    + +
    + +
    + + + + + + + + + + + + + + + +
    #ImageTabEyebrowTitleOrderActions
    +
    + +
    +
    + + + +
    + +
    +
    +
    + + + + + + + + + diff --git a/api/Database.php b/api/Database.php new file mode 100644 index 0000000..7ad4a55 --- /dev/null +++ b/api/Database.php @@ -0,0 +1,38 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ]); + + return self::$pdo; + } +} diff --git a/api/SlideRepository.php b/api/SlideRepository.php new file mode 100644 index 0000000..a67c0bd --- /dev/null +++ b/api/SlideRepository.php @@ -0,0 +1,195 @@ +db = $db ?? Database::connection(); + } + + /* ----------------------------- Tabs ------------------------------ */ + + /** @return array> */ + public function allTabs(): array + { + $stmt = $this->db->query( + 'SELECT id, name, icon, sort_order FROM tabs ORDER BY sort_order, id' + ); + return $stmt->fetchAll(); + } + + /** + * Full nested structure the public page needs in one round-trip: + * tabs, each with its ordered list of slides. + * + * @return array> + */ + public function tabsWithSlides(): array + { + $tabs = $this->allTabs(); + if ($tabs === []) { + return []; + } + + $slides = $this->db + ->query('SELECT * FROM slides ORDER BY tab_id, sort_order, id') + ->fetchAll(); + + $byTab = []; + foreach ($slides as $slide) { + $byTab[(int) $slide['tab_id']][] = $slide; + } + + foreach ($tabs as &$tab) { + $tab['slides'] = $byTab[(int) $tab['id']] ?? []; + } + unset($tab); + + return $tabs; + } + + /* ---------------------------- Slides ----------------------------- */ + + /** @return array> */ + public function allSlides(): array + { + $stmt = $this->db->query( + 'SELECT s.*, t.name AS tab_name + FROM slides s + JOIN tabs t ON t.id = s.tab_id + ORDER BY s.tab_id, s.sort_order, s.id' + ); + return $stmt->fetchAll(); + } + + /** + * One page of slides (server-side pagination — scales to large tables + * because only `limit` rows are ever materialised). + * + * @return array> + */ + public function pageOfSlides(int $limit, int $offset): array + { + $stmt = $this->db->prepare( + 'SELECT s.*, t.name AS tab_name + FROM slides s + JOIN tabs t ON t.id = s.tab_id + ORDER BY s.tab_id, s.sort_order, s.id + LIMIT :limit OFFSET :offset' + ); + // LIMIT/OFFSET must be bound as integers, not strings. + $stmt->bindValue(':limit', $limit, PDO::PARAM_INT); + $stmt->bindValue(':offset', $offset, PDO::PARAM_INT); + $stmt->execute(); + return $stmt->fetchAll(); + } + + public function countSlides(): int + { + return (int) $this->db->query('SELECT COUNT(*) FROM slides')->fetchColumn(); + } + + /** @return array|null */ + public function findSlide(int $id): ?array + { + $stmt = $this->db->prepare('SELECT * FROM slides WHERE id = :id'); + $stmt->execute([':id' => $id]); + $row = $stmt->fetch(); + return $row === false ? null : $row; + } + + /** + * @param array $data already-validated payload + * @return int new slide id + */ + public function createSlide(array $data): int + { + // When no explicit position is given, append to the end of the tab so + // new slides don't jump to the top of the list. + if ((int) ($data['sort_order'] ?? 0) <= 0) { + $data['sort_order'] = $this->nextSortOrder((int) $data['tab_id']); + } + + $stmt = $this->db->prepare( + 'INSERT INTO slides + (tab_id, eyebrow, title, description, link_text, link_url, image, sort_order) + VALUES + (:tab_id, :eyebrow, :title, :description, :link_text, :link_url, :image, :sort_order)' + ); + $stmt->execute($this->bindable($data)); + + return (int) $this->db->lastInsertId(); + } + + /** Next sort_order value for a tab (max + 1, starting at 1). */ + public function nextSortOrder(int $tabId): int + { + $stmt = $this->db->prepare( + 'SELECT COALESCE(MAX(sort_order), 0) + 1 FROM slides WHERE tab_id = :tab_id' + ); + $stmt->execute([':tab_id' => $tabId]); + return (int) $stmt->fetchColumn(); + } + + /** @param array $data already-validated payload */ + public function updateSlide(int $id, array $data): bool + { + $params = $this->bindable($data); + $params[':id'] = $id; + + $stmt = $this->db->prepare( + 'UPDATE slides SET + tab_id = :tab_id, + eyebrow = :eyebrow, + title = :title, + description = :description, + link_text = :link_text, + link_url = :link_url, + image = :image, + sort_order = :sort_order + WHERE id = :id' + ); + $stmt->execute($params); + + return $stmt->rowCount() >= 0; + } + + public function deleteSlide(int $id): bool + { + $stmt = $this->db->prepare('DELETE FROM slides WHERE id = :id'); + $stmt->execute([':id' => $id]); + return $stmt->rowCount() > 0; + } + + public function tabExists(int $id): bool + { + $stmt = $this->db->prepare('SELECT 1 FROM tabs WHERE id = :id'); + $stmt->execute([':id' => $id]); + return (bool) $stmt->fetchColumn(); + } + + /** @param array $data @return array */ + private function bindable(array $data): array + { + return [ + ':tab_id' => (int) $data['tab_id'], + ':eyebrow' => (string) ($data['eyebrow'] ?? ''), + ':title' => (string) $data['title'], + ':description' => (string) $data['description'], + ':link_text' => (string) ($data['link_text'] ?? 'Learn More'), + ':link_url' => (string) ($data['link_url'] ?? '#'), + ':image' => (string) ($data['image'] ?? ''), + ':sort_order' => (int) ($data['sort_order'] ?? 0), + ]; + } +} diff --git a/api/config.php b/api/config.php new file mode 100644 index 0000000..4c8c705 --- /dev/null +++ b/api/config.php @@ -0,0 +1,24 @@ + [ + 'host' => getenv('DB_HOST') ?: '127.0.0.1', + 'port' => getenv('DB_PORT') ?: '3306', + 'name' => getenv('DB_NAME') ?: 'delphianlogic', + 'user' => getenv('DB_USER') ?: 'root', + // XAMPP default root password is empty; MAMP default is 'root'. + 'pass' => getenv('DB_PASS') !== false ? getenv('DB_PASS') : '', + 'charset' => 'utf8mb4', + ], + + // When false, error responses omit internal details. Set DEBUG=1 locally. + 'debug' => (bool) (getenv('APP_DEBUG') ?: false), +]; diff --git a/api/index.php b/api/index.php new file mode 100644 index 0000000..78d44f6 --- /dev/null +++ b/api/index.php @@ -0,0 +1,217 @@ + tabs nested with their slides (public page) + * GET api/?resource=tabs -> list tabs + * GET api/?resource=slides -> list slides + * GET api/?resource=slides&id=1 -> single slide + * POST api/?resource=slides -> create slide (JSON or form body) + * PUT api/?resource=slides&id=1 -> update slide + * DELETE api/?resource=slides&id=1 -> delete slide + * + * POST may also carry _method=PUT|DELETE for clients that can't send verbs. + */ + +declare(strict_types=1); + +require_once __DIR__ . '/SlideRepository.php'; + +$config = require __DIR__ . '/config.php'; + +header('Content-Type: application/json; charset=utf-8'); +header('Access-Control-Allow-Origin: *'); +header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS'); +header('Access-Control-Allow-Headers: Content-Type'); + +if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { + http_response_code(204); + exit; +} + +/** Send a JSON response and stop. */ +function respond(int $status, $payload): void +{ + http_response_code($status); + echo json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + exit; +} + +function fail(int $status, string $message, array $errors = []): void +{ + $body = ['error' => $message]; + if ($errors !== []) { + $body['fields'] = $errors; + } + respond($status, $body); +} + +/** Decode the request body (JSON preferred, form-encoded fallback). */ +function requestBody(): array +{ + $raw = file_get_contents('php://input'); + if ($raw !== '' && $raw !== false) { + $decoded = json_decode($raw, true); + if (is_array($decoded)) { + return $decoded; + } + } + return $_POST; +} + +/** + * Validate a slide payload. Returns [cleanData, errors]. + * + * @return array{0: array, 1: array} + */ +function validateSlide(array $in, SlideRepository $repo): array +{ + $errors = []; + + $tabId = (int) ($in['tab_id'] ?? 0); + if ($tabId <= 0) { + $errors['tab_id'] = 'A tab is required.'; + } elseif (!$repo->tabExists($tabId)) { + $errors['tab_id'] = 'Selected tab does not exist.'; + } + + $title = trim((string) ($in['title'] ?? '')); + if ($title === '') { + $errors['title'] = 'Title is required.'; + } elseif (mb_strlen($title) > 255) { + $errors['title'] = 'Title must be 255 characters or fewer.'; + } + + $description = trim((string) ($in['description'] ?? '')); + if ($description === '') { + $errors['description'] = 'Description is required.'; + } + + $linkUrl = trim((string) ($in['link_url'] ?? '#')); + if ($linkUrl === '') { + $linkUrl = '#'; + } + + $clean = [ + 'tab_id' => $tabId, + 'eyebrow' => trim((string) ($in['eyebrow'] ?? '')), + 'title' => $title, + 'description' => $description, + 'link_text' => trim((string) ($in['link_text'] ?? 'Learn More')) ?: 'Learn More', + 'link_url' => $linkUrl, + 'image' => trim((string) ($in['image'] ?? '')), + 'sort_order' => (int) ($in['sort_order'] ?? 0), + ]; + + return [$clean, $errors]; +} + +try { + $repo = new SlideRepository(); + $resource = $_GET['resource'] ?? 'content'; + $id = isset($_GET['id']) ? (int) $_GET['id'] : null; + + // Allow method override for limited clients. + $method = $_SERVER['REQUEST_METHOD']; + if ($method === 'POST' && isset($_POST['_method'])) { + $method = strtoupper((string) $_POST['_method']); + } + + switch ($resource) { + case 'content': + if ($method !== 'GET') { + fail(405, 'Method not allowed.'); + } + respond(200, ['data' => $repo->tabsWithSlides()]); + break; + + case 'tabs': + if ($method !== 'GET') { + fail(405, 'Method not allowed.'); + } + respond(200, ['data' => $repo->allTabs()]); + break; + + case 'slides': + switch ($method) { + case 'GET': + if ($id !== null) { + $slide = $repo->findSlide($id); + $slide === null + ? fail(404, 'Slide not found.') + : respond(200, ['data' => $slide]); + } + // Paginated list. Defaults to page 1, 10 per page; + // per_page is capped to protect the server. + $page = max(1, (int) ($_GET['page'] ?? 1)); + $perPage = (int) ($_GET['per_page'] ?? 10); + $perPage = min(max($perPage, 1), 100); + + $total = $repo->countSlides(); + $totalPages = (int) max(1, ceil($total / $perPage)); + $page = min($page, $totalPages); + $offset = ($page - 1) * $perPage; + + respond(200, [ + 'data' => $repo->pageOfSlides($perPage, $offset), + 'meta' => [ + 'page' => $page, + 'per_page' => $perPage, + 'total' => $total, + 'total_pages' => $totalPages, + 'offset' => $offset, + ], + ]); + break; + + case 'POST': + [$clean, $errors] = validateSlide(requestBody(), $repo); + if ($errors !== []) { + fail(422, 'Validation failed.', $errors); + } + $newId = $repo->createSlide($clean); + respond(201, ['data' => $repo->findSlide($newId)]); + break; + + case 'PUT': + case 'PATCH': + if ($id === null) { + fail(400, 'Slide id is required.'); + } + if ($repo->findSlide($id) === null) { + fail(404, 'Slide not found.'); + } + [$clean, $errors] = validateSlide(requestBody(), $repo); + if ($errors !== []) { + fail(422, 'Validation failed.', $errors); + } + $repo->updateSlide($id, $clean); + respond(200, ['data' => $repo->findSlide($id)]); + break; + + case 'DELETE': + if ($id === null) { + fail(400, 'Slide id is required.'); + } + $repo->deleteSlide($id) + ? respond(200, ['data' => ['id' => $id, 'deleted' => true]]) + : fail(404, 'Slide not found.'); + break; + + default: + fail(405, 'Method not allowed.'); + } + break; + + default: + fail(404, 'Unknown resource.'); + } +} catch (Throwable $e) { + $message = $config['debug'] + ? $e->getMessage() + : 'An unexpected server error occurred.'; + fail(500, $message); +} diff --git a/assets/css/style.css b/assets/css/style.css new file mode 100644 index 0000000..90248b6 --- /dev/null +++ b/assets/css/style.css @@ -0,0 +1,356 @@ +/* ===================================================================== + DelphianLogic in Action — premium styling + Palette & type from files/Moodboard.xlsx styleguide. + ===================================================================== */ + +:root { + --brand-primary: #c4351e; /* red accent */ + --brand-secondary: #11324d; /* navy */ + --brand-fourth: #64b4c8; /* teal */ + --teal-deep: #3c7d92; + --gold: #f0b91e; /* brand-third — premium micro-accent */ + --gray: #696969; + --gray-light: #adadad; + --gray-white: #f6f6f6; + --white: #ffffff; + + --font-heading: 'Titillium Web', sans-serif; + --font-body: 'Open Sans', sans-serif; + --ease: cubic-bezier(.22,.61,.36,1); +} + +* { box-sizing: border-box; } + +body { + font-family: var(--font-body); + color: var(--white); + background: + radial-gradient(1100px 520px at 8% -12%, rgba(100,180,200,.18), transparent 58%), + radial-gradient(900px 560px at 104% 118%, rgba(240,185,30,.08), transparent 54%), + radial-gradient(700px 700px at 50% 50%, rgba(196,53,30,.05), transparent 60%), + linear-gradient(180deg, #0c2a40 0%, var(--brand-secondary) 58%, #0b2236 100%); + background-attachment: fixed; +} + +.dl-section { padding: 92px 0 100px; } + +/* ---------------------------------------------- header */ +.dl-header { margin-bottom: 52px; } + +.dl-kicker { + display: inline-flex; + align-items: center; + gap: 14px; + font-family: var(--font-heading); + font-weight: 700; + font-size: 12px; + letter-spacing: 3px; + text-transform: uppercase; + color: var(--gold); + margin-bottom: 18px; +} +.dl-kicker::before, .dl-kicker::after { + content: ""; + width: 28px; height: 1px; + background: linear-gradient(90deg, transparent, rgba(240,185,30,.7), transparent); +} + +.dl-title { + font-family: var(--font-heading); + font-weight: 600; + font-size: 44px; + line-height: 1.1; + letter-spacing: .2px; + margin: 0 0 18px; +} + +.dl-subtitle { + color: rgba(255,255,255,.62); + font-size: 16px; + line-height: 1.7; + margin: 0 auto; + max-width: 540px; +} + +/* ---------------------------------------------- status / errors */ +.dl-status { + display: flex; align-items: center; justify-content: center; + min-height: 120px; color: var(--gray-light); +} +.dl-status.dl-error { color: #ffb4a8; } + +.dl-web.is-ready, .dl-mobile.is-ready { animation: dl-fade-up .6s var(--ease) both; } +@keyframes dl-fade-up { from { opacity: 0; transform: translateY(16px); } to { opacity: 1; transform: none; } } + +/* ===================================================================== + WEB VIEW — 3 columns inside a glass card + ===================================================================== */ +.dl-grid { + display: grid; + grid-template-columns: 2.6fr 3.4fr 3fr; + min-height: 300px; + border-radius: 18px; + overflow: hidden; + border: 1px solid rgba(255,255,255,.10); + background: rgba(255,255,255,.03); + box-shadow: + 0 40px 80px -24px rgba(0,0,0,.55), + 0 2px 0 0 rgba(255,255,255,.05) inset; + backdrop-filter: blur(6px); +} + +/* ---- Column 1: tabs ---- */ +.dl-tabs { + display: flex; + flex-direction: column; + background: linear-gradient(180deg, #ffffff, #f4f6f8); +} + +.dl-tab { + position: relative; + display: flex; + align-items: center; + gap: 16px; + padding: 26px 26px; + border: 0; + background: transparent; + color: #1d2b3a; + font-family: var(--font-heading); + font-weight: 600; + font-size: 16.5px; + letter-spacing: .2px; + text-align: left; + cursor: pointer; + transition: background .3s var(--ease), padding-left .3s var(--ease), color .3s var(--ease); +} +/* hairline separators */ +.dl-tab + .dl-tab::after { + content: ""; position: absolute; left: 26px; right: 26px; top: 0; + height: 1px; background: rgba(17,50,77,.07); +} +.dl-tab:hover { background: rgba(100,180,200,.08); padding-left: 30px; } + +.dl-tab-icon { + width: 40px; height: 40px; padding: 7px; + object-fit: contain; border-radius: 11px; + background: rgba(17,50,77,.05); + box-shadow: inset 0 0 0 1px rgba(17,50,77,.04); + transition: background .3s var(--ease), transform .3s var(--ease), box-shadow .3s var(--ease); +} +.dl-tab:hover .dl-tab-icon { transform: scale(1.05); } + +/* active accent bar */ +.dl-tab::before { + content: ""; position: absolute; left: 0; top: 18px; bottom: 18px; width: 3px; + border-radius: 0 3px 3px 0; + background: linear-gradient(180deg, var(--brand-primary), #e1532f); + transform: scaleY(0); transform-origin: center; + transition: transform .32s var(--ease); +} +.dl-tab.is-active::before { transform: scaleY(1); } + +.dl-tab.is-active { + background: #fff; + padding-left: 30px; +} +.dl-tab.is-active .dl-tab-icon { + background: rgba(196,53,30,.10); + box-shadow: inset 0 0 0 1px rgba(196,53,30,.12); +} + +/* teal pointer into column 2 */ +.dl-tab.is-active::after { + content: ""; position: absolute; top: 50%; right: -10px; + transform: translateY(-50%); + border-top: 10px solid transparent; border-bottom: 10px solid transparent; + border-left: 10px solid var(--brand-fourth); + z-index: 3; +} + +/* ---- Column 2: slider ---- */ +.dl-slider { + position: relative; + background: + radial-gradient(120% 140% at 0% 0%, rgba(255,255,255,.14), transparent 45%), + linear-gradient(150deg, var(--brand-fourth) 0%, var(--teal-deep) 120%); + color: var(--white); + padding: 44px 42px 34px; + display: flex; flex-direction: column; justify-content: center; + overflow: hidden; +} + +.dl-slide-body { + flex: 1 1 auto; display: flex; flex-direction: column; justify-content: center; + transition: opacity .35s var(--ease), transform .35s var(--ease); +} +.dl-slide-body.is-animating { opacity: 0; transform: translateY(12px); } + +.dl-eyebrow { + display: inline-flex; align-items: center; gap: 10px; + align-self: flex-start; + color: rgba(255,255,255,.92); + font-size: 11px; font-weight: 700; letter-spacing: 2.2px; text-transform: uppercase; + margin-bottom: 18px; + background: none; padding: 0; +} +.dl-eyebrow::before { + content: ""; width: 26px; height: 2px; background: var(--gold); border-radius: 2px; +} + +.dl-slide-title { + font-family: var(--font-heading); + font-weight: 600; + font-size: 27px; + line-height: 1.28; + margin: 0 0 24px; + letter-spacing: .2px; + text-shadow: 0 2px 14px rgba(17,50,77,.18); +} + +.dl-learn-more { + display: inline-flex; align-items: center; gap: 14px; + align-self: flex-start; + color: #fff; font-weight: 600; font-size: 13px; letter-spacing: 1.4px; text-transform: uppercase; + text-decoration: none; +} +.dl-learn-more img { + width: 16px; height: 16px; + padding: 11px; box-sizing: content-box; + border: 1.5px solid rgba(255,255,255,.5); + border-radius: 50%; + transition: background .3s var(--ease), border-color .3s var(--ease), transform .3s var(--ease); +} +.dl-learn-more:hover { color: #fff; } +.dl-learn-more:hover img { + background: rgba(255,255,255,.16); + border-color: rgba(255,255,255,.9); + transform: translateX(3px); +} + +/* controls */ +.dl-controls { display: flex; align-items: center; gap: 16px; margin-top: 26px; } + +.dl-arrow { + width: 34px; height: 34px; border: 0; border-radius: 50%; + background: rgba(255,255,255,.18); + color: #fff; font-size: 18px; line-height: 1; cursor: pointer; + backdrop-filter: blur(2px); + transition: background .25s var(--ease), transform .25s var(--ease); +} +.dl-arrow:hover { background: rgba(255,255,255,.36); transform: scale(1.08); } +.dl-arrow:active { transform: scale(.95); } + +.dl-dots { display: flex; align-items: center; gap: 8px; } +.dl-dot { + width: 8px; height: 8px; border: 0; border-radius: 999px; + background: rgba(255,255,255,.4); cursor: pointer; padding: 0; + transition: background .3s var(--ease), width .3s var(--ease); +} +.dl-dot.is-active { background: var(--gold); width: 26px; } + +.dl-counter { + margin-left: auto; + font-family: var(--font-heading); + font-size: 13px; font-weight: 600; letter-spacing: 2px; + color: rgba(255,255,255,.9); +} +.dl-counter .dl-counter-total { color: rgba(255,255,255,.5); } + +/* autoplay progress */ +.dl-progress { position: absolute; left: 0; right: 0; bottom: 0; height: 3px; background: rgba(255,255,255,.16); } +.dl-progress-bar { display: block; height: 100%; width: 0; background: linear-gradient(90deg, var(--gold), #ffd76a); } +.dl-progress-bar.is-running { transition: width linear; } + +/* ---- Column 3: 1:1 image ---- */ +.dl-figure { position: relative; background: #0d2236; overflow: hidden; } +.dl-figure::after { + content: ""; position: absolute; inset: 0; z-index: 2; pointer-events: none; + background: + linear-gradient(180deg, rgba(13,34,54,.05), rgba(13,34,54,.5)), + linear-gradient(90deg, rgba(60,125,146,.22), transparent 30%); + box-shadow: inset 0 0 60px rgba(0,0,0,.3); +} +.dl-figure-img { + position: absolute; inset: 0; width: 100%; height: 100%; + object-fit: cover; display: block; opacity: 0; + transition: opacity .6s var(--ease); +} +.dl-figure-img.is-current { opacity: 1; animation: dl-kenburns 10s ease-out both; } +@keyframes dl-kenburns { from { transform: scale(1.001); } to { transform: scale(1.08); } } + +/* ===================================================================== + MOBILE VIEW — accordion + ===================================================================== */ +.dl-accordion { display: flex; flex-direction: column; gap: 16px; } +.dl-acc-item { border-radius: 12px; } + +.dl-acc-head { + display: flex; align-items: center; gap: 16px; width: 100%; border: 0; + background: linear-gradient(180deg,#ffffff,#f5f7f9); + color: #1d2b3a; + padding: 20px 22px; border-radius: 12px; + box-shadow: 0 10px 24px -8px rgba(0,0,0,.35); + font-family: var(--font-heading); font-weight: 600; font-size: 17px; + text-align: left; cursor: pointer; + transition: box-shadow .25s var(--ease), transform .25s var(--ease); +} +.dl-acc-head:hover { transform: translateY(-1px); } +.dl-acc-icon { + width: 42px; height: 42px; object-fit: contain; padding: 7px; + border-radius: 11px; background: rgba(17,50,77,.05); + box-shadow: inset 0 0 0 1px rgba(17,50,77,.04); +} +.dl-acc-item.is-open .dl-acc-icon { background: rgba(196,53,30,.10); } +.dl-acc-name { flex: 1 1 auto; } +.dl-acc-toggle { width: 28px; height: 28px; flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; } +.dl-acc-toggle img { width: 24px; height: 24px; } + +.dl-acc-panel { + position: relative; margin-top: 14px; border-radius: 14px; overflow: hidden; + background: var(--brand-fourth); background-size: cover; background-position: center; + color: #fff; display: none; + box-shadow: 0 18px 40px -12px rgba(0,0,0,.5); +} +.dl-acc-item.is-open .dl-acc-panel { display: block; animation: dl-acc-open .4s var(--ease) both; } +@keyframes dl-acc-open { from { opacity: 0; transform: translateY(-8px); } to { opacity: 1; transform: none; } } + +.dl-acc-panel::before { + content: ""; position: absolute; inset: 0; + background: linear-gradient(180deg, rgba(50,120,142,.74), rgba(28,82,100,.88)); +} +.dl-acc-panel > * { position: relative; z-index: 1; } + +.dl-acc-item.is-open .dl-acc-head { position: relative; } +.dl-acc-item.is-open .dl-acc-head::after { + content: ""; position: absolute; left: 40px; bottom: -13px; + border-left: 11px solid transparent; border-right: 11px solid transparent; + border-bottom: 12px solid #ffffff; +} + +.dl-acc-body { padding: 34px 26px 26px; min-height: 250px; display: flex; flex-direction: column; } +.dl-acc-body .dl-slide-body { flex: 1 1 auto; justify-content: center; align-items: center; text-align: center; } +.dl-acc-body .dl-eyebrow { align-self: center; } +.dl-acc-body .dl-slide-title { font-size: 25px; } +.dl-acc-body .dl-learn-more { align-self: center; } +.dl-acc-body .dl-controls { justify-content: center; margin-top: 26px; } +.dl-acc-body .dl-counter { display: none; } + +/* ===================================================================== + Responsive + ===================================================================== */ +@media (max-width: 991.98px) { + .dl-web { display: none !important; } + .dl-mobile.is-ready { display: block !important; } + .dl-title { font-size: 32px; } + .dl-section { padding: 64px 0 72px; } +} +@media (min-width: 992px) { + .dl-web.is-ready { display: block !important; } + .dl-mobile { display: none !important; } +} + +@media (prefers-reduced-motion: reduce) { + .dl-figure-img.is-current { animation: none; } + .dl-web.is-ready, .dl-mobile.is-ready, .dl-acc-item.is-open .dl-acc-panel { animation: none; } + * { transition-duration: .001ms !important; } +} diff --git a/assets/images/DL-Communication.jpg b/assets/images/DL-Communication.jpg new file mode 100644 index 0000000..51b3b5b Binary files /dev/null and b/assets/images/DL-Communication.jpg differ diff --git a/assets/images/DL-Learning-1.jpg b/assets/images/DL-Learning-1.jpg new file mode 100644 index 0000000..755171f Binary files /dev/null and b/assets/images/DL-Learning-1.jpg differ diff --git a/assets/images/DL-Technology.jpg b/assets/images/DL-Technology.jpg new file mode 100644 index 0000000..a8d4354 Binary files /dev/null and b/assets/images/DL-Technology.jpg differ diff --git a/assets/images/DL-communication.svg b/assets/images/DL-communication.svg new file mode 100644 index 0000000..0088544 --- /dev/null +++ b/assets/images/DL-communication.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/images/DL-learning.svg b/assets/images/DL-learning.svg new file mode 100644 index 0000000..bfeb0b5 --- /dev/null +++ b/assets/images/DL-learning.svg @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/images/DL-technology.svg b/assets/images/DL-technology.svg new file mode 100644 index 0000000..59f2ea5 --- /dev/null +++ b/assets/images/DL-technology.svg @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/images/arrow-right.svg b/assets/images/arrow-right.svg new file mode 100644 index 0000000..37d307b --- /dev/null +++ b/assets/images/arrow-right.svg @@ -0,0 +1,7 @@ + + + + + + diff --git a/assets/images/minus-01.svg b/assets/images/minus-01.svg new file mode 100644 index 0000000..a0639ca --- /dev/null +++ b/assets/images/minus-01.svg @@ -0,0 +1,9 @@ + + + + + + + diff --git a/assets/images/plus-01.svg b/assets/images/plus-01.svg new file mode 100644 index 0000000..d80a766 --- /dev/null +++ b/assets/images/plus-01.svg @@ -0,0 +1,9 @@ + + + + + + + diff --git a/assets/js/admin.js b/assets/js/admin.js new file mode 100644 index 0000000..3eba566 --- /dev/null +++ b/assets/js/admin.js @@ -0,0 +1,254 @@ +/* ===================================================================== + Slide admin — CRUD against ../api/index.php + ===================================================================== */ +(function ($) { + 'use strict'; + + var API = '../api/index.php'; + var modal, $form, tabsCache = []; + + // pagination state + var page = 1; + var perPage = 10; + var lastMeta = { page: 1, per_page: 10, total: 0, total_pages: 1, offset: 0 }; + + function esc(s) { + return $('
    ').text(s == null ? '' : String(s)).html(); + } + + // Colored pill per tab (consistent colour from the brand palette). + var TAB_COLORS = [ + { bg: '#e6f2f6', fg: '#2b6b80' }, // teal + { bg: '#ece9f4', fg: '#504682' }, // purple + { bg: '#fbe9e6', fg: '#c4351e' }, // red + { bg: '#fdf3e0', fg: '#9a7212' } // amber + ]; + var TAB_COLOR_MAP = { + 'Learning': TAB_COLORS[0], + 'Technology': TAB_COLORS[1], + 'Communication': TAB_COLORS[2] + }; + function tabColor(name) { + if (TAB_COLOR_MAP[name]) { return TAB_COLOR_MAP[name]; } + var sum = 0; + String(name || '').split('').forEach(function (c) { sum += c.charCodeAt(0); }); + return TAB_COLORS[sum % TAB_COLORS.length]; + } + function tabBadge(name) { + var c = tabColor(name); + return '' + + esc(name) + ''; + } + + function alertBox(type, msg) { + $('#alert-area').html( + '' + ); + } + + // ---- load + render list --------------------------------------------- + function loadTabs() { + return $.getJSON(API + '?resource=tabs').done(function (resp) { + tabsCache = (resp && resp.data) || []; + var $sel = $('select[name="tab_id"]').empty(); + tabsCache.forEach(function (t) { + $sel.append($('
    + + + + +
    + + +
    + + + + +
    +
    +
    + + + + + + + + + + + diff --git a/sql/schema.sql b/sql/schema.sql new file mode 100644 index 0000000..bcb89c2 --- /dev/null +++ b/sql/schema.sql @@ -0,0 +1,108 @@ +-- ===================================================================== +-- DelphianLogic in Action — database schema + seed data +-- Engine: MySQL 5.7+/8.0 (InnoDB, utf8mb4) +-- ===================================================================== + +CREATE DATABASE IF NOT EXISTS `delphianlogic` + CHARACTER SET utf8mb4 + COLLATE utf8mb4_unicode_ci; + +USE `delphianlogic`; + +-- --------------------------------------------------------------------- +-- tabs : Column 1 entries (Learning / Technology / Communication). +-- Each tab owns its own slider in Column 2. +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `tabs` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `name` VARCHAR(120) NOT NULL, + `icon` VARCHAR(255) NOT NULL DEFAULT '', -- relative path to svg icon + `sort_order` INT NOT NULL DEFAULT 0, + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_tabs_sort` (`sort_order`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- --------------------------------------------------------------------- +-- slides : Column 2 content. Each slide belongs to one tab and carries +-- the synced 1:1 image rendered in Column 3. +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `slides` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `tab_id` INT UNSIGNED NOT NULL, + `eyebrow` VARCHAR(180) NOT NULL DEFAULT '', -- small uppercase label + `title` VARCHAR(255) NOT NULL, + `description` TEXT NOT NULL, + `link_text` VARCHAR(120) NOT NULL DEFAULT 'Learn More', + `link_url` VARCHAR(512) NOT NULL DEFAULT '#', + `image` VARCHAR(512) NOT NULL DEFAULT '', -- Column 3 image (1:1) + `sort_order` INT NOT NULL DEFAULT 0, + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_slides_tab` (`tab_id`, `sort_order`), + CONSTRAINT `fk_slides_tab` + FOREIGN KEY (`tab_id`) REFERENCES `tabs` (`id`) + ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ===================================================================== +-- Seed data (idempotent: clears then repopulates) +-- ===================================================================== +SET FOREIGN_KEY_CHECKS = 0; +TRUNCATE TABLE `slides`; +TRUNCATE TABLE `tabs`; +SET FOREIGN_KEY_CHECKS = 1; + +INSERT INTO `tabs` (`id`, `name`, `icon`, `sort_order`) VALUES + (1, 'Learning', 'assets/images/DL-learning.svg', 1), + (2, 'Technology', 'assets/images/DL-technology.svg', 2), + (3, 'Communication', 'assets/images/DL-communication.svg', 3); + +INSERT INTO `slides` + (`tab_id`, `eyebrow`, `title`, `description`, `link_text`, `link_url`, `image`, `sort_order`) +VALUES + -- Learning + (1, 'Digital Learning Infrastructure', + 'Usability enhancement and Training for Transaction Portal for Customers', + 'Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor.', + 'Learn More', '#', 'assets/images/DL-Learning-1.jpg', 1), + (1, 'Digital Learning Infrastructure', + 'Blended Learning Programs that Scale Across Teams', + 'Curabitur ullamcorper ultricies nisi. Nam eget dui. Etiam rhoncus maecenas tempus.', + 'Learn More', '#', 'assets/images/DL-Learning-1.jpg', 2), + (1, 'Digital Learning Infrastructure', + 'Measurable Outcomes with Continuous Assessment', + 'Donec quam felis, ultricies nec, pellentesque eu, pretium quis, sem nulla consequat.', + 'Learn More', '#', 'assets/images/DL-Learning-1.jpg', 3), + + -- Technology + (2, 'Enterprise Technology', + 'Cloud-native Platforms Built for Scale and Resilience', + 'Aenean massa. Cum sociis natoque penatibus et magnis dis parturient montes nascetur.', + 'Learn More', '#', 'assets/images/DL-Technology.jpg', 1), + (2, 'Enterprise Technology', + 'Secure Integrations Across Your Digital Ecosystem', + 'Nulla consequat massa quis enim. Donec pede justo, fringilla vel, aliquet nec vulputate.', + 'Learn More', '#', 'assets/images/DL-Technology.jpg', 2), + (2, 'Enterprise Technology', + 'Automation that Reduces Operational Overhead', + 'Vivamus elementum semper nisi. Aenean vulputate eleifend tellus aenean leo ligula.', + 'Learn More', '#', 'assets/images/DL-Technology.jpg', 3), + + -- Communication + (3, 'Connected Communication', + 'Unified Messaging for Distributed Customer Teams', + 'Phasellus viverra nulla ut metus varius laoreet. Quisque rutrum aenean imperdiet.', + 'Learn More', '#', 'assets/images/DL-Communication.jpg', 1), + (3, 'Connected Communication', + 'Real-time Collaboration Without the Friction', + 'Etiam ultricies nisi vel augue. Curabitur ullamcorper ultricies nisi nam eget dui.', + 'Learn More', '#', 'assets/images/DL-Communication.jpg', 2), + (3, 'Connected Communication', + 'Engagement Insights that Drive Better Decisions', + 'Maecenas tempus, tellus eget condimentum rhoncus, sem quam semper libero sit amet.', + 'Learn More', '#', 'assets/images/DL-Communication.jpg', 3);