Done
This commit is contained in:
@@ -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');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
-3
@@ -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",
|
||||
@@ -114,4 +118,4 @@
|
||||
},
|
||||
"minimum-stability": "stable",
|
||||
"prefer-stable": true
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+154
-2
@@ -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"
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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 [
|
||||
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
Generated
+72
-7
@@ -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
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
.font-serif
|
||||
font-family: 'Playfair Display', serif
|
||||
+5
-2
@@ -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);
|
||||
},
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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']">
|
||||
Dredge<span class="masthead-accent">News</span>
|
||||
</span>
|
||||
<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
|
||||
|
||||
@@ -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>
|
||||
@@ -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>();
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
|
||||
Vendored
+10
@@ -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 {};
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user