diff --git a/app/Console/Commands/FetchFeeds.php b/app/Console/Commands/FetchFeeds.php new file mode 100644 index 0000000..191e839 --- /dev/null +++ b/app/Console/Commands/FetchFeeds.php @@ -0,0 +1,25 @@ +chunk(50, function ($feeds) use ($fetcher) { + foreach ($feeds as $feed) { + dispatch(fn () => $fetcher->fetch($feed))->onQueue('feeds'); + } + }); + } +} diff --git a/app/Console/Commands/UpDb.php b/app/Console/Commands/UpDb.php new file mode 100644 index 0000000..de84202 --- /dev/null +++ b/app/Console/Commands/UpDb.php @@ -0,0 +1,174 @@ +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.'"'); + } +} diff --git a/app/Http/Controllers/CategoryController.php b/app/Http/Controllers/CategoryController.php new file mode 100644 index 0000000..9a810a7 --- /dev/null +++ b/app/Http/Controllers/CategoryController.php @@ -0,0 +1,73 @@ +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(); + } +} diff --git a/app/Http/Controllers/FrontPageController.php b/app/Http/Controllers/FrontPageController.php new file mode 100644 index 0000000..1fcf577 --- /dev/null +++ b/app/Http/Controllers/FrontPageController.php @@ -0,0 +1,31 @@ +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]); + } +} diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index f4cc770..2d61c34 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -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(), + ] ]; } } diff --git a/app/Http/Requests/Categories/StoreCategoryRequest.php b/app/Http/Requests/Categories/StoreCategoryRequest.php new file mode 100644 index 0000000..12c2dfa --- /dev/null +++ b/app/Http/Requests/Categories/StoreCategoryRequest.php @@ -0,0 +1,30 @@ + ['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'], + ]; + } +} diff --git a/app/Http/Requests/Categories/UpdateCategoryRequest.php b/app/Http/Requests/Categories/UpdateCategoryRequest.php new file mode 100644 index 0000000..bbba8a7 --- /dev/null +++ b/app/Http/Requests/Categories/UpdateCategoryRequest.php @@ -0,0 +1,31 @@ +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'], + ]; + } +} diff --git a/app/Models/Article.php b/app/Models/Article.php new file mode 100644 index 0000000..2435b22 --- /dev/null +++ b/app/Models/Article.php @@ -0,0 +1,17 @@ + 'datetime']; + + public function feed(): BelongsTo + { + return $this->belongsTo(Feed::class); + } +} diff --git a/app/Models/Category.php b/app/Models/Category.php new file mode 100644 index 0000000..841c26a --- /dev/null +++ b/app/Models/Category.php @@ -0,0 +1,40 @@ +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); + } +} diff --git a/app/Models/Feed.php b/app/Models/Feed.php new file mode 100644 index 0000000..85560d6 --- /dev/null +++ b/app/Models/Feed.php @@ -0,0 +1,30 @@ +belongsTo(Category::class); + } + + public function articles(): HasMany + { + return $this->hasMany(Article::class); + } +} diff --git a/app/Services/ArticleSelector.php b/app/Services/ArticleSelector.php new file mode 100644 index 0000000..9bc49eb --- /dev/null +++ b/app/Services/ArticleSelector.php @@ -0,0 +1,30 @@ +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; + } +} diff --git a/app/Services/FeedFetcher.php b/app/Services/FeedFetcher.php new file mode 100644 index 0000000..3d09bc6 --- /dev/null +++ b/app/Services/FeedFetcher.php @@ -0,0 +1,70 @@ +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; + } +} diff --git a/app/Services/OgImageResolver.php b/app/Services/OgImageResolver.php new file mode 100644 index 0000000..5bfc94e --- /dev/null +++ b/app/Services/OgImageResolver.php @@ -0,0 +1,40 @@ + '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; + } + } +} diff --git a/composer.json b/composer.json index 12a7b86..a081f4c 100644 --- a/composer.json +++ b/composer.json @@ -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 -} \ No newline at end of file +} diff --git a/composer.lock b/composer.lock index 514d00b..edd42e0 100644 --- a/composer.lock +++ b/composer.lock @@ -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" diff --git a/config/database.php b/config/database.php index abbb88e..fb42e6c 100644 --- a/config/database.php +++ b/config/database.php @@ -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'), diff --git a/database/factories/CategoryFactory.php b/database/factories/CategoryFactory.php new file mode 100644 index 0000000..254b069 --- /dev/null +++ b/database/factories/CategoryFactory.php @@ -0,0 +1,18 @@ +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'); + } +}; diff --git a/database/migrations/2026_07_04_103453_create_feeds_table.php b/database/migrations/2026_07_04_103453_create_feeds_table.php new file mode 100644 index 0000000..8e7a2d2 --- /dev/null +++ b/database/migrations/2026_07_04_103453_create_feeds_table.php @@ -0,0 +1,25 @@ +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'); + } +}; diff --git a/database/migrations/2026_07_04_114559_create_articles_table.php b/database/migrations/2026_07_04_114559_create_articles_table.php new file mode 100644 index 0000000..f11a825 --- /dev/null +++ b/database/migrations/2026_07_04_114559_create_articles_table.php @@ -0,0 +1,38 @@ +id(); + $table->foreignId('feed_id')->constrained()->cascadeOnDelete(); + $table->string('guid'); // or Atom , 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'); + } +}; diff --git a/package-lock.json b/package-lock.json index 6a29845..c5edabe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" + } } } } diff --git a/package.json b/package.json index bf3402d..c892d2c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/resources/css/app.css b/resources/css/app.css deleted file mode 100644 index 6f9d1f5..0000000 --- a/resources/css/app.css +++ /dev/null @@ -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; - } -} diff --git a/resources/css/app.sass b/resources/css/app.sass new file mode 100644 index 0000000..dad407a --- /dev/null +++ b/resources/css/app.sass @@ -0,0 +1,2 @@ +.font-serif + font-family: 'Playfair Display', serif diff --git a/resources/js/app.ts b/resources/js/app.ts index 565ac85..5dfe88b 100644 --- a/resources/js/app.ts +++ b/resources/js/app.ts @@ -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); }, diff --git a/resources/js/components/ArticleCard.vue b/resources/js/components/ArticleCard.vue new file mode 100644 index 0000000..a31e4e8 --- /dev/null +++ b/resources/js/components/ArticleCard.vue @@ -0,0 +1,175 @@ + + + + + diff --git a/resources/js/components/Auth/AuthCard.vue b/resources/js/components/Auth/AuthCard.vue new file mode 100644 index 0000000..b1dc57d --- /dev/null +++ b/resources/js/components/Auth/AuthCard.vue @@ -0,0 +1,64 @@ + + + + + diff --git a/resources/js/components/Auth/AuthField.vue b/resources/js/components/Auth/AuthField.vue new file mode 100644 index 0000000..6d580c7 --- /dev/null +++ b/resources/js/components/Auth/AuthField.vue @@ -0,0 +1,26 @@ + + + diff --git a/resources/js/components/CategoryNavBar.vue b/resources/js/components/CategoryNavBar.vue new file mode 100644 index 0000000..c8a2658 --- /dev/null +++ b/resources/js/components/CategoryNavBar.vue @@ -0,0 +1,116 @@ + + + + + diff --git a/resources/js/components/CategorySection.vue b/resources/js/components/CategorySection.vue new file mode 100644 index 0000000..2b08893 --- /dev/null +++ b/resources/js/components/CategorySection.vue @@ -0,0 +1,181 @@ + + + + + diff --git a/resources/js/components/Eyebrow.vue b/resources/js/components/Eyebrow.vue new file mode 100644 index 0000000..a349071 --- /dev/null +++ b/resources/js/components/Eyebrow.vue @@ -0,0 +1,17 @@ + + + diff --git a/resources/js/components/Masthead.vue b/resources/js/components/Masthead.vue index 7699794..6b2242f 100644 --- a/resources/js/components/Masthead.vue +++ b/resources/js/components/Masthead.vue @@ -1,4 +1,6 @@ diff --git a/resources/js/pages/Settings/Index.vue b/resources/js/pages/Settings/Index.vue new file mode 100644 index 0000000..27d67f9 --- /dev/null +++ b/resources/js/pages/Settings/Index.vue @@ -0,0 +1,198 @@ + + + diff --git a/resources/js/plugins/vuetify.ts b/resources/js/plugins/vuetify.ts index 6b92c5a..dc9fa83 100644 --- a/resources/js/plugins/vuetify.ts +++ b/resources/js/plugins/vuetify.ts @@ -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, diff --git a/resources/js/types/types.ts b/resources/js/types/types.ts new file mode 100644 index 0000000..61df221 --- /dev/null +++ b/resources/js/types/types.ts @@ -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[]; +} + + diff --git a/resources/js/types/ziggy.d.ts b/resources/js/types/ziggy.d.ts new file mode 100644 index 0000000..14a6030 --- /dev/null +++ b/resources/js/types/ziggy.d.ts @@ -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 {}; diff --git a/resources/views/app.blade.php b/resources/views/app.blade.php index 7083da4..3d9f39c 100644 --- a/resources/views/app.blade.php +++ b/resources/views/app.blade.php @@ -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 {{ config('app.name', 'Laravel') }} diff --git a/routes/console.php b/routes/console.php index 3c9adf1..a77e817 100644 --- a/routes/console.php +++ b/routes/console.php @@ -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(); diff --git a/routes/settings.php b/routes/settings.php index 2e0e1eb..0fae809 100644 --- a/routes/settings.php +++ b/routes/settings.php @@ -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'); diff --git a/routes/web.php b/routes/web.php index 15ef763..9d310d7 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,8 +1,24 @@ 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'; diff --git a/vite.config.ts b/vite.config.ts index c4dd46a..316729b 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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: {