Done
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled

This commit is contained in:
2026-07-04 23:09:00 +10:00
parent 0485b85a59
commit 439139a057
50 changed files with 2405 additions and 548 deletions
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace App\Console\Commands;
use App\Models\Feed;
use App\Services\FeedFetcher;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
#[Signature('feeds:fetch')]
#[Description('Fetch feeds')]
class FetchFeeds extends Command
{
protected $signature = 'feeds:fetch';
public function handle(FeedFetcher $fetcher): void
{
Feed::query()->chunk(50, function ($feeds) use ($fetcher) {
foreach ($feeds as $feed) {
dispatch(fn () => $fetcher->fetch($feed))->onQueue('feeds');
}
});
}
}
+174
View File
@@ -0,0 +1,174 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
class UpDb extends Command
{
protected $signature = 'db:updb
{--no-backup : Skip saving a local backup before wiping}
{--force : Skip confirmation prompt}';
protected $description = 'Download the production database and replace the local database with it';
public function handle(): int
{
if (! $this->option('force') && ! $this->confirm('This will wipe your local database and replace it with production. Are you sure?')) {
$this->info('Aborted.');
return self::SUCCESS;
}
$local = $this->connectionConfig('local');
$production = $this->connectionConfig('production');
$timestamp = now()->format('Y_m_d_His');
$dumpPath = storage_path("app/private/db_dumps/production_{$timestamp}.dump");
$backupPath = storage_path("app/private/db_dumps/local_backup_{$timestamp}.dump");
if (! is_dir(storage_path('app/private/db_dumps'))) {
mkdir(storage_path('app/private/db_dumps'), 0755, true);
}
// Step 1: Dump production
$this->info('Dumping production database...');
$dumpResult = $this->pgDump($production, $dumpPath);
if ($dumpResult !== 0) {
$this->error('Failed to dump production database.');
return self::FAILURE;
}
$this->info("Production dump saved to: {$dumpPath}");
// Step 2: Back up local (unless skipped)
if (! $this->option('no-backup')) {
$this->info('Backing up local database...');
$backupResult = $this->pgDump($local, $backupPath);
if ($backupResult !== 0) {
$this->error('Failed to back up local database. Aborting to be safe. Use --no-backup to skip.');
return self::FAILURE;
}
$this->info("Local backup saved to: {$backupPath}");
}
// Step 3: Drop and recreate the local database
$this->info('Wiping local database...');
$this->dropAndRecreateLocalDatabase($local);
// Step 4: Restore production dump into local
$this->info('Restoring production dump to local database...');
$restoreResult = $this->pgRestore($local, $dumpPath);
if ($restoreResult !== 0) {
$this->error('Restore failed. Your local backup is at: '.($this->option('no-backup') ? 'N/A (backup was skipped)' : $backupPath));
return self::FAILURE;
}
$this->info('Done. Local database now mirrors production.');
return self::SUCCESS;
}
private function connectionConfig(string $type): array
{
$connection = $type === 'production' ? 'pgsql_production' : config('database.default');
return [
'host' => config("database.connections.{$connection}.host"),
'port' => config("database.connections.{$connection}.port"),
'database' => config("database.connections.{$connection}.database"),
'username' => config("database.connections.{$connection}.username"),
'password' => config("database.connections.{$connection}.password"),
];
}
private function pgDump(array $config, string $outputPath): int
{
$pgpass = $this->writePgPass($config);
$command = sprintf(
'pg_dump -Fc -h %s -p %s -U %s %s -f %s',
escapeshellarg($config['host']),
escapeshellarg($config['port']),
escapeshellarg($config['username']),
escapeshellarg($config['database']),
escapeshellarg($outputPath),
);
$resultCode = $this->runWithPgPass($pgpass, $command);
unlink($pgpass);
return $resultCode;
}
private function pgRestore(array $config, string $dumpPath): int
{
$pgpass = $this->writePgPass($config);
$command = sprintf(
'pg_restore -h %s -p %s -U %s -d %s --no-owner --no-privileges %s',
escapeshellarg($config['host']),
escapeshellarg($config['port']),
escapeshellarg($config['username']),
escapeshellarg($config['database']),
escapeshellarg($dumpPath),
);
$resultCode = $this->runWithPgPass($pgpass, $command);
unlink($pgpass);
return $resultCode;
}
/**
* Write a temporary pgpass file and return its path.
* This is the cross-platform alternative to PGPASSWORD which doesn't work on Windows.
*/
private function writePgPass(array $config): string
{
// Escape any colons or backslashes in the password per pgpass format rules
$password = str_replace(['\\', ':'], ['\\\\', '\\:'], $config['password']);
$content = "{$config['host']}:{$config['port']}:*:{$config['username']}:{$password}";
$path = tempnam(sys_get_temp_dir(), 'pgpass_');
file_put_contents($path, $content);
chmod($path, 0600);
return $path;
}
private function runWithPgPass(string $pgpassFile, string $command): int
{
// PGPASSFILE is supported on all platforms including Windows
putenv("PGPASSFILE={$pgpassFile}");
passthru($command, $resultCode);
putenv('PGPASSFILE');
return $resultCode;
}
private function dropAndRecreateLocalDatabase(array $config): void
{
$database = $config['database'];
// Connect to the postgres maintenance database to drop/recreate
$pdo = new \PDO(
"pgsql:host={$config['host']};port={$config['port']};dbname=postgres",
$config['username'],
$config['password'],
);
$pdo->exec('SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '.$pdo->quote($database));
$pdo->exec('DROP DATABASE IF EXISTS '.'"'.$database.'"');
$pdo->exec('CREATE DATABASE '.'"'.$database.'"');
}
}
@@ -0,0 +1,73 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\Categories\StoreCategoryRequest;
use App\Http\Requests\Categories\UpdateCategoryRequest;
use App\Models\Category;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
use Inertia\Response;
class CategoryController extends Controller
{
public function getAll(){
return Category::query()
->where('user_id', request()->user()->id)
->whereNull('parent_id')
->with(['children.feeds', 'feeds'])
->orderBy('title')
->get();
}
public function index(): Response
{
$categories = $this->getAll();
return Inertia::render('Settings/Index', [
'categories' => $categories,
]);
}
public function store(StoreCategoryRequest $request): RedirectResponse
{
DB::transaction(function () use ($request) {
$category = Category::create([
'user_id' => $request->user()->id,
'parent_id' => $request->input('parent_id'),
'title' => $request->input('title'),
'description' => $request->input('description'),
]);
$category->feeds()->createMany($request->input('feeds', []));
});
return back();
}
public function update(UpdateCategoryRequest $request, Category $category): RedirectResponse
{
DB::transaction(function () use ($request, $category) {
$category->update([
'parent_id' => $request->input('parent_id'),
'title' => $request->input('title'),
'description' => $request->input('description'),
]);
$category->feeds()->delete();
$category->feeds()->createMany($request->input('feeds', []));
});
return back();
}
public function destroy(Category $category): RedirectResponse
{
abort_unless($category->user_id === request()->user()->id, 403);
$category->delete();
return back();
}
}
@@ -0,0 +1,31 @@
<?php
namespace App\Http\Controllers;
use App\Models\Category;
use App\Services\ArticleSelector;
use Illuminate\Http\Request;
use Inertia\Inertia;
class FrontPageController extends Controller
{
public function __invoke(ArticleSelector $selector)
{
$categories = Category::whereNull('parent_id')->get()->map(function ($category) use ($selector) {
return [
'label' => $category->title,
'hero' => $category->slug === 'headlines', // or a boolean column on Category
'items' => $selector->forCategory($category)->map(fn ($article) => [
'source' => $article->feed->title ?? parse_url($article->feed->url, PHP_URL_HOST),
'time' => $article->published_at?->diffForHumans(),
'headline' => $article->title,
'deck' => \Illuminate\Support\Str::limit($article->description, 140),
'image' => $article->image_url,
'link' => $article->link,
]),
];
});
return Inertia::render('Index', ['categories' => $categories]);
}
}
+14 -1
View File
@@ -2,8 +2,10 @@
namespace App\Http\Middleware;
use App\Models\Category;
use Illuminate\Http\Request;
use Inertia\Middleware;
use Tighten\Ziggy\Ziggy;
class HandleInertiaRequests extends Middleware
{
@@ -41,7 +43,18 @@ class HandleInertiaRequests extends Middleware
'auth' => [
'user' => $request->user(),
],
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
'navigation_categories' => fn () => $request->user()
? Category::query()
->where('user_id', $request->user()->id)
->whereNull('parent_id')
->with('children:id,parent_id,title')
->orderBy('title')
->get(['id', 'parent_id', 'title'])
: [],
'ziggy' => fn () => [
...new Ziggy()->toArray(),
'location' => $request->url(),
]
];
}
}
@@ -0,0 +1,30 @@
<?php
namespace App\Http\Requests\Categories;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreCategoryRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string', 'max:2000'],
'parent_id' => [
'nullable',
Rule::exists('categories', 'id')->where('user_id', $this->user()->id),
],
'feeds' => ['array'],
'feeds.*.url' => ['required', 'url', 'max:2048'],
'feeds.*.type' => ['required', Rule::in(['rss'])],
'feeds.*.paywall' => ['boolean'],
];
}
}
@@ -0,0 +1,31 @@
<?php
namespace App\Http\Requests\Categories;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateCategoryRequest extends FormRequest
{
public function authorize(): bool
{
return $this->route('category')->user_id === $this->user()->id;
}
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string', 'max:2000'],
'parent_id' => [
'nullable',
Rule::exists('categories', 'id')->where('user_id', $this->user()->id),
Rule::notIn([$this->route('category')->id]),
],
'feeds' => ['array'],
'feeds.*.url' => ['required', 'url', 'max:2048'],
'feeds.*.type' => ['required', Rule::in(['rss'])],
'feeds.*.paywall' => ['boolean'],
];
}
}
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Article extends Model
{
protected $fillable = ['feed_id', 'guid', 'title', 'description', 'link', 'author', 'image_url', 'published_at'];
protected $casts = ['published_at' => 'datetime'];
public function feed(): BelongsTo
{
return $this->belongsTo(Feed::class);
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Category extends Model
{
use HasFactory;
protected $fillable = [
'user_id',
'parent_id',
'title',
'description',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function parent(): BelongsTo
{
return $this->belongsTo(Category::class, 'parent_id');
}
public function children(): HasMany
{
return $this->hasMany(Category::class, 'parent_id');
}
public function feeds(): HasMany
{
return $this->hasMany(Feed::class);
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Feed extends Model
{
use HasFactory;
protected $fillable = [
'category_id',
'type',
'url',
'paywall'
];
public function category(): BelongsTo
{
return $this->belongsTo(Category::class);
}
public function articles(): HasMany
{
return $this->hasMany(Article::class);
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace App\Services;
use App\Models\Category;
use Illuminate\Support\Collection;
class ArticleSelector
{
public function forCategory(Category $category, int $limit = 10): Collection
{
$feeds = $category->feeds()->with([
'articles' => fn ($q) => $q->orderByDesc('published_at')->limit($limit),
])->get();
// Round robin: take article[0] from every feed, then article[1] from every feed, etc.
$buckets = $feeds->map(fn ($feed) => $feed->articles->values());
$maxDepth = $buckets->max(fn ($b) => $b->count()) ?? 0;
$selected = collect();
for ($i = 0; $i < $maxDepth && $selected->count() < $limit; $i++) {
foreach ($buckets as $bucket) {
if ($selected->count() >= $limit) break;
if ($bucket->has($i)) $selected->push($bucket[$i]);
}
}
return $selected;
}
}
+70
View File
@@ -0,0 +1,70 @@
<?php
namespace App\Services;
use App\Models\Article;
use App\Models\Feed;
use SimplePie\SimplePie;
class FeedFetcher
{
public function __construct(private OgImageResolver $ogResolver) {}
public function fetch(Feed $feed): void
{
$simplepie = new SimplePie();
$simplepie->set_feed_url($feed->url);
$simplepie->enable_cache(false); // you're doing your own caching in the DB
$simplepie->init();
if ($simplepie->error()) {
report(new \RuntimeException("Feed fetch failed for {$feed->url}: {$simplepie->error()}"));
return;
}
foreach ($simplepie->get_items() as $item) {
$imageUrl = $this->extractImage($item);
$attributes = [
'title' => strip_tags($item->get_title()),
'description' => strip_tags($item->get_description() ?? ''),
'link' => $item->get_permalink(),
'author' => $item->get_author()?->get_name(),
'published_at' => $item->get_date('Y-m-d H:i:s'),
];
if ($imageUrl) {
$attributes['image_url'] = $imageUrl;
}
$article = Article::updateOrCreate(
['feed_id' => $feed->id, 'guid' => $item->get_id()],
$attributes
);
if (! $article->image_url && ! $article->image_checked_at) {
$og = $this->ogResolver->resolve($article->link);
$article->update([
'image_url' => $og,
'image_checked_at' => now(),
]);
}
}
}
private function extractImage($item): ?string
{
$mediaContents = $item->get_item_tags('http://search.yahoo.com/mrss/', 'content');
if (empty($mediaContents)) {
$enclosure = $item->get_enclosure();
$link = $enclosure?->get_thumbnail() ?? $enclosure?->get_link();
return $link ? html_entity_decode($link) : null;
}
usort($mediaContents, fn ($a, $b) =>
(int) ($b['attribs']['']['width'] ?? 0) <=> (int) ($a['attribs']['']['width'] ?? 0)
);
return $mediaContents[0]['attribs']['']['url'] ?? null;
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Http;
class OgImageResolver
{
public function resolve(string $pageUrl): ?string
{
try {
$response = Http::withHeaders([
'User-Agent' => 'Mozilla/5.0 (compatible; DredgeNewsBot/1.0)',
])->timeout(8)->get($pageUrl);
if (! $response->successful()) {
return null;
}
libxml_use_internal_errors(true); // suppress warnings from imperfect HTML
$dom = new \DOMDocument();
$dom->loadHTML($response->body());
$xpath = new \DOMXPath($dom);
foreach (['og:image', 'twitter:image', 'twitter:image:src'] as $property) {
$node = $xpath->query("//meta[@property='{$property}']/@content")->item(0)
?? $xpath->query("//meta[@name='{$property}']/@content")->item(0);
if ($node && filled($node->nodeValue)) {
return $node->nodeValue;
}
}
return null;
} catch (\Throwable $e) {
report($e);
return null;
}
}
}
+6 -2
View File
@@ -9,13 +9,17 @@
],
"license": "MIT",
"require": {
"php": "^8.3",
"php": "^8.5",
"ext-pdo": "*",
"inertiajs/inertia-laravel": "^3.0",
"laravel/chisel": "^0.1.0",
"laravel/fortify": "^1.37.2",
"laravel/framework": "^13.7",
"laravel/tinker": "^3.0",
"laravel/wayfinder": "^0.1.14"
"laravel/wayfinder": "^0.1.14",
"simplepie/simplepie": "^1.9",
"tightenco/ziggy": "^2.6",
"ext-dom": "*"
},
"require-dev": {
"fakerphp/faker": "^1.24",
Generated
+154 -2
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "2a52d1d7bba8be031c54468aa4e4587c",
"content-hash": "93df71aed031b18601ca7009de99ca14",
"packages": [
{
"name": "bacon/bacon-qr-code",
@@ -4121,6 +4121,87 @@
},
"time": "2026-06-18T03:57:49+00:00"
},
{
"name": "simplepie/simplepie",
"version": "1.9.0",
"source": {
"type": "git",
"url": "https://github.com/simplepie/simplepie.git",
"reference": "76cccb1b2c5dcaf44f304c925ab30c0f48643992"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/simplepie/simplepie/zipball/76cccb1b2c5dcaf44f304c925ab30c0f48643992",
"reference": "76cccb1b2c5dcaf44f304c925ab30c0f48643992",
"shasum": ""
},
"require": {
"ext-pcre": "*",
"ext-xml": "*",
"ext-xmlreader": "*",
"php": ">=7.2.0"
},
"require-dev": {
"donatj/mock-webserver": "^2.7",
"friendsofphp/php-cs-fixer": "^2.19 || ^3.8",
"mf2/mf2": "^0.5.0",
"phpstan/phpstan": "~1.12.2",
"phpunit/phpunit": "^8 || ^9 || ^10",
"psr/http-client": "^1.0",
"psr/http-factory": "^1.0",
"psr/simple-cache": "^1 || ^2 || ^3"
},
"suggest": {
"ext-curl": "",
"ext-iconv": "",
"ext-intl": "",
"ext-mbstring": "",
"mf2/mf2": "Microformat module that allows for parsing HTML for microformats"
},
"type": "library",
"autoload": {
"psr-0": {
"SimplePie": "library"
},
"psr-4": {
"SimplePie\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Ryan Parman",
"homepage": "http://ryanparman.com/",
"role": "Creator, alumnus developer"
},
{
"name": "Sam Sneddon",
"homepage": "https://gsnedders.com/",
"role": "Alumnus developer"
},
{
"name": "Ryan McCue",
"email": "me@ryanmccue.info",
"homepage": "http://ryanmccue.info/",
"role": "Developer"
}
],
"description": "A simple Atom/RSS parsing library for PHP",
"homepage": "http://simplepie.org/",
"keywords": [
"atom",
"feeds",
"rss"
],
"support": {
"issues": "https://github.com/simplepie/simplepie/issues",
"source": "https://github.com/simplepie/simplepie/tree/1.9.0"
},
"time": "2025-09-12T06:34:27+00:00"
},
{
"name": "spomky-labs/cbor-php",
"version": "3.2.3",
@@ -7129,6 +7210,76 @@
],
"time": "2026-05-29T05:06:50+00:00"
},
{
"name": "tightenco/ziggy",
"version": "v2.6.3",
"source": {
"type": "git",
"url": "https://github.com/tighten/ziggy.git",
"reference": "14c5744f155182188419f7729d96e0ed7225e73b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/tighten/ziggy/zipball/14c5744f155182188419f7729d96e0ed7225e73b",
"reference": "14c5744f155182188419f7729d96e0ed7225e73b",
"shasum": ""
},
"require": {
"ext-json": "*",
"laravel/framework": ">=9.0",
"php": ">=8.1"
},
"require-dev": {
"laravel/folio": "^1.1",
"orchestra/testbench": "^8.0 || ^9.0 || ^10.0 || ^11.0",
"pestphp/pest": "^2.0 || ^3.0 || ^4.0",
"pestphp/pest-plugin-laravel": "^2.0 || ^3.0 || ^4.0"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Tighten\\Ziggy\\ZiggyServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Tighten\\Ziggy\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Daniel Coulbourne",
"email": "daniel@tighten.co"
},
{
"name": "Jake Bathman",
"email": "jake@tighten.co"
},
{
"name": "Jacob Baker-Kretzmar",
"email": "jacob@tighten.co"
}
],
"description": "Use your Laravel named routes in JavaScript.",
"homepage": "https://github.com/tighten/ziggy",
"keywords": [
"Ziggy",
"javascript",
"laravel",
"routes"
],
"support": {
"issues": "https://github.com/tighten/ziggy/issues",
"source": "https://github.com/tighten/ziggy/tree/v2.6.3"
},
"time": "2026-06-23T21:57:52+00:00"
},
{
"name": "tijsverkoyen/css-to-inline-styles",
"version": "v2.4.0",
@@ -11071,7 +11222,8 @@
"prefer-stable": true,
"prefer-lowest": false,
"platform": {
"php": "^8.3"
"php": "^8.5",
"ext-pdo": "*"
},
"platform-dev": {},
"plugin-api-version": "2.9.0"
+14
View File
@@ -99,6 +99,20 @@ return [
'sslmode' => env('DB_SSLMODE', 'prefer'),
],
'pgsql_production' => [
'driver' => 'pgsql',
'host' => env('PRODUCTION_DB_HOST', '127.0.0.1'),
'port' => env('PRODUCTION_DB_PORT', '5432'),
'database' => env('PRODUCTION_DB_DATABASE', 'forge'),
'username' => env('PRODUCTION_DB_USERNAME', 'forge'),
'password' => env('PRODUCTION_DB_PASSWORD', ''),
'charset' => 'utf8',
'prefix' => '',
'prefix_indexes' => true,
'search_path' => 'public',
'sslmode' => 'prefer',
],
'sqlsrv' => [
'driver' => 'sqlsrv',
'url' => env('DB_URL'),
+18
View File
@@ -0,0 +1,18 @@
<?php
namespace Database\Factories;
use App\Models\Category;
use Illuminate\Database\Eloquent\Factories\Factory;
class CategoryFactory extends Factory
{
protected $model = Category::class;
public function definition(): array
{
return [
];
}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
namespace Database\Factories;
use App\Models\Feed;
use Illuminate\Database\Eloquent\Factories\Factory;
class FeedFactory extends Factory
{
protected $model = Feed::class;
public function definition(): array
{
return [
];
}
}
@@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('categories', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->foreignId('parent_id')->nullable()->constrained('categories')->cascadeOnDelete();
$table->string('title');
$table->text('description')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('categories');
}
};
@@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('feeds', function (Blueprint $table) {
$table->id();
$table->foreignId('category_id')->constrained()->cascadeOnDelete();
$table->string('url');
$table->string('type')->default('rss');
$table->boolean('paywall');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('feeds');
}
};
@@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('articles', function (Blueprint $table) {
$table->id();
$table->foreignId('feed_id')->constrained()->cascadeOnDelete();
$table->string('guid'); // <guid> or Atom <id>, this is what dedupes
$table->string('title');
$table->text('description')->nullable();
$table->string('link');
$table->string('author')->nullable();
$table->string('image_url')->nullable();
$table->timestamp('published_at')->nullable();
$table->timestamps();
$table->unique(['feed_id', 'guid']);
$table->index(['feed_id', 'published_at']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('articles');
}
};
+72 -7
View File
@@ -22,7 +22,8 @@
"vue": "^3.5.13",
"vue-input-otp": "^0.3.2",
"vue-sonner": "^2.0.0",
"vuetify": "^4.1.2"
"vuetify": "^4.1.2",
"ziggy-js": "^2.6.3"
},
"devDependencies": {
"@eslint/js": "^9.19.0",
@@ -40,6 +41,7 @@
"eslint-plugin-vue": "^9.32.0",
"prettier": "^3.4.2",
"prettier-plugin-tailwindcss": "^0.6.11",
"sass": "^1.101.0",
"sass-embedded": "^1.100.0",
"typescript": "^5.2.2",
"typescript-eslint": "^8.23.0",
@@ -2962,8 +2964,8 @@
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
"integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
"devOptional": true,
"license": "MIT",
"optional": true,
"dependencies": {
"readdirp": "^5.0.0"
},
@@ -6028,6 +6030,18 @@
"node": ">=6"
}
},
"node_modules/qs-esm": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/qs-esm/-/qs-esm-7.0.3.tgz",
"integrity": "sha512-8jbjCR0PPbqoQcv83C2K/zvVeytRPwPpt3WPDbq51qyLAxcWGtXVRjSe6GHtLCoVbg9+NEFkv7GyUxqjcDIJzw==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -6053,8 +6067,8 @@
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
"integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==",
"devOptional": true,
"license": "MIT",
"optional": true,
"engines": {
"node": ">= 20.19.0"
},
@@ -6358,11 +6372,11 @@
}
},
"node_modules/sass": {
"version": "1.100.0",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.100.0.tgz",
"integrity": "sha512-B5j0rYMlinhhOo9tjQebMVVn0TfyXAF+wB3b2ggZUuJ/is/Y+7+JGjirAMxHZ9Z3hIP98NPfamlAkBHa1lAaXQ==",
"version": "1.101.0",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.101.0.tgz",
"integrity": "sha512-OL3GoQyoUdDt843DpVmDO6y2k1sc5IhUDSpu8XucEI+35neq5QivZ1iuegnpraEVTJXlQGK1gl27zKcTLEPbQw==",
"devOptional": true,
"license": "MIT",
"optional": true,
"dependencies": {
"chokidar": "^5.0.0",
"immutable": "^5.1.5",
@@ -6436,6 +6450,27 @@
"sass": "1.100.0"
}
},
"node_modules/sass-embedded-all-unknown/node_modules/sass": {
"version": "1.100.0",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.100.0.tgz",
"integrity": "sha512-B5j0rYMlinhhOo9tjQebMVVn0TfyXAF+wB3b2ggZUuJ/is/Y+7+JGjirAMxHZ9Z3hIP98NPfamlAkBHa1lAaXQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"chokidar": "^5.0.0",
"immutable": "^5.1.5",
"source-map-js": ">=0.6.2 <2.0.0"
},
"bin": {
"sass": "sass.js"
},
"engines": {
"node": ">=20.19.0"
},
"optionalDependencies": {
"@parcel/watcher": "^2.4.1"
}
},
"node_modules/sass-embedded-android-arm": {
"version": "1.100.0",
"resolved": "https://registry.npmjs.org/sass-embedded-android-arm/-/sass-embedded-android-arm-1.100.0.tgz",
@@ -6684,6 +6719,27 @@
"sass": "1.100.0"
}
},
"node_modules/sass-embedded-unknown-all/node_modules/sass": {
"version": "1.100.0",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.100.0.tgz",
"integrity": "sha512-B5j0rYMlinhhOo9tjQebMVVn0TfyXAF+wB3b2ggZUuJ/is/Y+7+JGjirAMxHZ9Z3hIP98NPfamlAkBHa1lAaXQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"chokidar": "^5.0.0",
"immutable": "^5.1.5",
"source-map-js": ">=0.6.2 <2.0.0"
},
"bin": {
"sass": "sass.js"
},
"engines": {
"node": ">=20.19.0"
},
"optionalDependencies": {
"@parcel/watcher": "^2.4.1"
}
},
"node_modules/sass-embedded-win32-arm64": {
"version": "1.100.0",
"resolved": "https://registry.npmjs.org/sass-embedded-win32-arm64/-/sass-embedded-win32-arm64-1.100.0.tgz",
@@ -7879,6 +7935,15 @@
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/ziggy-js": {
"version": "2.6.3",
"resolved": "https://registry.npmjs.org/ziggy-js/-/ziggy-js-2.6.3.tgz",
"integrity": "sha512-YcuXM0BohyWUsQcibMmlGMrfF01BQogRjrfg/AWQhG3rB/Swt76/SJxH5AH8X8Xn5aDWxGpYKdXV7Fo5g+BVLQ==",
"license": "MIT",
"dependencies": {
"qs-esm": "^7.0.3"
}
}
}
}
+3 -1
View File
@@ -28,6 +28,7 @@
"eslint-plugin-vue": "^9.32.0",
"prettier": "^3.4.2",
"prettier-plugin-tailwindcss": "^0.6.11",
"sass": "^1.101.0",
"sass-embedded": "^1.100.0",
"typescript": "^5.2.2",
"typescript-eslint": "^8.23.0",
@@ -52,7 +53,8 @@
"vue": "^3.5.13",
"vue-input-otp": "^0.3.2",
"vue-sonner": "^2.0.0",
"vuetify": "^4.1.2"
"vuetify": "^4.1.2",
"ziggy-js": "^2.6.3"
},
"optionalDependencies": {
"@rollup/rollup-linux-x64-gnu": "4.9.5",
-172
View File
@@ -1,172 +0,0 @@
@import 'tailwindcss';
@import 'tw-animate-css';
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
@source '../../storage/framework/views/*.php';
@custom-variant dark (&:is(.dark *));
@theme inline {
--font-sans:
Instrument Sans, ui-sans-serif, system-ui, sans-serif,
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
'Noto Color Emoji';
--radius-lg: var(--radius);
--radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) - 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar-background);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
/*
The default border color has changed to `currentColor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentColor);
}
}
@layer utilities {
body,
html {
--font-sans:
'Instrument Sans', ui-sans-serif, system-ui, sans-serif,
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
'Noto Color Emoji';
}
}
:root {
--background: hsl(0 0% 100%);
--foreground: hsl(0 0% 3.9%);
--card: hsl(0 0% 100%);
--card-foreground: hsl(0 0% 3.9%);
--popover: hsl(0 0% 100%);
--popover-foreground: hsl(0 0% 3.9%);
--primary: hsl(0 0% 9%);
--primary-foreground: hsl(0 0% 98%);
--secondary: hsl(0 0% 92.1%);
--secondary-foreground: hsl(0 0% 9%);
--muted: hsl(0 0% 96.1%);
--muted-foreground: hsl(0 0% 45.1%);
--accent: hsl(0 0% 96.1%);
--accent-foreground: hsl(0 0% 9%);
--destructive: hsl(0 84.2% 60.2%);
--destructive-foreground: hsl(0 0% 98%);
--border: hsl(0 0% 92.8%);
--input: hsl(0 0% 89.8%);
--ring: hsl(0 0% 3.9%);
--chart-1: hsl(12 76% 61%);
--chart-2: hsl(173 58% 39%);
--chart-3: hsl(197 37% 24%);
--chart-4: hsl(43 74% 66%);
--chart-5: hsl(27 87% 67%);
--radius: 0.5rem;
--sidebar-background: hsl(0 0% 98%);
--sidebar-foreground: hsl(240 5.3% 26.1%);
--sidebar-primary: hsl(0 0% 10%);
--sidebar-primary-foreground: hsl(0 0% 98%);
--sidebar-accent: hsl(0 0% 94%);
--sidebar-accent-foreground: hsl(0 0% 30%);
--sidebar-border: hsl(0 0% 91%);
--sidebar-ring: hsl(217.2 91.2% 59.8%);
--sidebar: hsl(0 0% 98%);
}
.dark {
--background: hsl(0 0% 3.9%);
--foreground: hsl(0 0% 98%);
--card: hsl(0 0% 3.9%);
--card-foreground: hsl(0 0% 98%);
--popover: hsl(0 0% 3.9%);
--popover-foreground: hsl(0 0% 98%);
--primary: hsl(0 0% 98%);
--primary-foreground: hsl(0 0% 9%);
--secondary: hsl(0 0% 14.9%);
--secondary-foreground: hsl(0 0% 98%);
--muted: hsl(0 0% 16.08%);
--muted-foreground: hsl(0 0% 63.9%);
--accent: hsl(0 0% 14.9%);
--accent-foreground: hsl(0 0% 98%);
--destructive: hsl(0 84% 60%);
--destructive-foreground: hsl(0 0% 98%);
--border: hsl(0 0% 14.9%);
--input: hsl(0 0% 14.9%);
--ring: hsl(0 0% 83.1%);
--chart-1: hsl(220 70% 50%);
--chart-2: hsl(160 60% 45%);
--chart-3: hsl(30 80% 55%);
--chart-4: hsl(280 65% 60%);
--chart-5: hsl(340 75% 55%);
--sidebar-background: hsl(0 0% 7%);
--sidebar-foreground: hsl(0 0% 95.9%);
--sidebar-primary: hsl(360, 100%, 100%);
--sidebar-primary-foreground: hsl(0 0% 100%);
--sidebar-accent: hsl(0 0% 15.9%);
--sidebar-accent-foreground: hsl(240 4.8% 95.9%);
--sidebar-border: hsl(0 0% 15.9%);
--sidebar-ring: hsl(217.2 91.2% 59.8%);
--sidebar: hsl(240 5.9% 10%);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
+2
View File
@@ -0,0 +1,2 @@
.font-serif
font-family: 'Playfair Display', serif
+5 -2
View File
@@ -1,4 +1,4 @@
import '../css/app.css';
import '../css/app.sass';
import { createInertiaApp } from '@inertiajs/vue3';
import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
@@ -6,11 +6,13 @@ import { createApp, h } from 'vue';
import type {DefineComponent} from 'vue';
import MainLayout from '@/layouts/MainLayout.vue';
import vuetify from '@/plugins/vuetify';
import { ZiggyVue } from 'ziggy-js'
import '@mdi/font/css/materialdesignicons.css';
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
createInertiaApp({
title: (title) => (title ? `${title} - ${appName}` : appName),
title: (title) => (title ? `${title} | ${appName}` : appName),
resolve: (name) => {
const page = resolvePageComponent(
@@ -28,6 +30,7 @@ createInertiaApp({
return createApp({ render: () => h(App, props) })
.use(plugin)
.use(vuetify)
.use(ZiggyVue)
.mount(el);
},
+175
View File
@@ -0,0 +1,175 @@
<script setup lang="ts">
interface Story {
source: string;
time: string | null;
headline: string;
deck?: string | null;
image?: string | null;
link: string;
}
withDefaults(
defineProps<{
story: Story;
variant?: 'hero' | 'banner' | 'feature' | 'standard' | 'brief';
}>(),
{
variant: 'standard',
},
);
</script>
<template>
<a
:href="story.link"
target="_blank"
rel="noopener noreferrer"
class="card"
:class="[
`card--${variant}`,
{ 'card--no-image': variant !== 'brief' && !story.image },
]"
>
<div v-if="variant !== 'brief' && story.image" class="card-img">
<img :src="story.image" :alt="story.headline" loading="lazy" />
</div>
<div class="card-body">
<div class="card-source">{{ story.source }}</div>
<div class="card-headline">{{ story.headline }}</div>
<div v-if="variant === 'hero' && story.deck" class="card-deck">
{{ story.deck }}
</div>
<div v-if="story.time" class="card-time">{{ story.time }}</div>
</div>
</a>
</template>
<style scoped lang="sass">
.card
display: flex
flex-direction: column
text-decoration: none
background: #F7F7F5
&:hover
background: #fff
.card-img
width: 100%
background: #DDDDD8
overflow: hidden
flex-shrink: 0
img
width: 100%
height: 100%
object-fit: cover
display: block
.card-body
padding: 0.85rem
display: flex
flex-direction: column
gap: 5px
flex: 1
.card-source
font-size: 10px
font-weight: 600
letter-spacing: 1px
text-transform: uppercase
color: #C0392B
.card-headline
font-family: Georgia, serif
color: #111
line-height: 1.3
.card-deck
font-size: 13px
color: #555
line-height: 1.55
.card-time
font-size: 10px
color: #999
margin-top: auto
padding-top: 4px
// Hero: the big lead story
.card--hero
.card-img
height: 400px
.card-headline
font-size: 26px
font-weight: 700
// Banner: full-width lead when the section has no true hero,
// needs a taller image than feature since it's stretched much wider
.card--banner
.card-img
height: 540px
.card-headline
font-size: 20px
font-weight: 700
// Feature: supporting stories with a medium image
.card--feature
.card-img
height: 260px
.card-headline
font-size: 17px
font-weight: 700
// Standard: small image, compact headline
.card--standard
.card-img
height: 150px
.card-headline
font-size: 13px
font-weight: 700
// No image available: give the text more breathing room instead of
// leaving an empty gray box where a photo would have been
.card--no-image
.card-body
padding: 1rem 0.85rem
justify-content: center
// Brief: text only, single row, no image at all
.card--brief
flex-direction: row
align-items: baseline
justify-content: space-between
gap: 12px
background: transparent
padding: 0.55rem 0
border-bottom: 1px solid #E8E8E8
&:hover
background: transparent
.card-headline
text-decoration: underline
.card-body
padding: 0
flex-direction: row
align-items: baseline
gap: 8px
flex: 1
.card-source
display: none
.card-headline
font-size: 13px
font-weight: 400
flex: 1
.card-time
font-size: 10px
color: #999
padding-top: 0
white-space: nowrap
</style>
+64
View File
@@ -0,0 +1,64 @@
<script setup lang="ts">
defineProps<{
eyebrow?: string;
title: string;
subtitle: string;
}>();
</script>
<template>
<div class="auth-wrap">
<div class="auth-card">
<div v-if="eyebrow" class="auth-eyebrow">{{ eyebrow }}</div>
<h1 class="auth-title">{{ title }}</h1>
<p class="auth-sub">{{ subtitle }}</p>
<slot name="alert" />
<slot />
<div class="auth-rule" />
<p class="auth-footer">
<slot name="footer" />
</p>
</div>
</div>
</template>
<style scoped lang="sass">
.auth-wrap
display: flex
justify-content: center
padding: 2.5rem 1rem
.auth-card
width: 100%
max-width: 400px
background: #fff
border: 1px solid #E8E8E8
padding: 2.5rem 2.25rem
.auth-eyebrow
font-family: 'Inter', sans-serif
font-size: 11px
font-weight: 500
letter-spacing: 0.08em
text-transform: uppercase
color: #C0392B
margin-bottom: 0.5rem
.auth-title
font-family: 'Playfair Display', serif
font-weight: 700
font-size: 30px
color: #111
margin: 0 0 0.4rem
.auth-sub
font-family: 'Inter', sans-serif
font-size: 13px
color: #666
margin: 0 0 1.75rem
line-height: 1.5
</style>
@@ -0,0 +1,26 @@
<script setup lang="ts">
defineProps<{
id: string;
label: string;
type?: string;
autocomplete?: string;
modelValue: string;
error?: string;
}>();
defineEmits<{ 'update:modelValue': [value: string] }>();
</script>
<template>
<label class="field-label" :for="id">{{ label }}</label>
<VTextField
:id="id"
:model-value="modelValue"
@update:model-value="$emit('update:modelValue', $event)"
:type="type ?? 'text'"
variant="outlined"
density="comfortable"
:autocomplete="autocomplete"
:error-messages="error"
class="field"
/>
</template>
+116
View File
@@ -0,0 +1,116 @@
<script setup lang="ts">
import { usePage } from '@/composables/usePage';
const page = usePage();
const navCategories = page.props.navigation_categories;
</script>
<template>
<VAppBar
v-if="page.props.auth.user"
flat
:height="37"
color="surface"
style="top: 56px"
>
<div class="red-rule" />
<div class="subbar">
<template v-for="category in navCategories" :key="category.id">
<a
v-if="!category.children?.length"
href="#"
class="subbar-link"
>
{{ category.title }}
</a>
<VMenu v-else open-on-hover location="bottom start" :offset="2">
<template #activator="{ props: menuProps }">
<a
href="#"
class="subbar-link"
v-bind="menuProps"
@click.prevent
>
{{ category.title }}
</a>
</template>
<VList class="submenu" density="compact" elevation="4">
<VListItem
v-for="child in category.children"
:key="child.id"
:title="child.title"
href="#"
class="submenu-item"
/>
</VList>
</VMenu>
</template>
</div>
</VAppBar>
</template>
<style scoped lang="sass">
.red-rule
height: 3px
background: #C0392B
width: 100%
position: absolute
top: 0
left: 0
z-index: 1
.subbar
width: 100%
background: #fff
border-bottom: 1px solid #E8E8E8
display: flex
overflow-x: auto
padding: 0 1.5rem
position: absolute
top: 3px
left: 0
&::-webkit-scrollbar
display: none
.subbar-link
font-family: 'Inter', sans-serif
font-size: 12px
color: #666
text-decoration: none
padding: 8px 12px
white-space: nowrap
border-bottom: 2px solid transparent
flex-shrink: 0
cursor: pointer
&:hover
color: #111
&.active
color: #111
border-bottom-color: #111
.submenu
min-width: 160px
max-width: 240px
border: 1px solid #E8E8E8
padding: 4px 0
background: #fff
.submenu-item
font-family: 'Inter', sans-serif
font-size: 13px
color: #444
min-height: 32px
padding: 0 14px
:deep(.v-list-item-title)
font-size: 13px
&:hover
background: #F7F7F5
color: #111
</style>
+181
View File
@@ -0,0 +1,181 @@
<script setup lang="ts">
import ArticleCard from './ArticleCard.vue';
interface Story {
source: string;
time: string | null;
headline: string;
deck?: string | null;
image?: string | null;
link: string;
}
interface Category {
label: string;
hero: boolean;
items: Story[];
}
const props = withDefaults(
defineProps<{
category: Category;
index?: number;
}>(),
{
index: 0,
},
);
type Variant = 'hero' | 'banner' | 'feature' | 'standard' | 'brief';
interface Slot {
variant: Variant;
span: number;
}
// A hero category always gets the same big-lead treatment. Non-hero
// categories rotate through a few distinct shapes by position, so
// adjacent sections don't all read as the same grid: one leans on two
// medium photos, one leans on a single wide banner, one is mostly
// small thumbnails with more falling through to the text-only list.
const nonHeroLayouts: Slot[][] = [
// gallery: two medium features side by side, three small below
[
{ variant: 'feature', span: 3 },
{ variant: 'feature', span: 3 },
{ variant: 'standard', span: 2 },
{ variant: 'standard', span: 2 },
{ variant: 'standard', span: 2 },
],
// lead: one wide banner feature, three small below
[
{ variant: 'banner', span: 6 },
{ variant: 'standard', span: 2 },
{ variant: 'standard', span: 2 },
{ variant: 'standard', span: 2 },
],
// digest: small thumbnails only, text-heavy section
[
{ variant: 'standard', span: 2 },
{ variant: 'standard', span: 2 },
{ variant: 'standard', span: 2 },
],
];
const heroLayout: Slot[] = [
{ variant: 'hero', span: 6 },
{ variant: 'feature', span: 3 },
{ variant: 'feature', span: 3 },
{ variant: 'standard', span: 2 },
{ variant: 'standard', span: 2 },
{ variant: 'standard', span: 2 },
];
const slots = props.category.hero
? heroLayout
: nonHeroLayouts[props.index % nonHeroLayouts.length];
const gridItems = props.category.items
.slice(0, slots.length)
.map((story, i) => ({
story,
variant: slots[i].variant,
span: slots[i].span,
}));
const briefItems = props.category.items.slice(slots.length);
</script>
<template>
<section class="category">
<div class="category-header">
<span class="category-label">{{ category.label }}</span>
<a href="#" class="category-more">More</a>
</div>
<div v-if="category.items.length === 0" class="category-empty">
No stories yet. Feeds may still be fetching.
</div>
<template v-else>
<div class="section-grid">
<ArticleCard
v-for="entry in gridItems"
:key="entry.story.link"
:story="entry.story"
:variant="entry.variant"
:style="{ gridColumn: `span ${entry.span}` }"
/>
</div>
<div v-if="briefItems.length" class="brief-section">
<div class="brief-label">In brief</div>
<div class="brief-grid">
<ArticleCard
v-for="item in briefItems"
:key="item.link"
:story="item"
variant="brief"
/>
</div>
</div>
</template>
</section>
</template>
<style scoped lang="sass">
.category
border-bottom: 1px solid #E8E8E8
padding: 1.5rem 0
&:last-child
border-bottom: none
.category-header
display: flex
align-items: baseline
justify-content: space-between
margin-bottom: 1rem
.category-label
font-size: 10px
font-weight: 600
letter-spacing: 2px
text-transform: uppercase
color: #111
.category-more
font-size: 12px
color: #C0392B
text-decoration: none
&:hover
text-decoration: underline
.category-empty
font-size: 12px
color: #999
padding: 1rem 0
.section-grid
display: grid
grid-template-columns: repeat(6, 1fr)
gap: 1px
background: #E8E8E8
.brief-section
margin-top: 4px
.brief-label
font-size: 10px
font-weight: 600
letter-spacing: 1px
text-transform: uppercase
color: #999
padding: 0.9rem 0 0.3rem
.brief-grid
display: grid
grid-template-columns: repeat(2, 1fr)
column-gap: 2rem
</style>
+17
View File
@@ -0,0 +1,17 @@
<script setup lang="ts">
withDefaults(
defineProps<{
text: string;
color?: string;
}>(),
{
color: 'primary',
},
);
</script>
<template>
<p class="text-overline mb-1" :class="`text-${color}`">
{{ text }}
</p>
</template>
+12 -3
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
defineProps<{
light?: boolean;
small?: boolean;
@@ -6,14 +8,21 @@ defineProps<{
</script>
<template>
<span :class="['masthead', small && 'masthead-sm', light && 'masthead-light', !light && 'masthead-dark']">
<Link style="text-decoration: none;" :href="route('home')">
<span
:class="[
'masthead',
small && 'masthead-sm',
light && 'masthead-light',
!light && 'masthead-dark',
]"
>
Dredge<span class="masthead-accent">News</span>
</span>
</Link>
</template>
<style scoped lang="sass">
@import url('https://fonts.googleapis.com/css2?family=Playfair+Display:wght@700&display=swap')
.masthead
font-family: 'Playfair Display', serif
font-weight: 700
+36
View File
@@ -0,0 +1,36 @@
<script setup lang="ts">
withDefaults(
defineProps<{
maxWidth?: string | number;
centered?: boolean;
}>(),
{
maxWidth: 480,
centered: true,
},
);
</script>
<template>
<VCard
variant="outlined"
class="pa-6"
:class="{ 'mx-auto': centered }"
:style="{
maxWidth: typeof maxWidth === 'number' ? `${maxWidth}px` : maxWidth,
}"
>
<div v-if="$slots.header" class="mb-6">
<slot name="header" />
</div>
<slot />
<template v-if="$slots.footer">
<VDivider class="my-6" />
<div class="text-body-2 text-medium-emphasis text-center">
<slot name="footer" />
</div>
</template>
</VCard>
</template>
@@ -0,0 +1,28 @@
<script setup lang="ts">
import Eyebrow from '@/components/Eyebrow.vue';
withDefaults(
defineProps<{
title: string;
subtitle?: string;
eyebrow?: string;
eyebrowColor?: string;
titleClass?: string;
}>(),
{
titleClass: 'text-h4',
},
);
</script>
<template>
<div>
<Eyebrow v-if="eyebrow" :text="eyebrow" :color="eyebrowColor" />
<h1 class="font-weight-bold mb-1 font-serif" :class="titleClass">
{{ title }}
</h1>
<p v-if="subtitle" class="text-body-2 text-medium-emphasis mb-0">
{{ subtitle }}
</p>
</div>
</template>
@@ -0,0 +1,193 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { route } from 'ziggy-js';
import { watch } from 'vue';
import type { Category, Feed, FeedType } from '@/types/types';
const props = defineProps<{
modelValue: boolean;
category?: Category | null;
parentOptions: { title: string; value: number }[];
defaultParentId?: number | null;
}>();
const emit = defineEmits<{
'update:modelValue': [value: boolean];
}>();
const feedTypeOptions: { title: string; value: FeedType }[] = [
{ title: 'RSS', value: 'rss' },
];
type FeedInput = { url: string; type: FeedType; paywall: boolean };
const emptyFeed = (): FeedInput => ({ url: '', type: 'rss', paywall: false });
const form = useForm({
title: '',
description: '',
parent_id: null as number | null,
feeds: [emptyFeed()] as FeedInput[],
});
watch(
() => props.modelValue,
(open) => {
if (!open) return;
if (props.category) {
form.title = props.category.title;
form.description = props.category.description ?? '';
form.parent_id = props.category.parent_id;
form.feeds = props.category.feeds.length
? props.category.feeds.map((f: Feed) => ({
url: f.url,
type: f.type,
paywall: f.paywall,
}))
: [emptyFeed()];
} else {
form.reset();
form.parent_id = props.defaultParentId ?? null;
form.feeds = [emptyFeed()];
}
},
);
const addFeed = () => form.feeds.push(emptyFeed());
const removeFeed = (index: number) => form.feeds.splice(index, 1);
const close = () => emit('update:modelValue', false);
const submit = () => {
const payload = {
...form.data(),
feeds: form.feeds.filter((feed) => feed.url.trim() !== ''),
};
const options = {
onSuccess: () => close(),
preserveScroll: true,
};
if (props.category) {
form.transform(() => payload).put(
route('categories.update', props.category.id),
options,
);
} else {
form.transform(() => payload).post(route('categories.store'), options);
}
};
</script>
<template>
<VDialog
:model-value="modelValue"
max-width="520"
@update:model-value="emit('update:modelValue', $event)"
>
<VCard class="pa-6">
<h2 class="text-h5 font-weight-bold mb-4">
{{ category ? 'Edit category' : 'New category' }}
</h2>
<VForm @submit.prevent="submit">
<VTextField
v-model="form.title"
label="Title"
autofocus
:error-messages="form.errors.title"
class="mb-4"
/>
<VTextarea
v-model="form.description"
label="Description"
rows="2"
:error-messages="form.errors.description"
class="mb-4"
/>
<VSelect
v-model="form.parent_id"
:items="parentOptions"
item-title="title"
item-value="value"
label="Parent category (optional)"
clearable
:error-messages="form.errors.parent_id"
class="mb-2"
/>
<p class="text-caption text-medium-emphasis mb-2">Feeds</p>
<div
v-for="(feed, index) in form.feeds"
:key="index"
class="pa-3 mb-3 rounded border"
>
<VTextField
v-model="feed.url"
label="URL"
placeholder="https://example.com/feed.xml"
density="compact"
:error-messages="form.errors[`feeds.${index}.url`]"
class="mb-2"
/>
<div class="d-flex align-center ga-3">
<VSelect
v-model="feed.type"
:items="feedTypeOptions"
item-title="title"
item-value="value"
label="Type"
density="compact"
hide-details
style="max-width: 140px"
/>
<VCheckbox
v-model="feed.paywall"
label="Paywalled"
density="compact"
hide-details
/>
<VSpacer />
<VBtn
icon="mdi-close"
size="small"
variant="text"
:disabled="form.feeds.length === 1"
@click="removeFeed(index)"
/>
</div>
</div>
<VBtn
variant="text"
size="small"
color="primary"
class="mb-6"
@click="addFeed"
>
Add another feed
</VBtn>
<div class="d-flex ga-2 justify-end">
<VBtn variant="text" @click="close">Cancel</VBtn>
<VBtn
type="submit"
color="primary"
:loading="form.processing"
>
{{ category ? 'Save changes' : 'Create category' }}
</VBtn>
</div>
</VForm>
</VCard>
</VDialog>
</template>
+12
View File
@@ -0,0 +1,12 @@
import { usePage as useInertiaPage } from '@inertiajs/vue3';
import type { SharedData } from '@/types/types';
/**
* Typed drop-in replacement for Inertia's usePage().
* Usage: import { usePage } from '@/composables/usePage';
* const page = usePage();
* page.props.auth.user?.name // fully typed
*/
export function usePage() {
return useInertiaPage<SharedData>();
}
+62 -89
View File
@@ -1,35 +1,22 @@
<script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import { Head, router } from '@inertiajs/vue3';
import { ref } from 'vue';
import { useDisplay } from 'vuetify';
import Masthead from '@/components/Masthead.vue';
import { usePage } from '@/composables/usePage';
import CategoryNavBar from '@/components/CategoryNavBar.vue';
import { route } from 'ziggy-js';
defineProps<{
title?: string;
}>();
const page = usePage().props;
const { mobile } = useDisplay();
const drawer = ref(false);
const navItems = [
{ label: 'Top Stories', href: '/' },
{ label: 'World', href: '/world' },
{ label: 'Technology', href: '/technology' },
{ label: 'Business', href: '/business' },
{ label: 'Science', href: '/science' },
];
const subItems = [
'Headlines',
'Australia',
'UK',
'USA',
'Politics',
'Climate',
'Health',
'Sport',
'Arts',
];
const logout = () => router.post(route('logout'));
</script>
<template>
@@ -44,13 +31,43 @@ const subItems = [
<!-- Desktop nav -->
<template v-if="!mobile">
<div class="d-flex ga-2 mr-4">
<VBtn variant="text" href="/login" class="nav-btn">
<div class="d-flex align-center ga-2 mr-4">
<VBtn
v-if="!page.auth.user"
variant="text"
:href="route('login')"
class="nav-btn"
>
Login
</VBtn>
<VBtn variant="outlined" href="/register" class="nav-btn">
<VBtn
v-if="!page.auth.user"
variant="outlined"
:href="route('register')"
class="nav-btn"
>
Register
</VBtn>
<div style="display: flex; align-items: center">
<span v-if="page.auth.user"
>Hello {{ page.auth.user.name }}!</span
>
</div>
<VBtn
v-if="page.auth.user"
icon="mdi-cog"
variant="text"
:href="route('settings.index')"
aria-label="Settings"
/>
<VBtn
v-if="page.auth.user"
variant="outlined"
class="nav-btn"
@click="logout"
>
Log Out
</VBtn>
</div>
</template>
@@ -58,24 +75,30 @@ const subItems = [
<VAppBarNavIcon v-else @click="drawer = !drawer" />
</VAppBar>
<VAppBar flat :height="37" color="surface" style="top: 56px">
<div class="red-rule" />
<div class="subbar">
<a
v-for="item in subItems"
:key="item"
href="#"
class="subbar-link"
>
{{ item }}
</a>
</div>
</VAppBar>
<CategoryNavBar />
<VNavigationDrawer v-model="drawer" temporary>
<VList>
<VListItem href="/login" title="Login" />
<VListItem href="/register" title="Register" />
<VListItem
v-if="!page.auth.user"
:href="route('login')"
title="Login"
/>
<VListItem
v-if="!page.auth.user"
:href="route('register')"
title="Register"
/>
<VListItem
v-if="page.auth.user"
:href="route('settings.index')"
title="Settings"
/>
<VListItem
v-if="page.auth.user"
title="Log Out"
@click="logout"
/>
</VList>
</VNavigationDrawer>
@@ -90,16 +113,7 @@ const subItems = [
<VFooter app color="#111111" class="footer">
<Masthead small light />
<VSpacer />
<nav class="d-flex ga-4">
<a
v-for="link in ['About', 'Donate']"
:key="link"
:href="`/${link.toLowerCase()}`"
class="footer-link"
>
{{ link }}
</a>
</nav>
<nav class="d-flex ga-4"></nav>
<v-spacer />
<span class="footer-copy"
>© {{ new Date().getFullYear() }} DredgeNews</span
@@ -109,7 +123,6 @@ const subItems = [
</template>
<style scoped lang="sass">
@import url('https://fonts.googleapis.com/css2?family=Playfair+Display:wght@700&family=Inter:wght@400;500&display=swap')
.nav-btn
font-family: 'Inter', sans-serif
@@ -139,46 +152,6 @@ const subItems = [
color: #111
border-bottom-color: #C0392B
.red-rule
height: 3px
background: #C0392B
width: 100%
position: absolute
top: 0
left: 0
z-index: 1
.subbar
width: 100%
background: #fff
border-bottom: 1px solid #E8E8E8
display: flex
overflow-x: auto
padding: 0 1.5rem
position: absolute
top: 3px
left: 0
&::-webkit-scrollbar
display: none
.subbar-link
font-family: 'Inter', sans-serif
font-size: 12px
color: #666
text-decoration: none
padding: 8px 12px
white-space: nowrap
border-bottom: 2px solid transparent
flex-shrink: 0
&:hover
color: #111
&.active
color: #111
border-bottom-color: #111
.content-well
max-width: 1080px
+99
View File
@@ -0,0 +1,99 @@
<script setup lang="ts">
import { Head, useForm, Link } from '@inertiajs/vue3';
import { route } from 'ziggy-js';
import MainLayout from '@/layouts/MainLayout.vue';
import PageCard from '@/components/PageCard.vue';
import SectionHeading from '@/components/SectionHeading.vue';
defineOptions({ layout: MainLayout });
defineProps<{
status?: string;
}>();
const form = useForm({
email: '',
password: '',
remember: false,
});
const submit = () => {
form.post(route('login.store'), {
onFinish: () => form.reset('password'),
});
};
</script>
<template>
<Head title="Login" />
<VContainer class="d-flex justify-center py-8">
<PageCard max-width="400">
<template #header>
<SectionHeading
title="Sign in"
subtitle="Welcome back. Enter your details to continue reading."
/>
</template>
<VAlert
v-if="status"
type="success"
variant="tonal"
density="compact"
class="mb-4"
>
{{ status }}
</VAlert>
<VForm @submit.prevent="submit">
<VTextField
v-model="form.email"
label="Email address"
type="email"
autofocus
autocomplete="username"
:error-messages="form.errors.email"
class="mb-4"
/>
<VTextField
v-model="form.password"
label="Password"
type="password"
autocomplete="current-password"
:error-messages="form.errors.password"
class="mb-1"
/>
<VCheckbox
v-model="form.remember"
label="Keep me signed in"
density="compact"
hide-details
class="mb-4"
/>
<VBtn
type="submit"
block
size="large"
color="primary"
:loading="form.processing"
>
Sign in
</VBtn>
</VForm>
<template #footer>
New to DredgeNews?
<Link
:href="route('register')"
class="text-primary font-weight-medium"
>
Create an account
</Link>
</template>
</PageCard>
</VContainer>
</template>
+95
View File
@@ -0,0 +1,95 @@
<script setup lang="ts">
import { Head, useForm, Link } from '@inertiajs/vue3';
import { route } from 'ziggy-js';
import MainLayout from '@/layouts/MainLayout.vue';
import PageCard from '@/components/PageCard.vue';
import SectionHeading from '@/components/SectionHeading.vue';
defineOptions({ layout: MainLayout });
const form = useForm({
name: '',
email: '',
password: '',
password_confirmation: '',
});
const submit = () => {
form.post(route('register.store'), {
onFinish: () => form.reset('password', 'password_confirmation'),
});
};
</script>
<template>
<Head title="Register" />
<VContainer class="d-flex justify-center py-8">
<PageCard max-width="400">
<template #header>
<SectionHeading
eyebrow="Join DredgeNews"
title="Create your account"
subtitle="Unlimited stories, straight reporting, no noise."
/>
</template>
<VForm @submit.prevent="submit">
<VTextField
v-model="form.name"
label="Full name"
autofocus
autocomplete="name"
:error-messages="form.errors.name"
class="mb-4"
/>
<VTextField
v-model="form.email"
label="Email address"
type="email"
autocomplete="username"
:error-messages="form.errors.email"
class="mb-4"
/>
<VTextField
v-model="form.password"
label="Password"
type="password"
autocomplete="new-password"
:error-messages="form.errors.password"
class="mb-4"
/>
<VTextField
v-model="form.password_confirmation"
label="Confirm password"
type="password"
autocomplete="new-password"
:error-messages="form.errors.password_confirmation"
class="mb-4"
/>
<VBtn
type="submit"
block
size="large"
color="primary"
:loading="form.processing"
>
Create account
</VBtn>
</VForm>
<template #footer>
Already have an account?
<Link
:href="route('login')"
class="text-primary font-weight-medium"
>Sign in</Link
>
</template>
</PageCard>
</VContainer>
</template>
+14 -252
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import MainLayout from '@/layouts/MainLayout.vue';
import CategorySection from '@/components/CategorySection.vue';
defineOptions({
layout: MainLayout,
@@ -7,9 +8,11 @@ defineOptions({
interface Story {
source: string;
time: string;
time: string | null;
headline: string;
deck?: string;
deck?: string | null;
image?: string | null;
link: string;
}
interface Category {
@@ -18,152 +21,19 @@ interface Category {
items: Story[];
}
const categories: Category[] = [
{
label: 'Headlines',
hero: true,
items: [
{ source: 'Reuters', time: '42 minutes ago', headline: 'Leaders gather in Brussels as ceasefire talks enter critical third day', deck: 'Negotiators signalled cautious progress overnight, with both delegations agreeing to extend the informal moratorium on strikes.' },
{ source: 'Financial Times', time: '1 hour ago', headline: 'Markets edge higher on softer-than-expected inflation print', deck: 'Central banks signal caution as core inflation falls for the second consecutive month across G7 economies.' },
{ source: 'BBC News', time: '30 minutes ago', headline: 'Record heat across southern Europe for third week running' },
{ source: 'AP News', time: '55 minutes ago', headline: 'Congress reaches last-minute deal to avert government shutdown' },
{ source: 'The Guardian', time: '1 hour ago', headline: 'Tech giants face fresh scrutiny over data sharing practices' },
],
},
{
label: 'Australia',
hero: false,
items: [
{ source: 'ABC News', time: '8 minutes ago', headline: 'Treasurer signals pre-election budget revision in August' },
{ source: 'Sydney Morning Herald', time: '1 hour ago', headline: 'East coast housing approvals fall for third consecutive quarter' },
{ source: 'The Australian', time: '3 hours ago', headline: 'Queensland floods ease but thousands remain displaced' },
],
},
{
label: 'UK',
hero: false,
items: [
{ source: 'The Guardian', time: '20 minutes ago', headline: 'Prime Minister faces backbench revolt over public sector pay offer' },
{ source: 'BBC News', time: '1 hour ago', headline: 'London rents rise for twelfth consecutive month as supply tightens' },
{ source: 'The Times', time: '2 hours ago', headline: 'NHS waiting lists fall slightly but remain at historic highs' },
],
},
{
label: 'USA',
hero: false,
items: [
{ source: 'AP News', time: '15 minutes ago', headline: 'Congress reaches last-minute deal to avert government shutdown' },
{ source: 'Washington Post', time: '50 minutes ago', headline: 'Federal Reserve signals caution on further rate adjustments' },
{ source: 'New York Times', time: '2 hours ago', headline: 'Midwest storms leave hundreds of thousands without power' },
],
},
{
label: 'Politics',
hero: false,
items: [
{ source: 'Politico', time: '18 minutes ago', headline: 'Senate committee advances sweeping electoral reform bill' },
{ source: 'The Hill', time: '55 minutes ago', headline: 'Opposition leader calls for early election amid polling slump' },
{ source: 'AP News', time: '2 hours ago', headline: 'Foreign ministers meet in Geneva over disputed border region' },
],
},
{
label: 'Climate',
hero: false,
items: [
{ source: 'BBC News', time: '30 minutes ago', headline: 'Record heat across southern Europe for third week running' },
{ source: 'The Guardian', time: '1 hour ago', headline: 'Arctic sea ice extent hits lowest July measurement on record' },
{ source: 'Nature', time: '4 hours ago', headline: 'New modelling suggests 2°C target may be breached by 2031' },
],
},
{
label: 'Health',
hero: false,
items: [
{ source: 'Reuters', time: '25 minutes ago', headline: 'WHO recommends updated booster schedule ahead of winter' },
{ source: 'New Scientist', time: '2 hours ago', headline: "Trial drug shows promise in slowing early-onset Alzheimer's" },
{ source: 'The Lancet', time: '5 hours ago', headline: 'Childhood obesity rates plateau in high-income countries for first time' },
],
},
{
label: 'Sport',
hero: false,
items: [
{ source: 'The Guardian', time: '10 minutes ago', headline: 'Australian side advances after dramatic penalty shootout' },
{ source: 'ESPN', time: '1 hour ago', headline: 'Transfer window: three clubs circle record-breaking midfielder' },
{ source: 'Reuters', time: '3 hours ago', headline: 'Athletics world championships draw record television audience' },
],
},
{
label: 'Arts',
hero: false,
items: [
{ source: 'The New Yorker', time: '40 minutes ago', headline: 'Booker Prize longlist announced, debut novelists dominate' },
{ source: 'Pitchfork', time: '2 hours ago', headline: 'Acclaimed composer releases first album in seven years' },
{ source: 'Artforum', time: '6 hours ago', headline: 'Venice Biennale attendance surpasses pre-pandemic figures' },
],
},
];
defineProps<{
categories: Category[];
}>();
</script>
<template>
<div class="feed">
<section v-for="category in categories" :key="category.label" class="category">
<div class="category-header">
<span class="category-label">{{ category.label }}</span>
<a href="#" class="category-more">More</a>
</div>
<!-- Hero layout for flagged categories -->
<template v-if="category.hero">
<div class="hero-grid">
<a v-for="item in category.items.slice(0, 2)"
:key="item.headline"
href="#"
class="hero-story"
>
<div class="story-img story-img--hero" />
<div class="story-body">
<div class="story-source">{{ item.source }}</div>
<div class="story-headline story-headline--hero">{{ item.headline }}</div>
<div v-if="item.deck" class="story-deck">{{ item.deck }}</div>
<div class="story-time">{{ item.time }}</div>
</div>
</a>
</div>
<div class="story-grid">
<a v-for="item in category.items.slice(2)"
:key="item.headline"
href="#"
class="story"
>
<div class="story-img" />
<div class="story-body">
<div class="story-source">{{ item.source }}</div>
<div class="story-headline">{{ item.headline }}</div>
<div class="story-time">{{ item.time }}</div>
</div>
</a>
</div>
</template>
<!-- Standard 3-column grid -->
<div v-else class="story-grid">
<a v-for="item in category.items"
:key="item.headline"
href="#"
class="story"
>
<div class="story-img" />
<div class="story-body">
<div class="story-source">{{ item.source }}</div>
<div class="story-headline">{{ item.headline }}</div>
<div class="story-time">{{ item.time }}</div>
</div>
</a>
</div>
</section>
<CategorySection
v-for="(category, index) in categories"
:key="category.label"
:category="category"
:index="index"
/>
</div>
</template>
@@ -171,112 +41,4 @@ const categories: Category[] = [
.feed
display: flex
flex-direction: column
.category
border-bottom: 1px solid #E8E8E8
padding: 1.5rem 0
&:last-child
border-bottom: none
.category-header
display: flex
align-items: baseline
justify-content: space-between
margin-bottom: 1rem
.category-label
font-size: 10px
font-weight: 600
letter-spacing: 2px
text-transform: uppercase
color: #111
.category-more
font-size: 12px
color: #C0392B
text-decoration: none
&:hover
text-decoration: underline
// Hero grid (2 columns)
.hero-grid
display: grid
grid-template-columns: 1fr 1fr
gap: 1em
background: #E8E8E8
margin-bottom: 1px
.hero-story
background: #F7F7F5
text-decoration: none
display: flex
flex-direction: column
&:hover
background: #fff
// Standard story grid (3 columns)
.story-grid
display: grid
grid-template-columns: repeat(3, 1fr)
gap: 1px
background: #E8E8E8
.story
background: #F7F7F5
text-decoration: none
display: flex
flex-direction: column
&:hover
background: #fff
// Image placeholders
.story-img
width: 100%
height: 110px
background: #DDDDD8
flex-shrink: 0
&--hero
height: 200px
// Story body
.story-body
padding: 0.85rem
display: flex
flex-direction: column
gap: 5px
flex: 1
.story-source
font-size: 10px
font-weight: 600
letter-spacing: 1px
text-transform: uppercase
color: #C0392B
.story-headline
font-family: Georgia, serif
font-size: 13px
font-weight: 700
color: #111
line-height: 1.35
&--hero
font-size: 18px
line-height: 1.25
.story-deck
font-size: 12px
color: #555
line-height: 1.5
.story-time
font-size: 10px
color: #999
margin-top: auto
padding-top: 4px
</style>
+198
View File
@@ -0,0 +1,198 @@
<script setup lang="ts">
import { Head, router } from '@inertiajs/vue3';
import { route } from 'ziggy-js';
import { computed, ref } from 'vue';
import MainLayout from '@/layouts/MainLayout.vue';
import SectionHeading from '@/components/SectionHeading.vue';
import CategoryDialog from '@/components/Settings/Categories/CategoryDialog.vue';
import type { Category, Feed } from '@/types/types';
defineOptions({ layout: MainLayout });
const props = defineProps<{
categories: Category[];
}>();
const dialogOpen = ref(false);
const editingCategory = ref<Category | null>(null);
const dialogDefaultParentId = ref<number | null>(null);
const parentOptions = computed(() =>
props.categories.map((c) => ({ title: c.title, value: c.id })),
);
const openCreate = (parentId: number | null = null) => {
editingCategory.value = null;
dialogDefaultParentId.value = parentId;
dialogOpen.value = true;
};
const openEdit = (category: Category) => {
editingCategory.value = category;
dialogOpen.value = true;
};
const destroy = (category: Category) => {
if (
!confirm(
`Delete "${category.title}"? This also deletes its subcategories and feeds.`,
)
)
return;
router.delete(route('categories.destroy', category.id), {
preserveScroll: true,
});
};
const feedLabel = (feed: Feed) => feed.type.toUpperCase();
</script>
<template>
<Head title="Feeds" />
<VContainer class="py-8" style="max-width: 720px">
<div class="d-flex align-center justify-space-between mb-6">
<SectionHeading
eyebrow="Settings"
title="Feed categories"
subtitle="Organize the RSS feeds you follow into categories and subcategories."
/>
<VBtn color="primary" @click="openCreate()">New category</VBtn>
</div>
<p v-if="!categories.length" class="text-body-2 text-medium-emphasis">
No categories yet. Create one to start adding feeds.
</p>
<VCard
v-for="category in categories"
:key="category.id"
variant="outlined"
class="mb-4"
>
<VCardText class="pa-4">
<div class="d-flex align-start justify-space-between">
<div>
<h3 class="text-h6 font-weight-bold mb-1">
{{ category.title }}
</h3>
<p
v-if="category.description"
class="text-body-2 text-medium-emphasis mb-2"
>
{{ category.description }}
</p>
<div
v-for="feed in category.feeds"
:key="feed.id"
class="d-flex align-center ga-2 mb-1"
>
<span class="text-caption text-medium-emphasis">{{
feed.url
}}</span>
<VChip size="x-small" variant="outlined">{{
feedLabel(feed)
}}</VChip>
<VChip
v-if="feed.paywall"
size="x-small"
color="warning"
variant="outlined"
>
Paywalled
</VChip>
</div>
</div>
<div class="d-flex ga-1">
<VBtn
icon="mdi-pencil"
size="small"
variant="text"
@click="openEdit(category)"
/>
<VBtn
icon="mdi-delete"
size="small"
variant="text"
@click="destroy(category)"
/>
</div>
</div>
<VDivider v-if="category.children?.length" class="my-4" />
<div
v-for="child in category.children"
:key="child.id"
class="mb-3 ml-4"
>
<div class="d-flex align-start justify-space-between">
<div>
<h4 class="text-subtitle-1 font-weight-medium mb-1">
{{ child.title }}
</h4>
<p
v-if="child.description"
class="text-body-2 text-medium-emphasis mb-2"
>
{{ child.description }}
</p>
<div
v-for="feed in child.feeds"
:key="feed.id"
class="d-flex align-center ga-2 mb-1"
>
<span
class="text-caption text-medium-emphasis"
>{{ feed.url }}</span
>
<VChip size="x-small" variant="outlined">{{
feedLabel(feed)
}}</VChip>
<VChip
v-if="feed.paywall"
size="x-small"
color="warning"
variant="outlined"
>
Paywalled
</VChip>
</div>
</div>
<div class="d-flex ga-1">
<VBtn
icon="mdi-pencil"
size="small"
variant="text"
@click="openEdit(child)"
/>
<VBtn
icon="mdi-delete"
size="small"
variant="text"
@click="destroy(child)"
/>
</div>
</div>
</div>
<VBtn
variant="text"
size="small"
color="primary"
class="ml-4"
@click="openCreate(category.id)"
>
Add subcategory
</VBtn>
</VCardText>
</VCard>
<CategoryDialog
v-model="dialogOpen"
:category="editingCategory"
:parent-options="parentOptions"
:default-parent-id="dialogDefaultParentId"
/>
</VContainer>
</template>
+8
View File
@@ -1,6 +1,7 @@
import 'vuetify/styles';
import { createVuetify } from 'vuetify';
import { md3 } from 'vuetify/blueprints';
import { aliases, mdi } from 'vuetify/iconsets/mdi';
export default createVuetify({
theme: {
@@ -16,6 +17,13 @@ export default createVuetify({
},
},
},
icons: {
defaultSet: 'mdi',
aliases,
sets: {
mdi,
},
},
defaults: {
global: {
rounded: false,
+47
View File
@@ -0,0 +1,47 @@
import type { Config as ZiggyConfig } from 'ziggy-js';
export interface User {
id: number;
name: string;
email: string;
email_verified_at: string | null;
}
export interface Auth {
user: User | null;
}
export interface NavCategory {
id: number;
parent_id: number | null;
title: string;
children?: NavCategory[];
}
export interface SharedData {
name: string;
auth: Auth;
navigation_categories: NavCategory[];
ziggy: ZiggyConfig & { location: string };
[key: string]: unknown;
}
export type FeedType = 'rss' | 'atom';
export interface Feed {
id: number;
url: string;
type: FeedType;
paywall: boolean;
}
export interface Category {
id: number;
parent_id: number | null;
title: string;
description: string | null;
feeds: Feed[];
children?: Category[];
}
+10
View File
@@ -0,0 +1,10 @@
// resources/js/types/ziggy.d.ts
import { route as routeFn } from 'ziggy-js';
declare module 'vue' {
interface ComponentCustomProperties {
route: typeof routeFn;
}
}
export {};
+2 -1
View File
@@ -36,7 +36,8 @@
@fonts
@vite(['resources/css/app.css', 'resources/js/app.ts', "resources/js/pages/{$page['component']}.vue"])
@vite(['resources/css/app.sass', 'resources/js/app.ts', "resources/js/pages/{$page['component']}.vue"])
@routes
<x-inertia::head>
<title>{{ config('app.name', 'Laravel') }}</title>
</x-inertia::head>
+1 -3
View File
@@ -3,6 +3,4 @@
use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan;
Artisan::command('inspire', function () {
$this->comment(Inspiring::quote());
})->purpose('Display an inspiring quote');
Schedule::command('feeds:fetch')->everyFifteenMinutes();
-7
View File
@@ -5,13 +5,6 @@ use App\Http\Controllers\Settings\SecurityController;
use Illuminate\Auth\Middleware\RequirePassword;
use Illuminate\Support\Facades\Route;
Route::middleware(['auth'])->group(function () {
Route::redirect('settings', '/settings/profile');
Route::get('settings/profile', [ProfileController::class, 'edit'])->name('profile.edit');
Route::patch('settings/profile', [ProfileController::class, 'update'])->name('profile.update');
});
Route::middleware(['auth', 'verified'])->group(function () {
Route::delete('settings/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');
+17 -1
View File
@@ -1,8 +1,24 @@
<?php
use App\Http\Controllers\CategoryController;
use App\Http\Controllers\FrontPageController;
use Illuminate\Support\Facades\Route;
use Inertia\Inertia;
Route::inertia('/', 'Index')->name('home');
// routes/web.php
Route::middleware('auth')->group(function () {
Route::get('/', FrontPageController::class)->name('home');
Route::get('/settings', [CategoryController::class, 'index'])->name('settings.index');
Route::resource('settings/feeds', CategoryController::class)
->parameters(['feeds' => 'category'])
->only(['index', 'store', 'update', 'destroy'])
->names('categories');
});
Route::inertia('/login', 'Auth/Login')->name('login');
Route::inertia('/register', 'Auth/Register')->name('register');
require __DIR__.'/settings.php';
+4 -2
View File
@@ -10,15 +10,17 @@ import path from 'path';
export default defineConfig({
plugins: [
laravel({
input: ['resources/css/app.css', 'resources/js/app.ts'],
input: ['resources/css/app.sass', 'resources/js/app.ts'],
refresh: true,
fonts: [
bunny('Instrument Sans', {
weights: [400, 500, 600],
}),
bunny('Playfair Display', {
weights: [700],
}),
],
}),
tailwindcss(),
vue({
template: {
transformAssetUrls: {