Merge branch 'feature/media-set-cards'
This commit is contained in:
commit
070722e013
55 changed files with 508 additions and 122 deletions
|
|
@ -23,3 +23,9 @@ Before responding to the first user message in a session, you MUST:
|
|||
Skipping this protocol caused real bugs and rework in past sessions
|
||||
(work landed on master, TDD order was lost, formatter not run, banned
|
||||
constructs slipped in). Treat the protocol as non-negotiable.
|
||||
|
||||
## Command execution
|
||||
|
||||
When running commands that need the Nix devshell, set `login: false`.
|
||||
The login shell resets `PATH` and can hide tools like `phpcs`, Cypress,
|
||||
and the devshell Node version.
|
||||
|
|
|
|||
|
|
@ -12,5 +12,6 @@ class CreateSessionDto
|
|||
public User $user,
|
||||
public DateTimeImmutable $createdAt,
|
||||
public DateTimeImmutable $expiresAt,
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,9 @@ use DateTimeZone;
|
|||
|
||||
class EloquentSessionRepository implements SessionRepository
|
||||
{
|
||||
public function __construct(private UserRepository $userRepo) {}
|
||||
public function __construct(private UserRepository $userRepo)
|
||||
{
|
||||
}
|
||||
|
||||
public function create(CreateSessionDto $dto): Session
|
||||
{
|
||||
|
|
|
|||
|
|
@ -12,7 +12,8 @@ class Session
|
|||
private User $user,
|
||||
private DateTimeImmutable $createdAt,
|
||||
private DateTimeImmutable $expiresAt,
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
|
||||
public function getToken(): string
|
||||
{
|
||||
|
|
|
|||
|
|
@ -14,7 +14,8 @@ class AuthenticateUser
|
|||
public function __construct(
|
||||
private UserRepository $userRepo,
|
||||
private PasswordHasher $hasher,
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws BadRequestException
|
||||
|
|
|
|||
|
|
@ -7,5 +7,6 @@ class AuthenticateUserRequest
|
|||
public function __construct(
|
||||
public ?string $email,
|
||||
public ?string $password,
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,8 @@ class CreateSession
|
|||
private SessionRepository $sessionRepo,
|
||||
private TokenGenerator $tokenGenerator,
|
||||
private Clock $clock,
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
|
||||
public function execute(User $user): Session
|
||||
{
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@ class Logout
|
|||
{
|
||||
public function __construct(
|
||||
private SessionRepository $sessionRepo,
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
|
||||
public function execute(string $token): void
|
||||
{
|
||||
|
|
|
|||
|
|
@ -20,7 +20,8 @@ class AuthController
|
|||
private AuthenticateUser $authenticateUser,
|
||||
private CreateSession $createSession,
|
||||
private Logout $logout,
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
|
||||
public function login(Request $request): JsonResponse
|
||||
{
|
||||
|
|
@ -33,11 +34,13 @@ class AuthController
|
|||
);
|
||||
} catch (BadRequestException $exception) {
|
||||
return new JsonResponse(
|
||||
['error' => $exception->getMessage()], 400
|
||||
['error' => $exception->getMessage()],
|
||||
400
|
||||
);
|
||||
} catch (UnauthorizedException $exception) {
|
||||
return new JsonResponse(
|
||||
['error' => $exception->getMessage()], 401
|
||||
['error' => $exception->getMessage()],
|
||||
401
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -71,7 +74,7 @@ class AuthController
|
|||
}
|
||||
|
||||
/**
|
||||
* @return array{id: int, email: string, firstname: string, lastname: string}
|
||||
* @return array{id: int, email: string}
|
||||
*/
|
||||
private function buildUserPayload(User $user): array
|
||||
{
|
||||
|
|
|
|||
44
backend/app/Controllers/SetController.php
Normal file
44
backend/app/Controllers/SetController.php
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
<?php
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Set\Set as DomainSet;
|
||||
use App\Set\SetRepository;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class SetController
|
||||
{
|
||||
public function __construct(private SetRepository $setRepository)
|
||||
{
|
||||
}
|
||||
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
$sets = [];
|
||||
foreach ($this->setRepository->getAll() as $set) {
|
||||
$sets[] = $this->buildSetPayload($set);
|
||||
}
|
||||
|
||||
return new JsonResponse([
|
||||
'sets' => $sets,
|
||||
], 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* id: int,
|
||||
* name: string,
|
||||
* description: string,
|
||||
* iconImageUrl: string
|
||||
* }
|
||||
*/
|
||||
private function buildSetPayload(DomainSet $set): array
|
||||
{
|
||||
return [
|
||||
'id' => $set->getId(),
|
||||
'name' => $set->getName(),
|
||||
'description' => $set->getDescription(),
|
||||
'iconImageUrl' => $set->getIconImageUrl(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -10,5 +10,6 @@ class CreateElementDto
|
|||
public Set $set,
|
||||
public string $title,
|
||||
public ?Element $parentElement,
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,8 @@ class Element
|
|||
private string $title,
|
||||
private Set $set,
|
||||
private ?Element $parentElement,
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
|
||||
public function getId(): int
|
||||
{
|
||||
|
|
|
|||
|
|
@ -8,7 +8,9 @@ use DomainException;
|
|||
|
||||
class EloquentElementRepository implements ElementRepository
|
||||
{
|
||||
public function __construct(private SetRepository $setRepo) {}
|
||||
public function __construct(private SetRepository $setRepo)
|
||||
{
|
||||
}
|
||||
|
||||
public function create(CreateElementDto $dto): Element
|
||||
{
|
||||
|
|
|
|||
|
|
@ -15,7 +15,8 @@ class CreateElement
|
|||
public function __construct(
|
||||
private ElementRepository $elementRepo,
|
||||
private SetRepository $setRepo,
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws BadRequestException
|
||||
|
|
|
|||
|
|
@ -8,5 +8,6 @@ class CreateElementRequest
|
|||
public ?int $setId,
|
||||
public ?string $title,
|
||||
public ?int $parentElementId,
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,4 +4,6 @@ namespace App\Exceptions;
|
|||
|
||||
use DomainException;
|
||||
|
||||
class BadRequestException extends DomainException {}
|
||||
class BadRequestException extends DomainException
|
||||
{
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,4 +4,6 @@ namespace App\Exceptions;
|
|||
|
||||
use DomainException;
|
||||
|
||||
class UnauthorizedException extends DomainException {}
|
||||
class UnauthorizedException extends DomainException
|
||||
{
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,8 @@ class AuthMiddleware
|
|||
public function __construct(
|
||||
private SessionRepository $sessionRepo,
|
||||
private Clock $clock,
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Closure(Request): Response $next
|
||||
|
|
|
|||
|
|
@ -6,5 +6,8 @@ class CreateSetDto
|
|||
{
|
||||
public function __construct(
|
||||
public string $name,
|
||||
) {}
|
||||
public string $description,
|
||||
public string $iconImageUrl,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ class EloquentSetRepository implements SetRepository
|
|||
{
|
||||
$model = SetModel::create([
|
||||
'name' => $dto->name,
|
||||
'description' => $dto->description,
|
||||
'icon_image_url' => $dto->iconImageUrl,
|
||||
]);
|
||||
|
||||
return $this->toDomain($model);
|
||||
|
|
@ -36,6 +38,8 @@ class EloquentSetRepository implements SetRepository
|
|||
return new Set(
|
||||
id: $model->id,
|
||||
name: $model->name,
|
||||
description: $model->description,
|
||||
iconImageUrl: $model->icon_image_url,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,10 @@ class Set
|
|||
public function __construct(
|
||||
private int $id,
|
||||
private string $name,
|
||||
) {}
|
||||
private string $description,
|
||||
private string $iconImageUrl,
|
||||
) {
|
||||
}
|
||||
|
||||
public function getId(): int
|
||||
{
|
||||
|
|
@ -18,4 +21,14 @@ class Set
|
|||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
public function getDescription(): string
|
||||
{
|
||||
return $this->description;
|
||||
}
|
||||
|
||||
public function getIconImageUrl(): string
|
||||
{
|
||||
return $this->iconImageUrl;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ use Illuminate\Database\Eloquent\Model;
|
|||
/**
|
||||
* @property int $id
|
||||
* @property string $name
|
||||
* @property string $description
|
||||
* @property string $icon_image_url
|
||||
*
|
||||
* @method static Builder<static>|SetModel newModelQuery()
|
||||
* @method static Builder<static>|SetModel newQuery()
|
||||
|
|
@ -23,5 +25,5 @@ class SetModel extends Model
|
|||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = ['name'];
|
||||
protected $fillable = ['name', 'description', 'icon_image_url'];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,15 +18,15 @@ final readonly class EmailAddress
|
|||
$trimmed = trim($email);
|
||||
|
||||
if ($trimmed === '' || ! str_contains($trimmed, '@')) {
|
||||
throw new InvalidArgumentException(self::ERROR_MESSAGE." $email");
|
||||
throw new InvalidArgumentException(self::ERROR_MESSAGE . " $email");
|
||||
}
|
||||
|
||||
[$local, $domain] = explode('@', $trimmed, 2);
|
||||
$this->domain = mb_strtolower($domain);
|
||||
$normalized = $local.'@'.$this->domain;
|
||||
$normalized = $local . '@' . $this->domain;
|
||||
|
||||
if (filter_var($normalized, FILTER_VALIDATE_EMAIL) === false) {
|
||||
throw new InvalidArgumentException(self::ERROR_MESSAGE." $email");
|
||||
throw new InvalidArgumentException(self::ERROR_MESSAGE . " $email");
|
||||
}
|
||||
|
||||
$this->normalized = $normalized;
|
||||
|
|
|
|||
|
|
@ -9,5 +9,6 @@ class CreateUserDto
|
|||
public function __construct(
|
||||
public EmailAddress $email,
|
||||
public string $passwordHash,
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ class EloquentUserRepository implements UserRepository
|
|||
|
||||
public function findByEmailDomain(string $domain): array
|
||||
{
|
||||
$models = UserModel::where('email', 'like', '%@'.$domain)->get();
|
||||
$models = UserModel::where('email', 'like', '%@' . $domain)->get();
|
||||
$users = [];
|
||||
foreach ($models as $model) {
|
||||
$users[] = $this->toDomain($model);
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@ class User
|
|||
private int $id,
|
||||
private EmailAddress $email,
|
||||
private string $passwordHash,
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
|
||||
public function getId(): int
|
||||
{
|
||||
|
|
|
|||
|
|
@ -9,12 +9,6 @@ use Illuminate\Database\Eloquent\Model;
|
|||
* @property string $email
|
||||
* @property string $password_hash
|
||||
*
|
||||
* @method static \Illuminate\Database\Eloquent\Builder<static>|UserModel newModelQuery()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder<static>|UserModel newQuery()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder<static>|UserModel query()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder<static>|UserModel whereEmail($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder<static>|UserModel whereId($value)
|
||||
*
|
||||
* @mixin \Eloquent
|
||||
*/
|
||||
class UserModel extends Model
|
||||
|
|
|
|||
|
|
@ -95,7 +95,10 @@ return [
|
|||
'passwords' => [
|
||||
'users' => [
|
||||
'provider' => 'users',
|
||||
'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
|
||||
'table' => env(
|
||||
'AUTH_PASSWORD_RESET_TOKEN_TABLE',
|
||||
'password_reset_tokens'
|
||||
),
|
||||
'expire' => 60,
|
||||
'throttle' => 60,
|
||||
],
|
||||
|
|
|
|||
|
|
@ -112,7 +112,10 @@ return [
|
|||
|
|
||||
*/
|
||||
|
||||
'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'),
|
||||
'prefix' => env(
|
||||
'CACHE_PREFIX',
|
||||
Str::slug((string) env('APP_NAME', 'laravel')) . '-cache-'
|
||||
),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -111,7 +111,10 @@ return [
|
|||
'prefix' => '',
|
||||
'prefix_indexes' => true,
|
||||
// 'encrypt' => env('DB_ENCRYPT', 'yes'),
|
||||
// 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'),
|
||||
// 'trust_server_certificate' => env(
|
||||
// 'DB_TRUST_SERVER_CERTIFICATE',
|
||||
// 'false'
|
||||
// ),
|
||||
],
|
||||
|
||||
],
|
||||
|
|
@ -149,7 +152,11 @@ return [
|
|||
|
||||
'options' => [
|
||||
'cluster' => env('REDIS_CLUSTER', 'redis'),
|
||||
'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'),
|
||||
'prefix' => env(
|
||||
'REDIS_PREFIX',
|
||||
Str::slug((string) env('APP_NAME', 'laravel'))
|
||||
. '-database-'
|
||||
),
|
||||
'persistent' => env('REDIS_PERSISTENT', false),
|
||||
],
|
||||
|
||||
|
|
@ -161,7 +168,10 @@ return [
|
|||
'port' => env('REDIS_PORT', '6379'),
|
||||
'database' => env('REDIS_DB', '0'),
|
||||
'max_retries' => env('REDIS_MAX_RETRIES', 3),
|
||||
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
|
||||
'backoff_algorithm' => env(
|
||||
'REDIS_BACKOFF_ALGORITHM',
|
||||
'decorrelated_jitter'
|
||||
),
|
||||
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
|
||||
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
|
||||
],
|
||||
|
|
@ -174,7 +184,10 @@ return [
|
|||
'port' => env('REDIS_PORT', '6379'),
|
||||
'database' => env('REDIS_CACHE_DB', '1'),
|
||||
'max_retries' => env('REDIS_MAX_RETRIES', 3),
|
||||
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
|
||||
'backoff_algorithm' => env(
|
||||
'REDIS_BACKOFF_ALGORITHM',
|
||||
'decorrelated_jitter'
|
||||
),
|
||||
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
|
||||
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -41,7 +41,10 @@ return [
|
|||
'public' => [
|
||||
'driver' => 'local',
|
||||
'root' => storage_path('app/public'),
|
||||
'url' => rtrim(env('APP_URL', 'http://localhost'), '/').'/storage',
|
||||
'url' => rtrim(
|
||||
env('APP_URL', 'http://localhost'),
|
||||
'/'
|
||||
) . '/storage',
|
||||
'visibility' => 'public',
|
||||
'throw' => false,
|
||||
'report' => false,
|
||||
|
|
@ -55,7 +58,10 @@ return [
|
|||
'bucket' => env('AWS_BUCKET'),
|
||||
'url' => env('AWS_URL'),
|
||||
'endpoint' => env('AWS_ENDPOINT'),
|
||||
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
|
||||
'use_path_style_endpoint' => env(
|
||||
'AWS_USE_PATH_STYLE_ENDPOINT',
|
||||
false
|
||||
),
|
||||
'throw' => false,
|
||||
'report' => false,
|
||||
],
|
||||
|
|
|
|||
|
|
@ -89,7 +89,10 @@ return [
|
|||
'handler_with' => [
|
||||
'host' => env('PAPERTRAIL_URL'),
|
||||
'port' => env('PAPERTRAIL_PORT'),
|
||||
'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'),
|
||||
'connectionString' => 'tls://'
|
||||
. env('PAPERTRAIL_URL')
|
||||
. ':'
|
||||
. env('PAPERTRAIL_PORT'),
|
||||
],
|
||||
'processors' => [PsrLogMessageProcessor::class],
|
||||
],
|
||||
|
|
|
|||
|
|
@ -46,7 +46,13 @@ return [
|
|||
'username' => env('MAIL_USERNAME'),
|
||||
'password' => env('MAIL_PASSWORD'),
|
||||
'timeout' => null,
|
||||
'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)),
|
||||
'local_domain' => env(
|
||||
'MAIL_EHLO_DOMAIN',
|
||||
parse_url(
|
||||
(string) env('APP_URL', 'http://localhost'),
|
||||
PHP_URL_HOST
|
||||
)
|
||||
),
|
||||
],
|
||||
|
||||
'ses' => [
|
||||
|
|
|
|||
|
|
@ -57,7 +57,10 @@ return [
|
|||
'driver' => 'sqs',
|
||||
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||
'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'),
|
||||
'prefix' => env(
|
||||
'SQS_PREFIX',
|
||||
'https://sqs.us-east-1.amazonaws.com/your-account-id'
|
||||
),
|
||||
'queue' => env('SQS_QUEUE', 'default'),
|
||||
'suffix' => env('SQS_SUFFIX'),
|
||||
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
|
||||
|
|
|
|||
|
|
@ -129,7 +129,7 @@ return [
|
|||
|
||||
'cookie' => env(
|
||||
'SESSION_COOKIE',
|
||||
Str::slug((string) env('APP_NAME', 'laravel')).'-session'
|
||||
Str::slug((string) env('APP_NAME', 'laravel')) . '-session'
|
||||
),
|
||||
|
||||
/*
|
||||
|
|
@ -193,7 +193,7 @@ return [
|
|||
| take place, and can be used to mitigate CSRF attacks. By default, we
|
||||
| will set this value to "lax" to permit secure cross-site requests.
|
||||
|
|
||||
| See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value
|
||||
| See MDN Set-Cookie SameSite documentation.
|
||||
|
|
||||
| Supported: "lax", "strict", "none", null
|
||||
|
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ return new class extends Migration
|
|||
Schema::create('sets', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->text('description');
|
||||
$table->string('icon_image_url');
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,10 @@ class SetSeeder extends Seeder
|
|||
|
||||
$set = $setRepository->create(new CreateSetDto(
|
||||
name: $title,
|
||||
description: 'Baderech HaAvodah is a way of living - '
|
||||
. 'a structured path for inner and outer growth, '
|
||||
. 'spiritual refinement, and personal development.',
|
||||
iconImageUrl: '/assets/baderech-haavodah-icon.png',
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,23 @@
|
|||
<?php
|
||||
|
||||
// phpcs:disable PSR1.Files.SideEffects.FoundWithSymbols
|
||||
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
define('LARAVEL_START', microtime(true));
|
||||
|
||||
// Determine if the application is in maintenance mode...
|
||||
if (file_exists($maintenance = __DIR__.'/../storage/framework/maintenance.php')) {
|
||||
$maintenance = __DIR__ . '/../storage/framework/maintenance.php';
|
||||
if (file_exists($maintenance)) {
|
||||
require $maintenance;
|
||||
}
|
||||
|
||||
// Register the Composer autoloader...
|
||||
require __DIR__.'/../vendor/autoload.php';
|
||||
require __DIR__ . '/../vendor/autoload.php';
|
||||
|
||||
// Bootstrap Laravel and handle the request...
|
||||
/** @var Application $app */
|
||||
$app = require_once __DIR__.'/../bootstrap/app.php';
|
||||
$app = require_once __DIR__ . '/../bootstrap/app.php';
|
||||
|
||||
$app->handleRequest(Request::capture());
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<?php
|
||||
|
||||
use App\Controllers\AuthController;
|
||||
use App\Controllers\SetController;
|
||||
use App\Http\Middleware\AuthMiddleware;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
|
|
@ -8,3 +9,4 @@ Route::post('/login', [AuthController::class, 'login']);
|
|||
Route::post('/logout', [AuthController::class, 'logout']);
|
||||
Route::get('/me', [AuthController::class, 'me'])
|
||||
->middleware(AuthMiddleware::class);
|
||||
Route::get('/sets', [SetController::class, 'index']);
|
||||
|
|
|
|||
|
|
@ -7,7 +7,9 @@ use DateTimeImmutable;
|
|||
|
||||
class FakeClock implements Clock
|
||||
{
|
||||
public function __construct(private DateTimeImmutable $currentTime) {}
|
||||
public function __construct(private DateTimeImmutable $currentTime)
|
||||
{
|
||||
}
|
||||
|
||||
public function now(): DateTimeImmutable
|
||||
{
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ class FakeSetRepository implements SetRepository
|
|||
$set = new DomainSet(
|
||||
id: $id,
|
||||
name: $dto->name,
|
||||
description: $dto->description,
|
||||
iconImageUrl: $dto->iconImageUrl,
|
||||
);
|
||||
$this->setsById[$id] = $set;
|
||||
|
||||
|
|
@ -52,6 +54,8 @@ class FakeSetRepository implements SetRepository
|
|||
return new DomainSet(
|
||||
id: $set->getId(),
|
||||
name: $set->getName(),
|
||||
description: $set->getDescription(),
|
||||
iconImageUrl: $set->getIconImageUrl(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,9 @@ class FakeTokenGenerator implements TokenGenerator
|
|||
/**
|
||||
* @param string[] $tokens
|
||||
*/
|
||||
public function __construct(private array $tokens) {}
|
||||
public function __construct(private array $tokens)
|
||||
{
|
||||
}
|
||||
|
||||
public function generate(): string
|
||||
{
|
||||
|
|
|
|||
48
backend/tests/Feature/SetsEndpointTest.php
Normal file
48
backend/tests/Feature/SetsEndpointTest.php
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Set\CreateSetDto;
|
||||
use App\Set\SetRepository;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class SetsEndpointTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function testReturnsAllSets(): void
|
||||
{
|
||||
$setRepository = app(SetRepository::class);
|
||||
$baderechSet = $setRepository->create(new CreateSetDto(
|
||||
name: 'Baderech HaAvodah',
|
||||
description: 'Baderech HaAvodah is a way of living',
|
||||
iconImageUrl: '/assets/baderech-haavodah-icon.png',
|
||||
));
|
||||
$dailyLearningSet = $setRepository->create(new CreateSetDto(
|
||||
name: 'Daily Learning',
|
||||
description: 'Daily learning for steady growth',
|
||||
iconImageUrl: '/assets/daily-learning-icon.svg',
|
||||
));
|
||||
|
||||
$response = $this->getJson('/api/sets');
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertExactJson([
|
||||
'sets' => [
|
||||
[
|
||||
'id' => $baderechSet->getId(),
|
||||
'name' => $baderechSet->getName(),
|
||||
'description' => $baderechSet->getDescription(),
|
||||
'iconImageUrl' => $baderechSet->getIconImageUrl(),
|
||||
],
|
||||
[
|
||||
'id' => $dailyLearningSet->getId(),
|
||||
'name' => $dailyLearningSet->getName(),
|
||||
'description' => $dailyLearningSet->getDescription(),
|
||||
'iconImageUrl' => $dailyLearningSet->getIconImageUrl(),
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -31,7 +31,7 @@ class AuthMiddlewareTest extends TestCase
|
|||
'2026-04-29T12:00:00',
|
||||
new DateTimeZone('UTC')
|
||||
);
|
||||
$this->sessionRepo = new FakeSessionRepository;
|
||||
$this->sessionRepo = new FakeSessionRepository();
|
||||
$this->clock = new FakeClock($this->now);
|
||||
$this->middleware = new AuthMiddleware(
|
||||
$this->sessionRepo,
|
||||
|
|
@ -58,7 +58,7 @@ class AuthMiddlewareTest extends TestCase
|
|||
};
|
||||
}
|
||||
|
||||
public function test_missing_cookie_returns_unauthorized_json(): void
|
||||
public function testMissingCookieReturnsUnauthorizedJson(): void
|
||||
{
|
||||
$captured = null;
|
||||
$response = $this->middleware->handle(
|
||||
|
|
@ -74,7 +74,7 @@ class AuthMiddlewareTest extends TestCase
|
|||
$this->assertNull($captured);
|
||||
}
|
||||
|
||||
public function test_unknown_token_returns_unauthorized(): void
|
||||
public function testUnknownTokenReturnsUnauthorized(): void
|
||||
{
|
||||
$captured = null;
|
||||
$response = $this->middleware->handle(
|
||||
|
|
@ -86,7 +86,7 @@ class AuthMiddlewareTest extends TestCase
|
|||
$this->assertNull($captured);
|
||||
}
|
||||
|
||||
public function test_expired_session_returns_unauthorized_and_is_deleted(): void
|
||||
public function testExpiredSessionReturnsUnauthorizedAndIsDeleted(): void
|
||||
{
|
||||
$user = new User(
|
||||
id: 7,
|
||||
|
|
@ -113,7 +113,7 @@ class AuthMiddlewareTest extends TestCase
|
|||
);
|
||||
}
|
||||
|
||||
public function test_valid_session_attaches_user_and_calls_next(): void
|
||||
public function testValidSessionAttachesUserAndCallsNext(): void
|
||||
{
|
||||
$user = new User(
|
||||
id: 7,
|
||||
|
|
|
|||
|
|
@ -25,7 +25,10 @@ class AuthenticateUserTest extends TestCase
|
|||
$this->userRepo = new FakeUserRepository();
|
||||
$this->hasher = new FakeHasher();
|
||||
|
||||
$this->authenticateUser = new AuthenticateUser($this->userRepo, $this->hasher);
|
||||
$this->authenticateUser = new AuthenticateUser(
|
||||
$this->userRepo,
|
||||
$this->hasher
|
||||
);
|
||||
}
|
||||
|
||||
public function testAuthenticatesValidUser(): void
|
||||
|
|
|
|||
|
|
@ -44,8 +44,14 @@ class CreateSessionTest extends TestCase
|
|||
$this->assertSame('fake-token-123', $session->getToken());
|
||||
$this->assertSame($user, $session->getUser());
|
||||
$this->assertFalse($session->isExpired($this->clock->now()));
|
||||
$this->assertSame('2026-05-18 12:00:00', $session->getCreatedAt()->format('Y-m-d H:i:s'));
|
||||
$this->assertSame('2026-05-25 12:00:00', $session->getExpiresAt()->format('Y-m-d H:i:s'));
|
||||
$this->assertSame(
|
||||
'2026-05-18 12:00:00',
|
||||
$session->getCreatedAt()->format('Y-m-d H:i:s')
|
||||
);
|
||||
$this->assertSame(
|
||||
'2026-05-25 12:00:00',
|
||||
$session->getExpiresAt()->format('Y-m-d H:i:s')
|
||||
);
|
||||
|
||||
$stored = $this->sessionRepo->findByToken($session->getToken());
|
||||
$this->assertNotNull($stored);
|
||||
|
|
|
|||
|
|
@ -25,11 +25,11 @@ class LogoutTest extends TestCase
|
|||
'2026-04-29T12:00:00',
|
||||
new DateTimeZone('UTC')
|
||||
);
|
||||
$this->sessionRepo = new FakeSessionRepository;
|
||||
$this->sessionRepo = new FakeSessionRepository();
|
||||
$this->useCase = new Logout($this->sessionRepo);
|
||||
}
|
||||
|
||||
public function test_existing_token_session_is_removed(): void
|
||||
public function testExistingTokenSessionIsRemoved(): void
|
||||
{
|
||||
$this->sessionRepo->create(new CreateSessionDto(
|
||||
token: 'token-abc',
|
||||
|
|
@ -47,7 +47,7 @@ class LogoutTest extends TestCase
|
|||
$this->assertNull($this->sessionRepo->findByToken('token-abc'));
|
||||
}
|
||||
|
||||
public function test_unknown_token_does_not_throw(): void
|
||||
public function testUnknownTokenDoesNotThrow(): void
|
||||
{
|
||||
$this->useCase->execute('unknown-token');
|
||||
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ class AuthControllerTest extends TestCase
|
|||
);
|
||||
}
|
||||
|
||||
public function test_login_returns_200_and_sets_cookie_on_success(): void
|
||||
public function testLoginReturns200AndSetsCookieOnSuccess(): void
|
||||
{
|
||||
$email = 'user@example.com';
|
||||
$password = 'password';
|
||||
|
|
@ -104,21 +104,21 @@ class AuthControllerTest extends TestCase
|
|||
);
|
||||
}
|
||||
|
||||
public function test_login_returns_400_when_email_missing(): void
|
||||
public function testLoginReturns400WhenEmailMissing(): void
|
||||
{
|
||||
$request = new Request(['password' => 'correctpassword']);
|
||||
$response = $this->controller->login($request);
|
||||
$this->assertEquals(400, $response->getStatusCode());
|
||||
}
|
||||
|
||||
public function test_login_returns_400_when_password_missing(): void
|
||||
public function testLoginReturns400WhenPasswordMissing(): void
|
||||
{
|
||||
$request = new Request(['email' => 'user@example.com']);
|
||||
$response = $this->controller->login($request);
|
||||
$this->assertEquals(400, $response->getStatusCode());
|
||||
}
|
||||
|
||||
public function test_login_returns_401_when_credentials_invalid(): void
|
||||
public function testLoginReturns401WhenCredentialsInvalid(): void
|
||||
{
|
||||
$this->seedStartupUser('user@example.com', 'correctpassword');
|
||||
|
||||
|
|
@ -130,7 +130,7 @@ class AuthControllerTest extends TestCase
|
|||
$this->assertEquals(401, $response->getStatusCode());
|
||||
}
|
||||
|
||||
public function test_logout_returns_204_and_clears_cookie(): void
|
||||
public function testLogoutReturns204AndClearsCookie(): void
|
||||
{
|
||||
$this->seedStartupUser('user@example.com', 'correctpassword');
|
||||
$loginRequest = new Request([
|
||||
|
|
@ -139,7 +139,7 @@ class AuthControllerTest extends TestCase
|
|||
]);
|
||||
$this->controller->login($loginRequest);
|
||||
|
||||
$logoutRequest = new Request;
|
||||
$logoutRequest = new Request();
|
||||
$logoutRequest->cookies->set(
|
||||
AuthMiddleware::COOKIE_NAME,
|
||||
'session-token-1'
|
||||
|
|
@ -160,7 +160,7 @@ class AuthControllerTest extends TestCase
|
|||
$this->assertSame('', $cookies[0]->getValue());
|
||||
}
|
||||
|
||||
public function test_me_returns_200_with_user_when_authenticated(): void
|
||||
public function testMeReturns200WithUserWhenAuthenticated(): void
|
||||
{
|
||||
$email = 'me@example.com';
|
||||
$user = $this->userRepo->create(
|
||||
|
|
@ -170,7 +170,7 @@ class AuthControllerTest extends TestCase
|
|||
)
|
||||
);
|
||||
|
||||
$request = new Request;
|
||||
$request = new Request();
|
||||
$request->attributes->set('user', $user);
|
||||
|
||||
$response = $this->controller->me($request);
|
||||
|
|
|
|||
|
|
@ -10,7 +10,12 @@ class ElementTest extends TestCase
|
|||
{
|
||||
public function testCreatesElementWithNullableParent(): void
|
||||
{
|
||||
$set = new DomainSet(1, 'Daily learning');
|
||||
$set = new DomainSet(
|
||||
id: 1,
|
||||
name: 'Daily learning',
|
||||
description: 'Daily learning description',
|
||||
iconImageUrl: '/assets/daily-learning-icon.svg',
|
||||
);
|
||||
$rootElement = new Element(
|
||||
id: 1,
|
||||
title: 'Root',
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ use App\Element\UseCases\CreateElement\CreateElement;
|
|||
use App\Element\UseCases\CreateElement\CreateElementRequest;
|
||||
use App\Exceptions\BadRequestException;
|
||||
use App\Set\CreateSetDto;
|
||||
use App\Set\Set as DomainSet;
|
||||
use DomainException;
|
||||
use Tests\Fakes\FakeElementRepository;
|
||||
use Tests\Fakes\FakeSetRepository;
|
||||
|
|
@ -30,11 +31,18 @@ class CreateElementTest extends TestCase
|
|||
);
|
||||
}
|
||||
|
||||
private function createSet(string $name): DomainSet
|
||||
{
|
||||
return $this->setRepo->create(new CreateSetDto(
|
||||
name: $name,
|
||||
description: "$name description",
|
||||
iconImageUrl: '/assets/test-set-icon.svg',
|
||||
));
|
||||
}
|
||||
|
||||
public function testCreatesRootElement(): void
|
||||
{
|
||||
$set = $this->setRepo->create(
|
||||
new CreateSetDto('Daily learning')
|
||||
);
|
||||
$set = $this->createSet('Daily learning');
|
||||
|
||||
$element = $this->createElement->execute(new CreateElementRequest(
|
||||
setId: $set->getId(),
|
||||
|
|
@ -50,9 +58,7 @@ class CreateElementTest extends TestCase
|
|||
|
||||
public function testCreatesChildElement(): void
|
||||
{
|
||||
$set = $this->setRepo->create(
|
||||
new CreateSetDto('Daily learning')
|
||||
);
|
||||
$set = $this->createSet('Daily learning');
|
||||
$rootElement = $this->createElement->execute(
|
||||
new CreateElementRequest(
|
||||
setId: $set->getId(),
|
||||
|
|
@ -114,9 +120,7 @@ class CreateElementTest extends TestCase
|
|||
|
||||
public function testThrowsWhenParentElementDoesNotExist(): void
|
||||
{
|
||||
$set = $this->setRepo->create(
|
||||
new CreateSetDto('Daily learning')
|
||||
);
|
||||
$set = $this->createSet('Daily learning');
|
||||
|
||||
$this->expectException(DomainException::class);
|
||||
$this->expectExceptionMessage(
|
||||
|
|
@ -132,9 +136,7 @@ class CreateElementTest extends TestCase
|
|||
|
||||
public function testThrowsWhenRootElementAlreadyExists(): void
|
||||
{
|
||||
$set = $this->setRepo->create(
|
||||
new CreateSetDto('Daily learning')
|
||||
);
|
||||
$set = $this->createSet('Daily learning');
|
||||
$this->createElement->execute(new CreateElementRequest(
|
||||
setId: $set->getId(),
|
||||
title: 'Root',
|
||||
|
|
@ -155,12 +157,8 @@ class CreateElementTest extends TestCase
|
|||
|
||||
public function testThrowsWhenParentBelongsToAnotherSet(): void
|
||||
{
|
||||
$parentSet = $this->setRepo->create(
|
||||
new CreateSetDto('Parent set')
|
||||
);
|
||||
$childSet = $this->setRepo->create(
|
||||
new CreateSetDto('Child set')
|
||||
);
|
||||
$parentSet = $this->createSet('Parent set');
|
||||
$childSet = $this->createSet('Child set');
|
||||
$parentElement = $this->createElement->execute(
|
||||
new CreateElementRequest(
|
||||
setId: $parentSet->getId(),
|
||||
|
|
|
|||
|
|
@ -9,9 +9,22 @@ class SetTest extends TestCase
|
|||
{
|
||||
public function testCreatesSetWithName(): void
|
||||
{
|
||||
$set = new DomainSet(1, 'Daily learning');
|
||||
$set = new DomainSet(
|
||||
id: 1,
|
||||
name: 'Daily learning',
|
||||
description: 'A structured path for daily growth',
|
||||
iconImageUrl: '/assets/daily-learning-icon.svg',
|
||||
);
|
||||
|
||||
$this->assertSame(1, $set->getId());
|
||||
$this->assertSame('Daily learning', $set->getName());
|
||||
$this->assertSame(
|
||||
'A structured path for daily growth',
|
||||
$set->getDescription()
|
||||
);
|
||||
$this->assertSame(
|
||||
'/assets/daily-learning-icon.svg',
|
||||
$set->getIconImageUrl()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
26
frontend/rabbi_gerzi/cypress/e2e/media.cy.ts
Normal file
26
frontend/rabbi_gerzi/cypress/e2e/media.cy.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
describe('media page sets', () => {
|
||||
it('fetches and renders seeded set cards', () => {
|
||||
cy.visit('/media')
|
||||
|
||||
cy.get('header.site-header').within(() => {
|
||||
cy.contains('Torah Media').should('be.visible')
|
||||
cy.contains('About').should('be.visible')
|
||||
cy.contains('Contact').should('be.visible')
|
||||
cy.contains('Donate').should('be.visible')
|
||||
})
|
||||
cy.contains('h1', 'Torah Media').should('be.visible')
|
||||
cy.get('[data-cy="media-set-grid"]').should('be.visible')
|
||||
cy.get('[data-cy="media-set-card"]', { timeout: 10000 })
|
||||
.should('have.length', 1)
|
||||
.first()
|
||||
.within(() => {
|
||||
cy.get('img[data-cy="media-set-icon"]')
|
||||
.should('be.visible')
|
||||
.and('have.attr', 'src')
|
||||
.and('include', '/assets/baderech-haavodah-icon.png')
|
||||
cy.contains('h2', 'Baderech HaAvodah').should('be.visible')
|
||||
cy.contains('a structured path for inner and outer growth')
|
||||
.should('be.visible')
|
||||
})
|
||||
})
|
||||
})
|
||||
BIN
frontend/rabbi_gerzi/public/assets/baderech-haavodah-icon.png
Normal file
BIN
frontend/rabbi_gerzi/public/assets/baderech-haavodah-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
46
frontend/rabbi_gerzi/src/stores/mediaSets.ts
Normal file
46
frontend/rabbi_gerzi/src/stores/mediaSets.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export interface MediaSet {
|
||||
id: number
|
||||
name: string
|
||||
description: string
|
||||
iconImageUrl: string
|
||||
}
|
||||
|
||||
interface SetsResponse {
|
||||
sets: MediaSet[]
|
||||
}
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL as string
|
||||
|
||||
export const useMediaSetsStore = defineStore('mediaSets', () => {
|
||||
const sets = ref<MediaSet[]>([])
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
async function fetchSets(): Promise<void> {
|
||||
error.value = null
|
||||
isLoading.value = true
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/sets`)
|
||||
|
||||
if (!response.ok) {
|
||||
sets.value = []
|
||||
error.value = 'Could not load media sets'
|
||||
return
|
||||
}
|
||||
|
||||
const data: SetsResponse = await response.json()
|
||||
sets.value = data.sets
|
||||
} catch {
|
||||
sets.value = []
|
||||
error.value = 'Network error - could not load media sets'
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { sets, isLoading, error, fetchSets }
|
||||
})
|
||||
|
|
@ -1,17 +1,56 @@
|
|||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { onMounted } from 'vue'
|
||||
import SiteHeader from '@/components/SiteHeader.vue'
|
||||
import { useMediaSetsStore } from '@/stores/mediaSets'
|
||||
|
||||
const mediaSetsStore = useMediaSetsStore()
|
||||
const { sets, isLoading, error } = storeToRefs(mediaSetsStore)
|
||||
|
||||
onMounted(() => {
|
||||
void mediaSetsStore.fetchSets()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="media-page">
|
||||
<SiteHeader />
|
||||
<main class="media-page__main" data-cy="media-page">
|
||||
<section class="media-page__hero">
|
||||
<p class="media-page__eyebrow">Rabbi Yehoshua Gerzi</p>
|
||||
<header class="media-page__header">
|
||||
<h1 class="media-page__heading">Torah Media</h1>
|
||||
<p class="media-page__intro">
|
||||
Explore Rabbi Gerzi's Torah videos, audio teachings, and written resources.
|
||||
</header>
|
||||
|
||||
<section class="media-page__sets" aria-label="Featured sets">
|
||||
<p v-if="isLoading" class="media-page__status">Loading media sets...</p>
|
||||
<p
|
||||
v-else-if="error"
|
||||
class="media-page__status media-page__status--error"
|
||||
data-cy="media-sets-error"
|
||||
>
|
||||
{{ error }}
|
||||
</p>
|
||||
<p v-else-if="sets.length === 0" class="media-page__status">
|
||||
No media sets are available yet.
|
||||
</p>
|
||||
<div v-else class="media-page__grid" data-cy="media-set-grid">
|
||||
<article
|
||||
v-for="mediaSet in sets"
|
||||
:key="mediaSet.id"
|
||||
class="media-page__card"
|
||||
data-cy="media-set-card"
|
||||
>
|
||||
<img
|
||||
:src="mediaSet.iconImageUrl"
|
||||
:alt="`${mediaSet.name} icon`"
|
||||
class="media-page__card-icon"
|
||||
data-cy="media-set-icon"
|
||||
/>
|
||||
<h2 class="media-page__card-title">{{ mediaSet.name }}</h2>
|
||||
<p class="media-page__card-description">
|
||||
{{ mediaSet.description }}
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
|
@ -20,48 +59,88 @@ import SiteHeader from '@/components/SiteHeader.vue'
|
|||
<style scoped>
|
||||
.media-page {
|
||||
min-height: 100vh;
|
||||
background: var(--color-cream);
|
||||
background: var(--color-white);
|
||||
}
|
||||
|
||||
.media-page__main {
|
||||
padding: 5rem 2rem;
|
||||
min-height: 100vh;
|
||||
padding: 5.1rem 4.15rem 5rem;
|
||||
}
|
||||
|
||||
.media-page__hero {
|
||||
max-width: 760px;
|
||||
margin: 0 auto;
|
||||
padding: 4rem 3rem;
|
||||
background: var(--color-white);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 14px;
|
||||
.media-page__header {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.media-page__eyebrow {
|
||||
margin: 0 0 1rem;
|
||||
color: var(--color-olive);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.media-page__heading {
|
||||
margin: 0;
|
||||
color: var(--color-slate);
|
||||
color: #2c2c2c;
|
||||
font-family: var(--font-serif);
|
||||
font-size: clamp(2.25rem, 5vw, 4rem);
|
||||
font-size: clamp(2.6rem, 4.8vw, 3.2rem);
|
||||
font-weight: 400;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.media-page__intro {
|
||||
max-width: 34rem;
|
||||
margin: 1.5rem auto 0;
|
||||
.media-page__sets {
|
||||
margin-top: 5rem;
|
||||
}
|
||||
|
||||
.media-page__status {
|
||||
max-width: 48rem;
|
||||
margin: 0 auto;
|
||||
padding: 1rem 1.25rem;
|
||||
color: var(--color-text-muted);
|
||||
background: var(--color-white);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.media-page__status--error {
|
||||
color: #7c2d2d;
|
||||
border-color: #e5b8b8;
|
||||
}
|
||||
|
||||
.media-page__grid {
|
||||
display: grid;
|
||||
max-width: 1745px;
|
||||
margin: 0 auto;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 2.7rem 3.75rem;
|
||||
}
|
||||
|
||||
.media-page__card {
|
||||
min-height: 448px;
|
||||
padding: 3.1rem 3rem 2.7rem;
|
||||
background: var(--color-white);
|
||||
border: 1px solid #e5cf9f;
|
||||
border-radius: 17px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.media-page__card-icon {
|
||||
display: block;
|
||||
width: 140px;
|
||||
height: 140px;
|
||||
margin: 0 auto 2.65rem;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.media-page__card-title {
|
||||
margin: 0;
|
||||
color: #333333;
|
||||
font-family: var(--font-serif);
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.7;
|
||||
font-size: clamp(2rem, 3.7vw, 2.4rem);
|
||||
font-weight: 400;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.media-page__card-description {
|
||||
max-width: 35rem;
|
||||
margin: 2.15rem auto 0;
|
||||
color: #333333;
|
||||
font-family: var(--font-serif);
|
||||
font-size: clamp(1.25rem, 2.3vw, 1.45rem);
|
||||
line-height: 1.38;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
|
|
@ -69,8 +148,24 @@ import SiteHeader from '@/components/SiteHeader.vue'
|
|||
padding: 3rem 1rem;
|
||||
}
|
||||
|
||||
.media-page__hero {
|
||||
padding: 3rem 1.5rem;
|
||||
.media-page__sets {
|
||||
margin-top: 3rem;
|
||||
}
|
||||
|
||||
.media-page__grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.media-page__card {
|
||||
min-height: 390px;
|
||||
padding: 2.5rem 1.5rem;
|
||||
}
|
||||
|
||||
.media-page__card-icon {
|
||||
width: 118px;
|
||||
height: 118px;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue