generated from Flycro/laravel-nuxt
Compare commits
76 Commits
12d8f3913c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| cae791ae27 | |||
| ee3d211af1 | |||
| 779bc7d91f | |||
| 53628a319e | |||
| ec92f4bd5d | |||
| 4c57faef52 | |||
| f9c83d6459 | |||
| 91592b06d7 | |||
| 5534f00a7d | |||
| 5c0dab3448 | |||
| b1cb25c823 | |||
| dbdc7c1540 | |||
| 4e65112476 | |||
| b0ea02928a | |||
| fb64c29015 | |||
| 1887add00d | |||
| 4bb02faf14 | |||
| 18388246f8 | |||
| 041232bcae | |||
| 3e58347acb | |||
| 73dab4dd6e | |||
| c2a2ae0d07 | |||
| b6f9968a46 | |||
| f497b3af55 | |||
| 5f1e3ee176 | |||
| d6ec298e56 | |||
| 74c39c8c89 | |||
| b07d0d3e9d | |||
| ecc98924e3 | |||
| 999bd8e3d4 | |||
| 78e3f0ff0c | |||
| 9fd7b9f4c3 | |||
| 1297ba88c5 | |||
| ac371e4451 | |||
| 20d03b385b | |||
| f52415346e | |||
| 829fc93c98 | |||
| 70278dc6c0 | |||
| 8d276ee0d7 | |||
| e97c3bcf83 | |||
| 05898fbd9b | |||
| 13a4730c15 | |||
| aff6a6b6e4 | |||
| 0c42894960 | |||
| 09ef7f2c9e | |||
| 62042f3457 | |||
| bffcd2f414 | |||
| 6330268d20 | |||
| 87b8d48b84 | |||
| 517e166547 | |||
| ae83ec3ff6 | |||
| e1092d616a | |||
| 32ff5f22dd | |||
| 28d15cd26a | |||
| 5a546f8ef2 | |||
| 8c595aa3c6 | |||
| b001ea937f | |||
| fc63da0a3a | |||
| 48a87c9b7b | |||
| 837441ecb8 | |||
| bd8225374a | |||
| 726c86dbcd | |||
| e393ba5943 | |||
| 1d7f41e812 | |||
| 2953acd630 | |||
| 74d6f3efaa | |||
| 95ece0f614 | |||
| fffe1b4717 | |||
| 923e41b396 | |||
| cbc54210f0 | |||
| 1a836b71f0 | |||
| 3857ff70d2 | |||
| b97f8c785f | |||
| 5ab2c46335 | |||
| 096698a8e1 | |||
| daa80f4ff3 |
3
.dockerignore
Normal file
3
.dockerignore
Normal file
@@ -0,0 +1,3 @@
|
||||
**/node_modules/
|
||||
**/dist
|
||||
.git
|
||||
33
.drone.yml
Normal file
33
.drone.yml
Normal file
@@ -0,0 +1,33 @@
|
||||
---
|
||||
kind: pipeline
|
||||
type: exec
|
||||
name: default
|
||||
|
||||
platform:
|
||||
os: linux
|
||||
arch: amd64
|
||||
|
||||
steps:
|
||||
- name: Deploy
|
||||
commands:
|
||||
- cp -af . /var/www/html/bookstack.octolabs.net
|
||||
- cd /var/www/html/bookstack.octolabs.net
|
||||
- git reset --hard
|
||||
- git clean -fd
|
||||
- composer install
|
||||
- pnpm install
|
||||
- chown -R flycro:www-data /var/www/html/bookstack.octolabs.net
|
||||
- docker compose -f docker-compose-production.yml down
|
||||
- docker compose -f docker-compose-production.yml build
|
||||
- docker compose -f docker-compose-production.yml up -d
|
||||
- docker compose exec php php artisan migrate --force
|
||||
- docker compose exec php php artisan optimize
|
||||
- docker compose exec php php artisan storage:link
|
||||
- git config --global --add safe.directory /var/www/html/bookstack.octolabs.net
|
||||
- npx nuxi cleanup
|
||||
- pnpm run build
|
||||
- find /var/www/html/bookstack.octolabs.net -type f -exec chmod 664 {} \\;
|
||||
- find /var/www/html/bookstack.octolabs.net -type d -exec chmod 775 {} \\;
|
||||
- export GIT_HASH=$(git rev-parse --short HEAD)
|
||||
- env HOME=/home/flycro GIT_HASH=$GIT_HASH pm2 stop ecosystem.config.cjs
|
||||
- env HOME=/home/flycro GIT_HASH=$GIT_HASH pm2 start ecosystem.config.cjs
|
||||
@@ -45,7 +45,7 @@ CACHE_PREFIX=
|
||||
|
||||
MEMCACHED_HOST=127.0.0.1
|
||||
|
||||
REDIS_CLIENT=phpredis
|
||||
REDIS_CLIENT=predis
|
||||
REDIS_HOST=redis
|
||||
REDIS_PASSWORD=null
|
||||
REDIS_PORT=6379
|
||||
|
||||
11
Dockerfile
Normal file
11
Dockerfile
Normal file
@@ -0,0 +1,11 @@
|
||||
FROM dunglas/frankenphp
|
||||
|
||||
RUN install-php-extensions pcntl pdo_pgsql pgsql
|
||||
|
||||
#ENV SERVER_NAME=bookstack.octolabs.net
|
||||
ENV SERVER_NAME=:80
|
||||
|
||||
|
||||
COPY . /app
|
||||
|
||||
ENTRYPOINT ["php", "artisan", "octane:frankenphp"]
|
||||
55
app/Console/Commands/OpenLibraryFetchCoverArt.php
Normal file
55
app/Console/Commands/OpenLibraryFetchCoverArt.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\BookRecommendation;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class OpenLibraryFetchCoverArt extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'book:open-library-fetch-cover-art';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Fetch cover art for books from Open Library API.';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$books = BookRecommendation::whereNull('cover_image')->get();
|
||||
$this->info('Fetching cover art for '.$books->count().' books.');
|
||||
foreach ($books as $book) {
|
||||
$this->info('Fetching cover art for '.$book->book_name);
|
||||
$isbn = str_replace('-', '', $book->isbn);
|
||||
$url = 'https://covers.openlibrary.org/b/isbn/'.$isbn.'-L.jpg';
|
||||
$response = Http::get($url);
|
||||
if ($response->status() === 200) {
|
||||
$image = getimagesizefromstring($response->body());
|
||||
if ($image[0] <= 1 || $image[1] <= 1) {
|
||||
$this->error('Failed to fetch cover art.');
|
||||
continue;
|
||||
}
|
||||
$this->info('Cover art fetched.');
|
||||
$imagePath = 'cover_images/'.$isbn.'.jpg';
|
||||
Storage::disk('public')->put($imagePath, $response->body());
|
||||
$book->cover_image = $imagePath;
|
||||
$book->save();
|
||||
$this->info('Cover art fetched and saved.');
|
||||
} else {
|
||||
$this->error('Failed to fetch cover art.');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
42
app/Events/BookRecommendation/BookRecommendationCreated.php
Normal file
42
app/Events/BookRecommendation/BookRecommendationCreated.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events\BookRecommendation;
|
||||
|
||||
use App\Models\BookRecommendation;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class BookRecommendationCreated implements ShouldBroadcast
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* Create a new event instance.
|
||||
*/
|
||||
public function __construct(
|
||||
public BookRecommendation $bookRecommendation
|
||||
)
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return 'BookRecommendationCreated';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the channels the event should broadcast on.
|
||||
*
|
||||
* @return array<int, \Illuminate\Broadcasting\Channel>
|
||||
*/
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [
|
||||
new PrivateChannel('BookRecommendation'),
|
||||
];
|
||||
}
|
||||
}
|
||||
42
app/Events/BookRecommendation/BookRecommendationDeleted.php
Normal file
42
app/Events/BookRecommendation/BookRecommendationDeleted.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events\BookRecommendation;
|
||||
|
||||
use App\Models\BookRecommendation;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class BookRecommendationDeleted implements ShouldBroadcast
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* Create a new event instance.
|
||||
*/
|
||||
public function __construct(
|
||||
public BookRecommendation $bookRecommendation
|
||||
)
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return 'BookRecommendationDeleted';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the channels the event should broadcast on.
|
||||
*
|
||||
* @return array<int, \Illuminate\Broadcasting\Channel>
|
||||
*/
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [
|
||||
new PrivateChannel('BookRecommendation'),
|
||||
];
|
||||
}
|
||||
}
|
||||
42
app/Events/BookRecommendation/BookRecommendationUpdated.php
Normal file
42
app/Events/BookRecommendation/BookRecommendationUpdated.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events\BookRecommendation;
|
||||
|
||||
use App\Models\BookRecommendation;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class BookRecommendationUpdated implements ShouldBroadcast
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* Create a new event instance.
|
||||
*/
|
||||
public function __construct(
|
||||
public BookRecommendation $bookRecommendation
|
||||
)
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return 'BookRecommendationUpdated';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the channels the event should broadcast on.
|
||||
*
|
||||
* @return array<int, \Illuminate\Broadcasting\Channel>
|
||||
*/
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [
|
||||
new PrivateChannel('BookRecommendation'),
|
||||
];
|
||||
}
|
||||
}
|
||||
40
app/Events/Deadline/DeadlineCreated.php
Normal file
40
app/Events/Deadline/DeadlineCreated.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events\Deadline;
|
||||
|
||||
use App\Models\Deadline;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class DeadlineCreated implements ShouldBroadcast
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* Create a new event instance.
|
||||
*/
|
||||
public function __construct(public Deadline $deadline)
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return 'DeadlineCreated';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the channels the event should broadcast on.
|
||||
*
|
||||
* @return array<int, \Illuminate\Broadcasting\Channel>
|
||||
*/
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [
|
||||
new PrivateChannel('Deadline'),
|
||||
];
|
||||
}
|
||||
}
|
||||
42
app/Events/Vote/VoteCreated.php
Normal file
42
app/Events/Vote/VoteCreated.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events\Vote;
|
||||
|
||||
use App\Models\Vote;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class VoteCreated implements ShouldBroadcast
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* Create a new event instance.
|
||||
*/
|
||||
public function __construct(
|
||||
public Vote $vote
|
||||
)
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return 'VoteCreated';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the channels the event should broadcast on.
|
||||
*
|
||||
* @return array<int, \Illuminate\Broadcasting\Channel>
|
||||
*/
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [
|
||||
new PrivateChannel('Vote'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\BookRecommendation;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class BookRecommendationController extends Controller
|
||||
@@ -14,10 +15,16 @@ class BookRecommendationController extends Controller
|
||||
public function index()
|
||||
{
|
||||
$relations = [];
|
||||
$bookRecommendations = BookRecommendation::query();
|
||||
if (request()->has('with')) {
|
||||
$relations = explode(',', request()->with);
|
||||
$bookRecommendations->with($relations);
|
||||
}
|
||||
return BookRecommendation::with($relations)->get();
|
||||
if(request()->has('status')) {
|
||||
$bookRecommendations->where('status', request()->status);
|
||||
}
|
||||
|
||||
return $bookRecommendations->get();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -50,7 +57,7 @@ class BookRecommendationController extends Controller
|
||||
$data['cover_image'] = $imagePath;
|
||||
}
|
||||
|
||||
$bookRecommendation = BookRecommendation::create([...$request->all(), 'recommended_by' => auth()->id()]);
|
||||
$bookRecommendation = BookRecommendation::create([...$data, 'recommended_by' => auth()->id()]);
|
||||
return response()->json($bookRecommendation, 201);
|
||||
}
|
||||
|
||||
@@ -124,7 +131,16 @@ class BookRecommendationController extends Controller
|
||||
if ($bookRecommendation->recommended_by !== auth()->id() && !(auth()->user()->hasRole('admin')) ) {
|
||||
return response()->json(['message' => 'Keine Berechtigung.'], 403);
|
||||
}
|
||||
if ($bookRecommendation->cover_image) {
|
||||
Storage::delete($bookRecommendation->cover_image);
|
||||
}
|
||||
$bookRecommendation->delete();
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
public function fetchCover(Request $request)
|
||||
{
|
||||
Artisan::call('book:open-library-fetch-cover-art');
|
||||
return response()->json(Artisan::output());
|
||||
}
|
||||
}
|
||||
|
||||
104
app/Http/Controllers/DeadlineController.php
Normal file
104
app/Http/Controllers/DeadlineController.php
Normal file
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Deadline;
|
||||
use App\Models\UserDeadline;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class DeadlineController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$userId = Auth::id();
|
||||
|
||||
$deadlines = Deadline::with(['bookRecommendation' => function ($query) {
|
||||
$query->where('status', 'active');
|
||||
}, 'userDeadlines'])
|
||||
->get();
|
||||
|
||||
|
||||
$deadlines->each(function ($deadline) use ($userId) {
|
||||
UserDeadline::firstOrCreate([
|
||||
'user_id' => $userId,
|
||||
'deadline_id' => $deadline->id,
|
||||
], [
|
||||
'completed_at' => null, // Default value, indicating not completed
|
||||
]);
|
||||
});
|
||||
|
||||
$deadlinesWithAllUserProgress = Deadline::with(['bookRecommendation', 'userDeadlines.user'])
|
||||
->get();
|
||||
|
||||
return response()->json($deadlinesWithAllUserProgress);
|
||||
}
|
||||
|
||||
public function deadlinesByBookRecommendation($bookRecommendationId)
|
||||
{
|
||||
$userId = Auth::id();
|
||||
$refetch = false;
|
||||
|
||||
$deadlines = Deadline::with(['userDeadlines' => function ($query) use ($userId) {
|
||||
$query->where('user_id', $userId);
|
||||
}, 'userDeadlines.user'])
|
||||
->where('book_recommendation_id', $bookRecommendationId)
|
||||
->get();
|
||||
|
||||
$deadlines->each(function ($deadline) use ($userId, &$refetch) {
|
||||
UserDeadline::firstOrCreate([
|
||||
'user_id' => $userId,
|
||||
'deadline_id' => $deadline->id,
|
||||
], [
|
||||
'completed_at' => null, // Default to not completed.
|
||||
]);
|
||||
$refetch = true;
|
||||
});
|
||||
|
||||
if ($refetch) {
|
||||
$deadlines = Deadline::with(['userDeadlines' => function ($query) use ($userId) {
|
||||
$query->where('user_id', $userId);
|
||||
}, 'userDeadlines.user'])
|
||||
->where('book_recommendation_id', $bookRecommendationId)
|
||||
->get();
|
||||
}
|
||||
|
||||
return response()->json($deadlines);
|
||||
}
|
||||
|
||||
public function createDeadline(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'book_recommendation_id' => 'required|integer|exists:book_recommendations,id',
|
||||
'deadline' => 'required|date',
|
||||
'target_page' => 'nullable|integer',
|
||||
'target_chapter' => 'nullable|string|max:255',
|
||||
]);
|
||||
|
||||
if (!(auth()->user()->hasRole('admin')) ) {
|
||||
return response()->json(['message' => 'Keine Berechtigung.'], 403);
|
||||
}
|
||||
|
||||
$deadline = new Deadline($validated);
|
||||
$deadline->save();
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Deadline created successfully.',
|
||||
'deadline' => $deadline,
|
||||
], 201);
|
||||
}
|
||||
|
||||
|
||||
public function updateUserDeadline(Request $request, $deadlineId)
|
||||
{
|
||||
$userId = Auth::id();
|
||||
$userDeadline = UserDeadline::where('deadline_id', $deadlineId)
|
||||
->where('user_id', $userId)
|
||||
->firstOrFail();
|
||||
|
||||
$userDeadline->completed_at = now(); // Mark as completed
|
||||
$userDeadline->save();
|
||||
|
||||
return response()->json(['message' => 'Deadline marked as completed successfully.']);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\BookRecommendation;
|
||||
use App\Models\User;
|
||||
use App\Models\Vote;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
@@ -29,4 +30,22 @@ class VoteController extends Controller
|
||||
|
||||
return response()->json(['message' => 'No remaining votes.'], 403);
|
||||
}
|
||||
|
||||
public function addTotalVotesAll(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'total_votes' => 'required|integer|min:1',
|
||||
]);
|
||||
|
||||
if (!(auth()->user()->hasRole('admin')) ) {
|
||||
return response()->json(['message' => 'Keine Berechtigung.'], 403);
|
||||
}
|
||||
|
||||
$users = User::all();
|
||||
foreach ($users as $user) {
|
||||
$user->increment('total_votes', $request->total_votes);
|
||||
}
|
||||
|
||||
return response()->json(['message' => 'Total votes added to all book recommendations.']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,12 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Events\BookRecommendation\BookRecommendationCreated;
|
||||
use App\Events\BookRecommendation\BookRecommendationDeleted;
|
||||
use App\Events\BookRecommendation\BookRecommendationUpdated;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class BookRecommendation extends Model
|
||||
{
|
||||
@@ -17,18 +22,39 @@ class BookRecommendation extends Model
|
||||
'recommended_by',
|
||||
'cover_image',
|
||||
'status',
|
||||
'published_at',
|
||||
];
|
||||
|
||||
protected static function booted() :void
|
||||
{
|
||||
static::created(static function ($bookRecommendation) {
|
||||
broadcast(new BookRecommendationCreated($bookRecommendation))->toOthers();
|
||||
});
|
||||
|
||||
static::updated(static function ($task) {
|
||||
broadcast(new BookRecommendationUpdated($task))->toOthers();
|
||||
});
|
||||
|
||||
static::deleting(static function ($task) {
|
||||
broadcast(new BookRecommendationDeleted($task))->toOthers();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user that recommended the book.
|
||||
*/
|
||||
public function recommender()
|
||||
public function recommender(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'recommended_by');
|
||||
}
|
||||
|
||||
public function votes()
|
||||
public function votes(): HasMany
|
||||
{
|
||||
return $this->hasMany(Vote::class);
|
||||
}
|
||||
|
||||
public function deadlines(): HasMany
|
||||
{
|
||||
return $this->hasMany(Deadline::class);
|
||||
}
|
||||
}
|
||||
|
||||
35
app/Models/Deadline.php
Normal file
35
app/Models/Deadline.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Events\Deadline\DeadlineCreated;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Deadline extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'book_recommendation_id',
|
||||
'deadline',
|
||||
'target_page',
|
||||
'target_chapter',
|
||||
];
|
||||
|
||||
protected static function booted() :void
|
||||
{
|
||||
static::created(static function ($deadline) {
|
||||
event(new DeadlineCreated($deadline));
|
||||
});
|
||||
}
|
||||
|
||||
public function bookRecommendation()
|
||||
{
|
||||
return $this->belongsTo(BookRecommendation::class);
|
||||
}
|
||||
public function userDeadlines()
|
||||
{
|
||||
return $this->hasMany(UserDeadline::class);
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,11 @@ class User extends Authenticatable implements MustVerifyEmail
|
||||
return $this->hasMany(Vote::class);
|
||||
}
|
||||
|
||||
public function deadlinesProgress()
|
||||
{
|
||||
return $this->hasMany(UserDeadline::class);
|
||||
}
|
||||
|
||||
public function mustVerifyEmail(): bool
|
||||
{
|
||||
return $this instanceof MustVerifyEmail && !$this->hasVerifiedEmail();
|
||||
|
||||
26
app/Models/UserDeadline.php
Normal file
26
app/Models/UserDeadline.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class UserDeadline extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'deadline_id',
|
||||
'completed_at',
|
||||
];
|
||||
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
public function deadline()
|
||||
{
|
||||
return $this->belongsTo(Deadline::class);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Events\Vote\VoteCreated;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
@@ -14,6 +15,13 @@ class Vote extends Model
|
||||
'book_recommendation_id',
|
||||
];
|
||||
|
||||
protected static function booted() :void
|
||||
{
|
||||
static::created(static function ($vote) {
|
||||
broadcast(new VoteCreated($vote))->toOthers();
|
||||
});
|
||||
}
|
||||
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
|
||||
@@ -14,6 +14,7 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
api: __DIR__.'/../routes/api.php',
|
||||
apiPrefix: '',
|
||||
commands: __DIR__.'/../routes/console.php',
|
||||
channels: __DIR__.'/../routes/channels.php',
|
||||
health: '/up',
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware) {
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"laravel/tinker": "^2.9",
|
||||
"league/flysystem-aws-s3-v3": "^3.24",
|
||||
"predis/predis": "*",
|
||||
"pusher/pusher-php-server": "^7.2",
|
||||
"spatie/laravel-permission": "^6.4"
|
||||
},
|
||||
"require-dev": {
|
||||
|
||||
359
composer.lock
generated
359
composer.lock
generated
@@ -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": "ec31886b4063db8aa1efc22c187f614a",
|
||||
"content-hash": "572762c5d2b5b91e9a8012b47f3cd07f",
|
||||
"packages": [
|
||||
{
|
||||
"name": "aws/aws-crt-php",
|
||||
@@ -62,16 +62,16 @@
|
||||
},
|
||||
{
|
||||
"name": "aws/aws-sdk-php",
|
||||
"version": "3.301.1",
|
||||
"version": "3.301.6",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/aws/aws-sdk-php.git",
|
||||
"reference": "0a910d2b35e7087337cdf3569dc9b6ce232aafba"
|
||||
"reference": "18c0ebd71d3071304f1ea02aa9af75f95863177a"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/0a910d2b35e7087337cdf3569dc9b6ce232aafba",
|
||||
"reference": "0a910d2b35e7087337cdf3569dc9b6ce232aafba",
|
||||
"url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/18c0ebd71d3071304f1ea02aa9af75f95863177a",
|
||||
"reference": "18c0ebd71d3071304f1ea02aa9af75f95863177a",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -151,9 +151,9 @@
|
||||
"support": {
|
||||
"forum": "https://forums.aws.amazon.com/forum.jspa?forumID=80",
|
||||
"issues": "https://github.com/aws/aws-sdk-php/issues",
|
||||
"source": "https://github.com/aws/aws-sdk-php/tree/3.301.1"
|
||||
"source": "https://github.com/aws/aws-sdk-php/tree/3.301.6"
|
||||
},
|
||||
"time": "2024-03-15T18:14:42+00:00"
|
||||
"time": "2024-03-22T18:05:21+00:00"
|
||||
},
|
||||
{
|
||||
"name": "brick/math",
|
||||
@@ -1408,16 +1408,16 @@
|
||||
},
|
||||
{
|
||||
"name": "intervention/image",
|
||||
"version": "3.4.0",
|
||||
"version": "3.5.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Intervention/image.git",
|
||||
"reference": "fe1b0e2e64157133322974c28b44c25c2770a0c5"
|
||||
"reference": "408d3655c7705339e8c79731ea7efb51546cfa10"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/Intervention/image/zipball/fe1b0e2e64157133322974c28b44c25c2770a0c5",
|
||||
"reference": "fe1b0e2e64157133322974c28b44c25c2770a0c5",
|
||||
"url": "https://api.github.com/repos/Intervention/image/zipball/408d3655c7705339e8c79731ea7efb51546cfa10",
|
||||
"reference": "408d3655c7705339e8c79731ea7efb51546cfa10",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -1428,7 +1428,7 @@
|
||||
"require-dev": {
|
||||
"mockery/mockery": "^1.6",
|
||||
"phpstan/phpstan": "^1",
|
||||
"phpunit/phpunit": "^9",
|
||||
"phpunit/phpunit": "^10.0",
|
||||
"slevomat/coding-standard": "~8.0",
|
||||
"squizlabs/php_codesniffer": "^3.8"
|
||||
},
|
||||
@@ -1464,7 +1464,7 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/Intervention/image/issues",
|
||||
"source": "https://github.com/Intervention/image/tree/3.4.0"
|
||||
"source": "https://github.com/Intervention/image/tree/3.5.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -1476,20 +1476,20 @@
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2024-02-14T15:11:21+00:00"
|
||||
"time": "2024-03-13T16:26:15+00:00"
|
||||
},
|
||||
{
|
||||
"name": "jaybizzle/crawler-detect",
|
||||
"version": "v1.2.116",
|
||||
"version": "v1.2.117",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/JayBizzle/Crawler-Detect.git",
|
||||
"reference": "97e9fe30219e60092e107651abb379a38b342921"
|
||||
"reference": "6785557f03d0fa9e2205352ebae9a12a4484cc8e"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/JayBizzle/Crawler-Detect/zipball/97e9fe30219e60092e107651abb379a38b342921",
|
||||
"reference": "97e9fe30219e60092e107651abb379a38b342921",
|
||||
"url": "https://api.github.com/repos/JayBizzle/Crawler-Detect/zipball/6785557f03d0fa9e2205352ebae9a12a4484cc8e",
|
||||
"reference": "6785557f03d0fa9e2205352ebae9a12a4484cc8e",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -1526,9 +1526,9 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/JayBizzle/Crawler-Detect/issues",
|
||||
"source": "https://github.com/JayBizzle/Crawler-Detect/tree/v1.2.116"
|
||||
"source": "https://github.com/JayBizzle/Crawler-Detect/tree/v1.2.117"
|
||||
},
|
||||
"time": "2023-07-21T15:49:49+00:00"
|
||||
"time": "2024-03-19T22:51:22+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laminas/laminas-diactoros",
|
||||
@@ -1617,16 +1617,16 @@
|
||||
},
|
||||
{
|
||||
"name": "laravel/framework",
|
||||
"version": "v11.0.7",
|
||||
"version": "v11.0.8",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/laravel/framework.git",
|
||||
"reference": "28eabe9dcdcb017a21ce226eda4538c5c8c93b1c"
|
||||
"reference": "0379a7ccb77e2029c43ce508fa76e251a0d68fce"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/laravel/framework/zipball/28eabe9dcdcb017a21ce226eda4538c5c8c93b1c",
|
||||
"reference": "28eabe9dcdcb017a21ce226eda4538c5c8c93b1c",
|
||||
"url": "https://api.github.com/repos/laravel/framework/zipball/0379a7ccb77e2029c43ce508fa76e251a0d68fce",
|
||||
"reference": "0379a7ccb77e2029c43ce508fa76e251a0d68fce",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -1729,7 +1729,7 @@
|
||||
"league/flysystem-sftp-v3": "^3.0",
|
||||
"mockery/mockery": "^1.6",
|
||||
"nyholm/psr7": "^1.2",
|
||||
"orchestra/testbench-core": "^9.0",
|
||||
"orchestra/testbench-core": "^9.0.6",
|
||||
"pda/pheanstalk": "^5.0",
|
||||
"phpstan/phpstan": "^1.4.7",
|
||||
"phpunit/phpunit": "^10.5|^11.0",
|
||||
@@ -1818,7 +1818,7 @@
|
||||
"issues": "https://github.com/laravel/framework/issues",
|
||||
"source": "https://github.com/laravel/framework"
|
||||
},
|
||||
"time": "2024-03-15T23:17:58+00:00"
|
||||
"time": "2024-03-21T14:15:49+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/octane",
|
||||
@@ -2414,16 +2414,16 @@
|
||||
},
|
||||
{
|
||||
"name": "league/flysystem",
|
||||
"version": "3.25.0",
|
||||
"version": "3.25.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/thephpleague/flysystem.git",
|
||||
"reference": "4c44347133618cccd9b3df1729647a1577b4ad99"
|
||||
"reference": "abbd664eb4381102c559d358420989f835208f18"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/thephpleague/flysystem/zipball/4c44347133618cccd9b3df1729647a1577b4ad99",
|
||||
"reference": "4c44347133618cccd9b3df1729647a1577b4ad99",
|
||||
"url": "https://api.github.com/repos/thephpleague/flysystem/zipball/abbd664eb4381102c559d358420989f835208f18",
|
||||
"reference": "abbd664eb4381102c559d358420989f835208f18",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -2488,7 +2488,7 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/thephpleague/flysystem/issues",
|
||||
"source": "https://github.com/thephpleague/flysystem/tree/3.25.0"
|
||||
"source": "https://github.com/thephpleague/flysystem/tree/3.25.1"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -2500,20 +2500,20 @@
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2024-03-09T17:06:45+00:00"
|
||||
"time": "2024-03-16T12:53:19+00:00"
|
||||
},
|
||||
{
|
||||
"name": "league/flysystem-aws-s3-v3",
|
||||
"version": "3.24.0",
|
||||
"version": "3.25.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/thephpleague/flysystem-aws-s3-v3.git",
|
||||
"reference": "809474e37b7fb1d1f8bcc0f8a98bc1cae99aa513"
|
||||
"reference": "6a5be0e6d6a93574e80805c9cc108a4b63c824d8"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/809474e37b7fb1d1f8bcc0f8a98bc1cae99aa513",
|
||||
"reference": "809474e37b7fb1d1f8bcc0f8a98bc1cae99aa513",
|
||||
"url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/6a5be0e6d6a93574e80805c9cc108a4b63c824d8",
|
||||
"reference": "6a5be0e6d6a93574e80805c9cc108a4b63c824d8",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -2553,7 +2553,7 @@
|
||||
"storage"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/3.24.0"
|
||||
"source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/3.25.1"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -2565,20 +2565,20 @@
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2024-01-26T18:43:21+00:00"
|
||||
"time": "2024-03-15T19:58:44+00:00"
|
||||
},
|
||||
{
|
||||
"name": "league/flysystem-local",
|
||||
"version": "3.23.1",
|
||||
"version": "3.25.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/thephpleague/flysystem-local.git",
|
||||
"reference": "b884d2bf9b53bb4804a56d2df4902bb51e253f00"
|
||||
"reference": "61a6a90d6e999e4ddd9ce5adb356de0939060b92"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/b884d2bf9b53bb4804a56d2df4902bb51e253f00",
|
||||
"reference": "b884d2bf9b53bb4804a56d2df4902bb51e253f00",
|
||||
"url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/61a6a90d6e999e4ddd9ce5adb356de0939060b92",
|
||||
"reference": "61a6a90d6e999e4ddd9ce5adb356de0939060b92",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -2612,8 +2612,7 @@
|
||||
"local"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/thephpleague/flysystem-local/issues",
|
||||
"source": "https://github.com/thephpleague/flysystem-local/tree/3.23.1"
|
||||
"source": "https://github.com/thephpleague/flysystem-local/tree/3.25.1"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -2625,7 +2624,7 @@
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2024-01-26T18:25:23+00:00"
|
||||
"time": "2024-03-15T19:58:44+00:00"
|
||||
},
|
||||
{
|
||||
"name": "league/mime-type-detection",
|
||||
@@ -3570,6 +3569,142 @@
|
||||
],
|
||||
"time": "2024-03-06T16:17:14+00:00"
|
||||
},
|
||||
{
|
||||
"name": "paragonie/random_compat",
|
||||
"version": "v9.99.100",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/paragonie/random_compat.git",
|
||||
"reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/paragonie/random_compat/zipball/996434e5492cb4c3edcb9168db6fbb1359ef965a",
|
||||
"reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">= 7"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "4.*|5.*",
|
||||
"vimeo/psalm": "^1"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-libsodium": "Provides a modern crypto API that can be used to generate random bytes."
|
||||
},
|
||||
"type": "library",
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Paragon Initiative Enterprises",
|
||||
"email": "security@paragonie.com",
|
||||
"homepage": "https://paragonie.com"
|
||||
}
|
||||
],
|
||||
"description": "PHP 5.x polyfill for random_bytes() and random_int() from PHP 7",
|
||||
"keywords": [
|
||||
"csprng",
|
||||
"polyfill",
|
||||
"pseudorandom",
|
||||
"random"
|
||||
],
|
||||
"support": {
|
||||
"email": "info@paragonie.com",
|
||||
"issues": "https://github.com/paragonie/random_compat/issues",
|
||||
"source": "https://github.com/paragonie/random_compat"
|
||||
},
|
||||
"time": "2020-10-15T08:29:30+00:00"
|
||||
},
|
||||
{
|
||||
"name": "paragonie/sodium_compat",
|
||||
"version": "v1.20.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/paragonie/sodium_compat.git",
|
||||
"reference": "e592a3e06d1fa0d43988c7c7d9948ca836f644b6"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/paragonie/sodium_compat/zipball/e592a3e06d1fa0d43988c7c7d9948ca836f644b6",
|
||||
"reference": "e592a3e06d1fa0d43988c7c7d9948ca836f644b6",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"paragonie/random_compat": ">=1",
|
||||
"php": "^5.2.4|^5.3|^5.4|^5.5|^5.6|^7|^8"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^3|^4|^5|^6|^7|^8|^9"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-libsodium": "PHP < 7.0: Better performance, password hashing (Argon2i), secure memory management (memzero), and better security.",
|
||||
"ext-sodium": "PHP >= 7.0: Better performance, password hashing (Argon2i), secure memory management (memzero), and better security."
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"files": [
|
||||
"autoload.php"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"ISC"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Paragon Initiative Enterprises",
|
||||
"email": "security@paragonie.com"
|
||||
},
|
||||
{
|
||||
"name": "Frank Denis",
|
||||
"email": "jedisct1@pureftpd.org"
|
||||
}
|
||||
],
|
||||
"description": "Pure PHP implementation of libsodium; uses the PHP extension if it exists",
|
||||
"keywords": [
|
||||
"Authentication",
|
||||
"BLAKE2b",
|
||||
"ChaCha20",
|
||||
"ChaCha20-Poly1305",
|
||||
"Chapoly",
|
||||
"Curve25519",
|
||||
"Ed25519",
|
||||
"EdDSA",
|
||||
"Edwards-curve Digital Signature Algorithm",
|
||||
"Elliptic Curve Diffie-Hellman",
|
||||
"Poly1305",
|
||||
"Pure-PHP cryptography",
|
||||
"RFC 7748",
|
||||
"RFC 8032",
|
||||
"Salpoly",
|
||||
"Salsa20",
|
||||
"X25519",
|
||||
"XChaCha20-Poly1305",
|
||||
"XSalsa20-Poly1305",
|
||||
"Xchacha20",
|
||||
"Xsalsa20",
|
||||
"aead",
|
||||
"cryptography",
|
||||
"ecdh",
|
||||
"elliptic curve",
|
||||
"elliptic curve cryptography",
|
||||
"encryption",
|
||||
"libsodium",
|
||||
"php",
|
||||
"public-key cryptography",
|
||||
"secret-key cryptography",
|
||||
"side-channel resistant"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/paragonie/sodium_compat/issues",
|
||||
"source": "https://github.com/paragonie/sodium_compat/tree/v1.20.0"
|
||||
},
|
||||
"time": "2023-04-30T00:54:53+00:00"
|
||||
},
|
||||
{
|
||||
"name": "phpoption/phpoption",
|
||||
"version": "1.9.2",
|
||||
@@ -4120,16 +4255,16 @@
|
||||
},
|
||||
{
|
||||
"name": "psy/psysh",
|
||||
"version": "v0.12.1",
|
||||
"version": "v0.12.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/bobthecow/psysh.git",
|
||||
"reference": "39621c73e0754328252f032c6997b983afc50431"
|
||||
"reference": "9185c66c2165bbf4d71de78a69dccf4974f9538d"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/bobthecow/psysh/zipball/39621c73e0754328252f032c6997b983afc50431",
|
||||
"reference": "39621c73e0754328252f032c6997b983afc50431",
|
||||
"url": "https://api.github.com/repos/bobthecow/psysh/zipball/9185c66c2165bbf4d71de78a69dccf4974f9538d",
|
||||
"reference": "9185c66c2165bbf4d71de78a69dccf4974f9538d",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -4193,9 +4328,70 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/bobthecow/psysh/issues",
|
||||
"source": "https://github.com/bobthecow/psysh/tree/v0.12.1"
|
||||
"source": "https://github.com/bobthecow/psysh/tree/v0.12.2"
|
||||
},
|
||||
"time": "2024-03-15T03:22:57+00:00"
|
||||
"time": "2024-03-17T01:53:00+00:00"
|
||||
},
|
||||
{
|
||||
"name": "pusher/pusher-php-server",
|
||||
"version": "7.2.4",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/pusher/pusher-http-php.git",
|
||||
"reference": "de2f72296808f9cafa6a4462b15a768ff130cddb"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/pusher/pusher-http-php/zipball/de2f72296808f9cafa6a4462b15a768ff130cddb",
|
||||
"reference": "de2f72296808f9cafa6a4462b15a768ff130cddb",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-curl": "*",
|
||||
"ext-json": "*",
|
||||
"guzzlehttp/guzzle": "^7.2",
|
||||
"paragonie/sodium_compat": "^1.6",
|
||||
"php": "^7.3|^8.0",
|
||||
"psr/log": "^1.0|^2.0|^3.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"overtrue/phplint": "^2.3",
|
||||
"phpunit/phpunit": "^9.3"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "5.0-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Pusher\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"description": "Library for interacting with the Pusher REST API",
|
||||
"keywords": [
|
||||
"events",
|
||||
"messaging",
|
||||
"php-pusher-server",
|
||||
"publish",
|
||||
"push",
|
||||
"pusher",
|
||||
"real time",
|
||||
"real-time",
|
||||
"realtime",
|
||||
"rest",
|
||||
"trigger"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/pusher/pusher-http-php/issues",
|
||||
"source": "https://github.com/pusher/pusher-http-php/tree/7.2.4"
|
||||
},
|
||||
"time": "2023-12-15T10:58:53+00:00"
|
||||
},
|
||||
{
|
||||
"name": "ralouphie/getallheaders",
|
||||
@@ -7438,16 +7634,16 @@
|
||||
},
|
||||
{
|
||||
"name": "composer/pcre",
|
||||
"version": "3.1.2",
|
||||
"version": "3.1.3",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/composer/pcre.git",
|
||||
"reference": "4775f35b2d70865807c89d32c8e7385b86eb0ace"
|
||||
"reference": "5b16e25a5355f1f3afdfc2f954a0a80aec4826a8"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/composer/pcre/zipball/4775f35b2d70865807c89d32c8e7385b86eb0ace",
|
||||
"reference": "4775f35b2d70865807c89d32c8e7385b86eb0ace",
|
||||
"url": "https://api.github.com/repos/composer/pcre/zipball/5b16e25a5355f1f3afdfc2f954a0a80aec4826a8",
|
||||
"reference": "5b16e25a5355f1f3afdfc2f954a0a80aec4826a8",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -7489,7 +7685,7 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/composer/pcre/issues",
|
||||
"source": "https://github.com/composer/pcre/tree/3.1.2"
|
||||
"source": "https://github.com/composer/pcre/tree/3.1.3"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -7505,7 +7701,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2024-03-07T15:38:35+00:00"
|
||||
"time": "2024-03-19T10:26:25+00:00"
|
||||
},
|
||||
{
|
||||
"name": "doctrine/deprecations",
|
||||
@@ -7807,16 +8003,16 @@
|
||||
},
|
||||
{
|
||||
"name": "laravel/sail",
|
||||
"version": "v1.29.0",
|
||||
"version": "v1.29.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/laravel/sail.git",
|
||||
"reference": "e40cc7ffb5186c45698dbd47e9477e0e429396d0"
|
||||
"reference": "8be4a31150eab3b46af11a2e7b2c4632eefaad7e"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/laravel/sail/zipball/e40cc7ffb5186c45698dbd47e9477e0e429396d0",
|
||||
"reference": "e40cc7ffb5186c45698dbd47e9477e0e429396d0",
|
||||
"url": "https://api.github.com/repos/laravel/sail/zipball/8be4a31150eab3b46af11a2e7b2c4632eefaad7e",
|
||||
"reference": "8be4a31150eab3b46af11a2e7b2c4632eefaad7e",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -7824,6 +8020,7 @@
|
||||
"illuminate/contracts": "^9.52.16|^10.0|^11.0",
|
||||
"illuminate/support": "^9.52.16|^10.0|^11.0",
|
||||
"php": "^8.0",
|
||||
"symfony/console": "^6.0|^7.0",
|
||||
"symfony/yaml": "^6.0|^7.0"
|
||||
},
|
||||
"require-dev": {
|
||||
@@ -7865,7 +8062,7 @@
|
||||
"issues": "https://github.com/laravel/sail/issues",
|
||||
"source": "https://github.com/laravel/sail"
|
||||
},
|
||||
"time": "2024-03-08T16:32:33+00:00"
|
||||
"time": "2024-03-20T20:09:31+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/telescope",
|
||||
@@ -7937,16 +8134,16 @@
|
||||
},
|
||||
{
|
||||
"name": "mockery/mockery",
|
||||
"version": "1.6.9",
|
||||
"version": "1.6.11",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/mockery/mockery.git",
|
||||
"reference": "0cc058854b3195ba21dc6b1f7b1f60f4ef3a9c06"
|
||||
"reference": "81a161d0b135df89951abd52296adf97deb0723d"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/mockery/mockery/zipball/0cc058854b3195ba21dc6b1f7b1f60f4ef3a9c06",
|
||||
"reference": "0cc058854b3195ba21dc6b1f7b1f60f4ef3a9c06",
|
||||
"url": "https://api.github.com/repos/mockery/mockery/zipball/81a161d0b135df89951abd52296adf97deb0723d",
|
||||
"reference": "81a161d0b135df89951abd52296adf97deb0723d",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -7958,8 +8155,8 @@
|
||||
"phpunit/phpunit": "<8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^8.5 || ^9.6.10",
|
||||
"symplify/easy-coding-standard": "^12.0.8"
|
||||
"phpunit/phpunit": "^8.5 || ^9.6.17",
|
||||
"symplify/easy-coding-standard": "^12.1.14"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
@@ -8016,7 +8213,7 @@
|
||||
"security": "https://github.com/mockery/mockery/security/advisories",
|
||||
"source": "https://github.com/mockery/mockery"
|
||||
},
|
||||
"time": "2023-12-10T02:24:34+00:00"
|
||||
"time": "2024-03-21T18:34:15+00:00"
|
||||
},
|
||||
{
|
||||
"name": "myclabs/deep-copy",
|
||||
@@ -8405,16 +8602,16 @@
|
||||
},
|
||||
{
|
||||
"name": "phpstan/phpdoc-parser",
|
||||
"version": "1.26.0",
|
||||
"version": "1.27.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/phpstan/phpdoc-parser.git",
|
||||
"reference": "231e3186624c03d7e7c890ec662b81e6b0405227"
|
||||
"reference": "86e4d5a4b036f8f0be1464522f4c6b584c452757"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/231e3186624c03d7e7c890ec662b81e6b0405227",
|
||||
"reference": "231e3186624c03d7e7c890ec662b81e6b0405227",
|
||||
"url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/86e4d5a4b036f8f0be1464522f4c6b584c452757",
|
||||
"reference": "86e4d5a4b036f8f0be1464522f4c6b584c452757",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -8446,9 +8643,9 @@
|
||||
"description": "PHPDoc parser with support for nullable, intersection and generic types",
|
||||
"support": {
|
||||
"issues": "https://github.com/phpstan/phpdoc-parser/issues",
|
||||
"source": "https://github.com/phpstan/phpdoc-parser/tree/1.26.0"
|
||||
"source": "https://github.com/phpstan/phpdoc-parser/tree/1.27.0"
|
||||
},
|
||||
"time": "2024-02-23T16:05:55+00:00"
|
||||
"time": "2024-03-21T13:14:53+00:00"
|
||||
},
|
||||
{
|
||||
"name": "phpunit/php-code-coverage",
|
||||
@@ -8773,16 +8970,16 @@
|
||||
},
|
||||
{
|
||||
"name": "phpunit/phpunit",
|
||||
"version": "10.5.13",
|
||||
"version": "10.5.15",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/sebastianbergmann/phpunit.git",
|
||||
"reference": "20a63fc1c6db29b15da3bd02d4b6cf59900088a7"
|
||||
"reference": "86376e05e8745ed81d88232ff92fee868247b07b"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/20a63fc1c6db29b15da3bd02d4b6cf59900088a7",
|
||||
"reference": "20a63fc1c6db29b15da3bd02d4b6cf59900088a7",
|
||||
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/86376e05e8745ed81d88232ff92fee868247b07b",
|
||||
"reference": "86376e05e8745ed81d88232ff92fee868247b07b",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -8854,7 +9051,7 @@
|
||||
"support": {
|
||||
"issues": "https://github.com/sebastianbergmann/phpunit/issues",
|
||||
"security": "https://github.com/sebastianbergmann/phpunit/security/policy",
|
||||
"source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.13"
|
||||
"source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.15"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -8870,7 +9067,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2024-03-12T15:37:41+00:00"
|
||||
"time": "2024-03-22T04:17:47+00:00"
|
||||
},
|
||||
{
|
||||
"name": "sebastian/cli-parser",
|
||||
|
||||
82
config/broadcasting.php
Normal file
82
config/broadcasting.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Broadcaster
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option controls the default broadcaster that will be used by the
|
||||
| framework when an event needs to be broadcast. You may set this to
|
||||
| any of the connections defined in the "connections" array below.
|
||||
|
|
||||
| Supported: "reverb", "pusher", "ably", "redis", "log", "null"
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('BROADCAST_CONNECTION', 'null'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Broadcast Connections
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may define all of the broadcast connections that will be used
|
||||
| to broadcast events to other systems or over WebSockets. Samples of
|
||||
| each available type of connection are provided inside this array.
|
||||
|
|
||||
*/
|
||||
|
||||
'connections' => [
|
||||
|
||||
'reverb' => [
|
||||
'driver' => 'reverb',
|
||||
'key' => env('REVERB_APP_KEY'),
|
||||
'secret' => env('REVERB_APP_SECRET'),
|
||||
'app_id' => env('REVERB_APP_ID'),
|
||||
'options' => [
|
||||
'host' => env('REVERB_HOST'),
|
||||
'port' => env('REVERB_PORT', 443),
|
||||
'scheme' => env('REVERB_SCHEME', 'https'),
|
||||
'useTLS' => env('REVERB_SCHEME', 'https') === 'https',
|
||||
],
|
||||
'client_options' => [
|
||||
// Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html
|
||||
],
|
||||
],
|
||||
|
||||
'pusher' => [
|
||||
'driver' => 'pusher',
|
||||
'key' => env('PUSHER_APP_KEY'),
|
||||
'secret' => env('PUSHER_APP_SECRET'),
|
||||
'app_id' => env('PUSHER_APP_ID'),
|
||||
'options' => [
|
||||
'cluster' => env('PUSHER_APP_CLUSTER'),
|
||||
'host' => env('PUSHER_HOST') ?: 'api-'.env('PUSHER_APP_CLUSTER', 'mt1').'.pusher.com',
|
||||
'port' => env('PUSHER_PORT', 443),
|
||||
'scheme' => env('PUSHER_SCHEME', 'https'),
|
||||
'encrypted' => true,
|
||||
'useTLS' => env('PUSHER_SCHEME', 'https') === 'https',
|
||||
],
|
||||
'client_options' => [
|
||||
// Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html
|
||||
],
|
||||
],
|
||||
|
||||
'ably' => [
|
||||
'driver' => 'ably',
|
||||
'key' => env('ABLY_KEY'),
|
||||
],
|
||||
|
||||
'log' => [
|
||||
'driver' => 'log',
|
||||
],
|
||||
|
||||
'null' => [
|
||||
'driver' => 'null',
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
];
|
||||
@@ -21,6 +21,7 @@ return new class extends Migration
|
||||
$table->string('cover_image')->nullable();
|
||||
$table->unsignedBigInteger('recommended_by');
|
||||
$table->enum('status', ['PENDING', 'COMPLETED', 'REJECTED','ACTIVE'])->default('PENDING');
|
||||
$table->dateTime('published_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->foreign('recommended_by')->references('id')->on('users')->onDelete('cascade');
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<?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('deadlines', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('book_recommendation_id')->constrained()->onDelete('cascade');
|
||||
$table->date('deadline');
|
||||
$table->unsignedInteger('target_page')->nullable();
|
||||
$table->string('target_chapter')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('deadlines');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
<?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('user_deadlines', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->constrained()->onDelete('cascade');
|
||||
$table->foreignId('deadline_id')->constrained('deadlines')->onDelete('cascade');
|
||||
$table->dateTime('completed_at')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('user_deadlines');
|
||||
}
|
||||
};
|
||||
@@ -14,7 +14,7 @@ class BookRecommendationsTableSeeder extends Seeder
|
||||
public function run(): void
|
||||
{
|
||||
// Let's say we want to create 50 fake book recommendations
|
||||
for ($i = 0; $i < 50; $i++) {
|
||||
/*for ($i = 0; $i < 50; $i++) {
|
||||
BookRecommendation::create([
|
||||
'book_name' => fake()->sentence($nbWords = 3, $variableNbWords = true),
|
||||
'author' => fake()->name,
|
||||
@@ -24,6 +24,107 @@ class BookRecommendationsTableSeeder extends Seeder
|
||||
'recommended_by' => 1, // Adjust the range as necessary
|
||||
'cover_image' => null, // You could also simulate image paths if needed
|
||||
]);
|
||||
}
|
||||
}*/
|
||||
|
||||
BookRecommendation::create(
|
||||
[
|
||||
'book_name' => '100 Jahre Einsamkeit',
|
||||
'author' => 'Gabriel García Márquez',
|
||||
'description' => 'Die Geschichte der Familie Buendía und ihres Dorfes Macondo, das sie gründeten, ist ein Epos voller Leidenschaft, Tragik und Komik. Die Geschichte der Familie Buendía und ihres Dorfes Macondo, das sie gründeten, ist ein Epos voller Leidenschaft, Tragik und Komik.',
|
||||
'isbn' => '978-3-462-05021-9',
|
||||
'pages' => 528,
|
||||
'recommended_by' => 1,
|
||||
'cover_image' => null,
|
||||
'published_at' => '1967-05-30'
|
||||
]
|
||||
);
|
||||
BookRecommendation::create(
|
||||
[
|
||||
'book_name' => 'Die Mitternachtsbibliothek',
|
||||
'author' => 'Matt Haig',
|
||||
'description' => 'Nora Seed erkennt, dass sie viele Leben gelebt hat. Nicht die, die sie sich erhofft hatte. In der Mitternachtsbibliothek erhält sie die Möglichkeit, diese zu ändern.',
|
||||
'isbn' => '978-3-426-28256-4',
|
||||
'pages' => 320,
|
||||
'recommended_by' => 1,
|
||||
'cover_image' => null,
|
||||
'published_at' => '2020-08-13'
|
||||
]
|
||||
);
|
||||
BookRecommendation::create(
|
||||
[
|
||||
'book_name' => 'Die Schwarze Königin',
|
||||
'author' => 'Markus Heitz',
|
||||
'description' => 'Blutig, actionreich, dramatisch: Bestseller-Autor Markus Heitz kehrt mit seinem Dark-Fantasy-Roman »Die Schwarze Königin« zu den Vampiren zurück!',
|
||||
'isbn' => '978-3-426-22781-7',
|
||||
'pages' => 544,
|
||||
'recommended_by' => 4,
|
||||
'cover_image' => null,
|
||||
'published_at' => '2023-08-21'
|
||||
]
|
||||
);
|
||||
BookRecommendation::create(
|
||||
[
|
||||
'book_name' => 'The Stand - Das letzte Gefecht',
|
||||
'author' => 'Stephen King',
|
||||
'description' => 'In einem entvölkerten Amerika versucht eine Handvoll Überlebende die Zivilisation zu retten. Ihr Gegenspieler ist eine mythische Gestalt, die man den Dunklen Mann nennt, eine Verkörperung des absolut Bösen. In der Wüste Nevada kommt es zum Entscheidungskampf um das Schicksal der Menschheit.',
|
||||
'isbn' => '978-3-45343818-7',
|
||||
'pages' => 1712,
|
||||
'recommended_by' => 4,
|
||||
'cover_image' => null,
|
||||
'published_at' => '1978-09-01'
|
||||
]
|
||||
);
|
||||
BookRecommendation::create([
|
||||
'book_name' => 'Der Fremde',
|
||||
'author' => 'Albert Camus',
|
||||
'description' => 'Die Geschichte eines jungen Franzosen in Algerien, den ein lächerlicher Zufall zum Mörder macht, wurde 1942 im besetzten Frankreich zu einer literarischen Sensation. Der Roman bedeutete den schriftstellerischen Durchbruch für Albert Camus und gilt heute als einer der Haupttexte des Existenzialismus.',
|
||||
'isbn' => '978-3-49922189-7',
|
||||
'pages' => 160,
|
||||
'recommended_by' => 2,
|
||||
'cover_image' => null,
|
||||
'published_at' => '1942-06-01'
|
||||
]);
|
||||
BookRecommendation::create([
|
||||
'book_name' => 'No Longer Human',
|
||||
'author' => 'Osamu Dazai',
|
||||
'description' => 'No Longer Human is a Japanese novel by Osamu Dazai. It is considered Dazai\'s masterpiece and ranks as the second-best selling novel in Japan, behind Natsume Sōseki\'s Kokoro.',
|
||||
'isbn' => '978-0-81120481-1',
|
||||
'pages' => 177,
|
||||
'recommended_by' => 2,
|
||||
'cover_image' => null,
|
||||
'published_at' => '1948-06-01'
|
||||
]);
|
||||
BookRecommendation::create([
|
||||
'book_name' => 'After Dark',
|
||||
'author' => 'Haruki Murakami',
|
||||
'description' => 'Geschichten zwischen Mitternacht und Morgengrauen: Murakami begleitet seine Helden eine Nacht lang und zeichnet ein eindrucksvolles, geheimnisvoll schillerndes Großstadtporträt: Die 19-jährige Mari, ihre schöne, aber unglückliche Schwester Eri, der unscheinbare Posaunist, die Prostituierte aus einem Love Hotel und ein gewissenloser Freier streifen durch das nächtliche Tokyo.',
|
||||
'isbn' => '978-3-83217940-3',
|
||||
'pages' => 191,
|
||||
'recommended_by' => 2,
|
||||
'cover_image' => null,
|
||||
'published_at' => '2004-01-01'
|
||||
]);
|
||||
BookRecommendation::create([
|
||||
'book_name' => 'Futu.re',
|
||||
'author' => 'Dmitry Glukhovsky',
|
||||
'description' => 'Moskau 2038: Der junge Student Innokenti wächst in einer Welt auf, die von Technologie und künstlicher Intelligenz bestimmt wird. Doch er ist ein Träumer und glaubt an die Kraft der Literatur. Als er eines Tages auf die geheimnisvolle Chiski trifft, die in einer Parallelwelt lebt, gerät sein Leben aus den Fugen. Chiski ist auf der Flucht vor einer mächtigen Organisation, die die Welt beherrscht und die Literatur vernichten will. Innokenti wird in einen Strudel aus Gewalt und Intrigen gezogen und muss sich entscheiden: Wird er zum Helden oder zum Verräter?',
|
||||
'isbn' => '978-3-45331758-1',
|
||||
'pages' => 928,
|
||||
'recommended_by' => 3,
|
||||
'cover_image' => null,
|
||||
'published_at' => '2017-02-06'
|
||||
]);
|
||||
BookRecommendation::create([
|
||||
'book_name' => 'Die Seele des Königs',
|
||||
'author' => 'Brandon Sanderson',
|
||||
'description' => 'Die Seele des Königs ist der erste Band der neuen Serie von Bestsellerautor Brandon Sanderson. Die Serie spielt in einer Welt, in der die Magie auf dem Konzept der Metalle basiert. Die Serie spielt in einer Welt, in der die Magie auf dem Konzept der Metalle basiert.',
|
||||
'isbn' => '978-3-45331524-2',
|
||||
'pages' => 448,
|
||||
'recommended_by' => 3,
|
||||
'cover_image' => null,
|
||||
'published_at' => '2012-10-10'
|
||||
]);
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,8 @@ class DatabaseSeeder extends Seeder
|
||||
|
||||
// create admin user
|
||||
$user = \App\Models\User::factory()->create([
|
||||
'name' => 'Super',
|
||||
'email' => 'admin@admin.com',
|
||||
'name' => 'Philipp',
|
||||
'email' => 'hi@flycro.me',
|
||||
'password' => Hash::make('password'),
|
||||
]);
|
||||
|
||||
@@ -36,6 +36,40 @@ class DatabaseSeeder extends Seeder
|
||||
|
||||
$user->assignRole($role1);
|
||||
|
||||
$user = \App\Models\User::factory()->create([
|
||||
'name' => 'Marcel',
|
||||
'email' => 'marcel@test.de',
|
||||
'password' => Hash::make('password'),
|
||||
]);
|
||||
$user->ulid = Str::ulid()->toBase32();
|
||||
$user->email_verified_at = now();
|
||||
$user->save(['timestamps' => false]);
|
||||
|
||||
$user->assignRole($role1);
|
||||
|
||||
$user = \App\Models\User::factory()->create([
|
||||
'name' => 'Robert',
|
||||
'email' => 'robert@test.de',
|
||||
'password' => Hash::make('password'),
|
||||
]);
|
||||
$user->ulid = Str::ulid()->toBase32();
|
||||
$user->email_verified_at = now();
|
||||
$user->save(['timestamps' => false]);
|
||||
|
||||
$user->assignRole($role2);
|
||||
|
||||
$user = \App\Models\User::factory()->create([
|
||||
'name' => 'Lukas',
|
||||
'email' => 'lukas@test.de',
|
||||
'password' => Hash::make('password'),
|
||||
]);
|
||||
$user->ulid = Str::ulid()->toBase32();
|
||||
$user->email_verified_at = now();
|
||||
$user->save(['timestamps' => false]);
|
||||
|
||||
$user->assignRole($role2);
|
||||
|
||||
|
||||
$this->call(BookRecommendationsTableSeeder::class);
|
||||
}
|
||||
}
|
||||
|
||||
40
docker-compose-production.yml
Normal file
40
docker-compose-production.yml
Normal file
@@ -0,0 +1,40 @@
|
||||
|
||||
services:
|
||||
php:
|
||||
image: dunglas/frankenphp
|
||||
restart: unless-stopped
|
||||
build:
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "13379:8000"
|
||||
networks:
|
||||
- laravel
|
||||
volumes:
|
||||
- '.:/app'
|
||||
- caddy_data:/data
|
||||
- caddy_config:/config
|
||||
redis:
|
||||
image: 'redis:alpine'
|
||||
volumes:
|
||||
- 'redis:/data'
|
||||
networks:
|
||||
- laravel
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- redis-cli
|
||||
- ping
|
||||
retries: 3
|
||||
timeout: 5s
|
||||
|
||||
networks:
|
||||
laravel:
|
||||
driver: bridge
|
||||
# Volumes needed for Caddy certificates and configuration
|
||||
volumes:
|
||||
caddy_data:
|
||||
driver: local
|
||||
caddy_config:
|
||||
driver: local
|
||||
redis:
|
||||
driver: local
|
||||
14
ecosystem.config.cjs
Normal file
14
ecosystem.config.cjs
Normal file
@@ -0,0 +1,14 @@
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: 'BookStack',
|
||||
port: '3377',
|
||||
exec_mode: 'cluster',
|
||||
instances: 'max',
|
||||
script: './.output/server/index.mjs',
|
||||
env_production: {
|
||||
"GIT_HASH": process.env.GIT_HASH,
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||
import pkg from './package.json'
|
||||
|
||||
export default defineNuxtConfig({
|
||||
srcDir: 'nuxt/',
|
||||
|
||||
@@ -11,7 +13,7 @@ export default defineNuxtConfig({
|
||||
|
||||
app: {
|
||||
head: {
|
||||
title: 'Laravel/Nuxt Boilerplate',
|
||||
title: 'BookStack',
|
||||
meta: [
|
||||
{ charset: 'utf-8' },
|
||||
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
|
||||
@@ -30,11 +32,13 @@ export default defineNuxtConfig({
|
||||
* @see https://v3.nuxtjs.org/api/configuration/nuxt.config#modules
|
||||
*/
|
||||
extends: ['@nuxt/ui-pro'],
|
||||
|
||||
modules: [
|
||||
'@nuxt/ui',
|
||||
'@nuxt/image',
|
||||
'@pinia/nuxt',
|
||||
'dayjs-nuxt',
|
||||
'nuxt-security',
|
||||
],
|
||||
|
||||
ui: {
|
||||
@@ -74,6 +78,8 @@ export default defineNuxtConfig({
|
||||
apiBase: process.env.API_URL,
|
||||
apiPrefix: '/api/v1',
|
||||
storageBase: `${process.env.API_URL}/storage/`,
|
||||
packageVersion: pkg.version,
|
||||
gitHash: process.env.GIT_HASH,
|
||||
providers: {
|
||||
google: {
|
||||
name: 'Google',
|
||||
@@ -81,6 +87,17 @@ export default defineNuxtConfig({
|
||||
color: 'gray',
|
||||
},
|
||||
},
|
||||
echo: {
|
||||
broadcaster: process.env.BROADCAST_CONNECTION,
|
||||
key: process.env.PUSHER_APP_KEY,
|
||||
cluster: process.env.PUSHER_APP_CLUSTER,
|
||||
wsHost: process.env.PUSHER_APP_HOST,
|
||||
wsPort: process.env.PUSHER_APP_PORT,
|
||||
forceTLS: process.env.PUSHER_APP_TLS,
|
||||
encrypted: process.env.PUSHER_APP_ENCRYPTED,
|
||||
disableStats: process.env.PUSHER_APP_DISABLE_STATS,
|
||||
enabledTransports: ['ws', 'wss'],
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -2,9 +2,12 @@
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UMain>
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
</UMain>
|
||||
<Footer />
|
||||
|
||||
<UNotifications />
|
||||
</template>
|
||||
|
||||
33
nuxt/components/Footer.vue
Normal file
33
nuxt/components/Footer.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import party from 'party-js'
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
|
||||
function confirmDeadline(event) {
|
||||
party.confetti(event)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<UDivider />
|
||||
<UFooter>
|
||||
<template #left>
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- <OctolabsLogo class="fill-primary rotate-45" width="36" /> -->
|
||||
<div class="text-xs">
|
||||
Copyright © {{ new Date().getFullYear() }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #center>
|
||||
Made with <span class="cursor-pointer transition duration-500 hover:scale-125 hover:animate-pulse" @click="confirmDeadline">❤️</span> by <a class="text-primary hover:text-primary-600 ml-1" href="https://flycro.me" target="_blank">Flycro</a>
|
||||
</template>
|
||||
<template #right>
|
||||
<div class="font-mono text-xs">
|
||||
{{ config.public.packageVersion }} - {{ config.public.gitHash !== '' ? config.public.gitHash : '0000000' }}
|
||||
</div>
|
||||
</template>
|
||||
</UFooter>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,18 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
const links = [{
|
||||
label: 'Documentation',
|
||||
icon: 'i-heroicons-book-open',
|
||||
to: 'https://ui.nuxt.com/getting-started',
|
||||
}, {
|
||||
label: 'Pro',
|
||||
icon: 'i-heroicons-square-3-stack-3d',
|
||||
to: 'https://ui.nuxt.com/pro',
|
||||
}, {
|
||||
label: 'Releases',
|
||||
icon: 'i-heroicons-rocket-launch',
|
||||
to: 'https://github.com/nuxt/ui/releases',
|
||||
target: '_blank',
|
||||
}]
|
||||
import ColorPicker from '~/components/color-picker/ColorPicker.vue'
|
||||
|
||||
const links = []
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -22,12 +11,13 @@ const links = [{
|
||||
</template>
|
||||
|
||||
<template #right>
|
||||
<ColorPicker />
|
||||
<UColorModeButton />
|
||||
<UserDropdown />
|
||||
</template>
|
||||
|
||||
<template #panel>
|
||||
<UNavigationTree :links="links" />
|
||||
<Navigation />
|
||||
</template>
|
||||
</UHeader>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div>
|
||||
<span class="text-primary">Nuxt</span> Breeze
|
||||
<span class="text-primary">Book</span> Stack
|
||||
</div>
|
||||
</template>
|
||||
|
||||
53
nuxt/components/Navigation.client.vue
Normal file
53
nuxt/components/Navigation.client.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
import type { NavigationTree } from '#ui-pro/types'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const links: NavigationTree[] = [
|
||||
{
|
||||
label: 'Dashboard',
|
||||
to: '/',
|
||||
icon: 'i-heroicons-home',
|
||||
},
|
||||
{
|
||||
label: 'Bücher',
|
||||
icon: 'i-heroicons-book-open',
|
||||
children: [
|
||||
{
|
||||
label: 'Übersicht',
|
||||
to: '/book-recommendations',
|
||||
icon: 'i-heroicons-eye',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
if (authStore.user?.roles.includes('admin')) {
|
||||
links.push({
|
||||
label: 'Admin',
|
||||
to: '/admin',
|
||||
icon: 'i-heroicons-cog-solid',
|
||||
children: [
|
||||
{
|
||||
label: 'Übersicht',
|
||||
to: '/admin',
|
||||
icon: 'i-heroicons-eye',
|
||||
},
|
||||
{
|
||||
label: 'Votes',
|
||||
to: '/admin/votes',
|
||||
icon: 'i-heroicons-check-circle',
|
||||
},
|
||||
{
|
||||
label: 'Jobs',
|
||||
to: '/admin/jobs',
|
||||
icon: 'i-heroicons-briefcase',
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UNavigationTree :links="links" />
|
||||
</template>
|
||||
@@ -1,26 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
const links = [
|
||||
{
|
||||
label: 'Bücher',
|
||||
icon: 'i-heroicons-book-open',
|
||||
children: [
|
||||
{
|
||||
label: 'Übersicht',
|
||||
to: '/book-recommendations',
|
||||
icon: 'i-heroicons-eye',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'People',
|
||||
to: '/login',
|
||||
icon: 'i-heroicons-user-group',
|
||||
},
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UContainer>
|
||||
<UNavigationTree :links="links" />
|
||||
</UContainer>
|
||||
</template>
|
||||
31
nuxt/components/OctolabsLogo.vue
Normal file
31
nuxt/components/OctolabsLogo.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
const props = defineProps<{
|
||||
width?: string
|
||||
height?: string
|
||||
class?: string
|
||||
}>()
|
||||
|
||||
const cssClasses = computed(() => {
|
||||
return twMerge([
|
||||
'fill-current',
|
||||
props.class,
|
||||
])
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" :class="cssClasses" :width="props.width" :height="props.height" viewBox="0 0 101.27 145.9">
|
||||
<g id="Ebene_2" data-name="Ebene 2">
|
||||
<g id="Ebene_8" data-name="Ebene 8">
|
||||
<path d="M50.63 0C22.67 0 0 21.46 0 47.93 0 61.85 6.69 76.49 19.35 85a69.67 69.67 0 0 0 21.59 10c6.49 1.62 13.4 1.7 19.38 1.7 28 0 40.95-22.34 40.95-48.81S78.6 0 50.63 0Zm15.18 65.91c-5.46 5.24-13.51 7.59-20.51 5.71a20.29 20.29 0 0 1-14.43-14.47c-1.49-5.43-.48-12.33 2.88-16.9 2.45-3.34 4.76-6 8.51-8a20.24 20.24 0 0 1 15-2 24.4 24.4 0 0 1 6.67 3.14 22.12 22.12 0 0 1 2.48 2.15 23.32 23.32 0 0 1 6.26 14.76c.07 8.44-5.17 13.98-6.86 15.61Z" class="cls-1" />
|
||||
<path d="M57.8 49.89c.67-2.92-.89-5.08-3.49-6.15-3.13-1.3-7 .07-9.14 2.5A8.35 8.35 0 0 0 45 57.1c4.53 5.9 14 3.39 15.73-3.68 1.79-7.42-6.38-14.55-13.25-10.23-3.17 2-5.39 7.23-4.11 10.8s5.45 4.43 9.14 3.94c4.58-.62 7.51-5.9 5.11-10M19.62 74.51c-4.41 4-7.87 9.7-6.41 15.89 1.59 6.71 7.23 11.22 5.08 18.75-1.83 6.42-8.36 10-10.34 16.38-1.54 5-.45 9.84 3.78 13 3.1 2.31 6.1-2.9 3-5.18-5.27-3.92 2.69-11.66 5-14.57A21.51 21.51 0 0 0 24.72 107a21 21 0 0 0-2.8-12C20.58 92.51 19 90.3 19 87.43c0-3.62 2.32-6.35 4.87-8.68 2.86-2.61-1.39-6.84-4.24-4.24ZM55.6 88c-9.62 2.3-11.4 12.24-7 20 3 5.23 7.65 11.16 5.78 17.61-1.37 4.72-5.26 7.5-5.63 12.71-.27 3.85 5.73 3.83 6 0s3.7-6.47 5-9.88a18.49 18.49 0 0 0 0-11.76c-1.83-6.1-14.25-20.06-2.57-22.86 3.75-.89 2.16-6.68-1.6-5.78ZM78.89 84.18c10 4.89.34 21.37 1.44 29.69.56 4.26 2.72 7.27 5.31 10.55 4.11 5.2 5.53 10.92 2 17-1.93 3.35 3.26 6.38 5.18 3a20 20 0 0 0 1.93-16.37c-2.06-6.19-8.63-9.79-8.63-16.72 0-10.37 9-25.92-4.23-32.35-3.46-1.69-6.51 3.48-3 5.18Z" class="cls-1" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
@@ -1,15 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
const links = [
|
||||
{
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const links = [{
|
||||
label: 'Account',
|
||||
to: '/account',
|
||||
icon: 'i-heroicons-user-solid',
|
||||
},
|
||||
{
|
||||
}, {
|
||||
label: 'Logout',
|
||||
to: '/logout',
|
||||
icon: 'i-heroicons-arrow-left-on-rectangle',
|
||||
}]
|
||||
|
||||
if (authStore.user?.roles.includes('admin')) {
|
||||
links.push({
|
||||
label: 'Admin',
|
||||
to: '/admin',
|
||||
icon: 'i-heroicons-cog-solid',
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
48
nuxt/components/admin/AddVotes.vue
Normal file
48
nuxt/components/admin/AddVotes.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
const isOpen = ref(false)
|
||||
|
||||
const state = reactive({
|
||||
total_votes: 2,
|
||||
})
|
||||
|
||||
const { refresh: onClick, status } = useFetch<any>(`admin/add-total-votes-all`, {
|
||||
method: 'POST',
|
||||
body: state,
|
||||
immediate: false,
|
||||
watch: false,
|
||||
async onResponse({ response }) {
|
||||
if (response.ok) {
|
||||
useToast().add({
|
||||
icon: 'i-heroicons-check-circle-solid',
|
||||
title: 'Es wurden allen Nutzern 2 Votes hinzugefügt.',
|
||||
color: 'emerald',
|
||||
})
|
||||
}
|
||||
|
||||
isOpen.value = false
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UButton icon="i-heroicons-check-circle" solid label="Nutzern Votes hinzufügen" @click="isOpen = true" />
|
||||
<UDashboardModal
|
||||
v-model="isOpen"
|
||||
title="Votes hinzufügen"
|
||||
description="Bist du dir sicher das du jedem Benutzer 2 Votes geben möchtest?"
|
||||
icon="i-heroicons-check-circle"
|
||||
:ui="{
|
||||
icon: { base: 'text-primary dark:text-primary-400' } as any,
|
||||
footer: { base: 'ml-16' } as any,
|
||||
}"
|
||||
>
|
||||
<template #footer>
|
||||
<UButton color="primary" label="Bestätigen" :loading="status === 'pending'" @click="onClick" />
|
||||
<UButton color="white" label="Abbrechen" @click="isOpen = false" />
|
||||
</template>
|
||||
</UDashboardModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
@@ -6,6 +6,8 @@ import NewBookRecommendation from '~/components/modal/NewBookRecommendation.vue'
|
||||
import CastVote from '~/components/modal/CastVote.vue'
|
||||
|
||||
const dayjs = useDayjs()
|
||||
const { $echo } = useNuxtApp()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const bookRecommendationStore = useBookRecommendationStore()
|
||||
bookRecommendationStore.fetchRecommendations()
|
||||
@@ -14,10 +16,17 @@ const columns = [
|
||||
{
|
||||
key: 'book_name',
|
||||
label: 'Name',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
key: 'author',
|
||||
label: 'Autor',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
key: 'published_at',
|
||||
label: 'Erstveröffentlichung',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
key: 'description',
|
||||
@@ -26,45 +35,94 @@ const columns = [
|
||||
{
|
||||
key: 'pages',
|
||||
label: 'Seiten',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
key: 'recommender.name',
|
||||
label: 'Empfohlen von',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
key: 'created_at',
|
||||
label: 'Erstellt am',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Status',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
key: 'votes',
|
||||
label: 'Votes',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
label: '',
|
||||
},
|
||||
]
|
||||
const sort = ref({
|
||||
column: 'votes',
|
||||
direction: 'desc',
|
||||
})
|
||||
|
||||
function resolveStatus(status: string) {
|
||||
return bookRecommendationStore.statusOptions.find(option => option.value === status)
|
||||
}
|
||||
|
||||
const rows = computed(() => {
|
||||
// return all bookRecommendationStore.recommendations but add actions
|
||||
return bookRecommendationStore.recommendations.map((recommendation) => {
|
||||
return {
|
||||
...recommendation,
|
||||
actions: {
|
||||
class: '!max-w-96',
|
||||
},
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
$echo.private(`BookRecommendation`)
|
||||
.listen('.BookRecommendationUpdated', (e) => {
|
||||
bookRecommendationStore.updateRecommendationWS(e.bookRecommendation)
|
||||
})
|
||||
.listen('.BookRecommendationDeleted', (e) => {
|
||||
bookRecommendationStore.deleteRecommendationWS(e.bookRecommendation)
|
||||
})
|
||||
.listen('.BookRecommendationCreated', (e) => {
|
||||
bookRecommendationStore.createRecommendationWS(e.bookRecommendation)
|
||||
})
|
||||
authStore.socketId = $echo.socketId()
|
||||
|
||||
$echo.private(`Vote`)
|
||||
.listen('.VoteCreated', (e) => {
|
||||
bookRecommendationStore.createVoteWS(e.vote)
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<NewBookRecommendation />
|
||||
<UTable :loading="bookRecommendationStore.fetchRecommendationsStatus === 'pending'" :columns="columns" :rows="bookRecommendationStore.recommendations">
|
||||
<UTable :ui="{ td: { base: 'max-w-[0] truncate' } }" :sort="sort" :loading="bookRecommendationStore.fetchRecommendationsStatus === 'pending'" :columns="columns" :rows="rows">
|
||||
<template #created_at-data="{ row }">
|
||||
<div>{{ dayjs(row.created_at).format('DD.MM.YYYY') }}</div>
|
||||
</template>
|
||||
<template #description-data="{ row }">
|
||||
{{ `${row.description.substring(0, 50)}...` }}
|
||||
<template #published_at-data="{ row }">
|
||||
<div>{{ dayjs(row.published_at).format('DD.MM.YYYY') }}</div>
|
||||
</template>
|
||||
<template #votes-data="{ row }">
|
||||
{{ row.votes.length }}
|
||||
</template>
|
||||
<template #status-data="{ row }">
|
||||
<UBadge :color="resolveStatus(row.status)?.color">
|
||||
{{ resolveStatus(row.status)?.name }}
|
||||
</UBadge>
|
||||
</template>
|
||||
<template #actions-data="{ row }">
|
||||
<div class="flex space-x-2">
|
||||
<div class="flex max-w-full space-x-2">
|
||||
<CastVote :row="row" />
|
||||
<EditBookRecommendation :row="row" />
|
||||
<DeleteBookRecommendation :row="row" />
|
||||
|
||||
56
nuxt/components/color-picker/ColorPicker.vue
Normal file
56
nuxt/components/color-picker/ColorPicker.vue
Normal file
@@ -0,0 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
import colors from '#tailwind-config/theme/colors'
|
||||
|
||||
const appConfig = useAppConfig()
|
||||
const colorMode = useColorMode()
|
||||
|
||||
// Computed
|
||||
|
||||
const primaryColors = computed(() => appConfig.ui.colors.filter(color => color !== 'primary').map(color => ({ value: color, text: color, hex: colors[color][colorMode.value === 'dark' ? 400 : 500] })))
|
||||
const primary = computed({
|
||||
get() {
|
||||
return primaryColors.value.find(option => option.value === appConfig.ui.primary)
|
||||
},
|
||||
set(option) {
|
||||
appConfig.ui.primary = option.value
|
||||
|
||||
window.localStorage.setItem('nuxt-ui-primary', appConfig.ui.primary)
|
||||
},
|
||||
})
|
||||
|
||||
const grayColors = computed(() => ['slate', 'cool', 'zinc', 'neutral', 'stone'].map(color => ({ value: color, text: color, hex: colors[color][colorMode.value === 'dark' ? 400 : 500] })))
|
||||
const gray = computed({
|
||||
get() {
|
||||
return grayColors.value.find(option => option.value === appConfig.ui.gray)
|
||||
},
|
||||
set(option) {
|
||||
appConfig.ui.gray = option.value
|
||||
|
||||
window.localStorage.setItem('nuxt-ui-gray', appConfig.ui.gray)
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UPopover mode="hover" :popper="{ strategy: 'absolute' }" :ui="{ width: 'w-[156px]' }">
|
||||
<template #default="{ open }">
|
||||
<UButton color="gray" variant="ghost" square :class="[open && 'bg-gray-50 dark:bg-gray-800']" aria-label="Color picker">
|
||||
<UIcon name="i-heroicons-paint-brush-20-solid" class="text-primary-500 dark:text-primary-400 size-5" />
|
||||
</UButton>
|
||||
</template>
|
||||
|
||||
<template #panel>
|
||||
<div class="p-2">
|
||||
<div class="grid grid-cols-5 gap-px">
|
||||
<ColorPickerPill v-for="color in primaryColors" :key="color.value" :color="color" :selected="primary" @select="primary = color" />
|
||||
</div>
|
||||
|
||||
<hr class="my-2 border-gray-200 dark:border-gray-800">
|
||||
|
||||
<div class="grid grid-cols-5 gap-px">
|
||||
<ColorPickerPill v-for="color in grayColors" :key="color.value" :color="color" :selected="gray" @select="gray = color" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</UPopover>
|
||||
</template>
|
||||
25
nuxt/components/color-picker/ColorPickerPill.vue
Normal file
25
nuxt/components/color-picker/ColorPickerPill.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{ color: { value: string, hex: string }, selected: { value: string } }>()
|
||||
defineEmits(['select'])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UTooltip :text="color.value" class="capitalize" :open-delay="500">
|
||||
<UButton
|
||||
color="white"
|
||||
square
|
||||
:ui="{
|
||||
color: {
|
||||
white: {
|
||||
solid: 'ring-0 bg-gray-100 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-800',
|
||||
ghost: 'hover:bg-gray-50 dark:hover:bg-gray-800/50',
|
||||
},
|
||||
},
|
||||
}"
|
||||
:variant="color.value === selected.value ? 'solid' : 'ghost'"
|
||||
@click.stop.prevent="$emit('select')"
|
||||
>
|
||||
<span class="inline-block size-3 rounded-full" :style="{ backgroundColor: color.hex }" />
|
||||
</UButton>
|
||||
</UTooltip>
|
||||
</template>
|
||||
91
nuxt/components/dashboard/BookInfoCard.vue
Normal file
91
nuxt/components/dashboard/BookInfoCard.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
import DeadlineTable from '~/components/deadline/DeadlineTable.vue'
|
||||
import NewDeadline from '~/components/modal/NewDeadline.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
book: BookRecommendation
|
||||
}>()
|
||||
|
||||
const auth = useAuthStore()
|
||||
const { $storage } = useNuxtApp()
|
||||
const dayjs = useDayjs()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UCard class="my-4">
|
||||
<div class="flex w-full justify-between md:space-x-8 lg:space-x-4">
|
||||
<div class="hidden w-1/5 md:block">
|
||||
<img :src="$storage(props.book.cover_image)" :alt="props.book.book_name" class="rounded-lg">
|
||||
</div>
|
||||
<div class="w-full space-y-4 md:w-4/5">
|
||||
<div class="space-y-2">
|
||||
<h1 class="font-sans text-3xl font-bold">
|
||||
{{ props.book.book_name }}
|
||||
</h1>
|
||||
<div class="flex flex-col flex-wrap gap-y-2 md:flex-row md:justify-between">
|
||||
<div>
|
||||
<UBadge>
|
||||
{{ props.book.author }}
|
||||
</UBadge>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<ClientOnly>
|
||||
<NewDeadline v-if="auth.user?.roles.includes('admin')" :book-recommendation-id="props.book.id" />
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<UDivider />
|
||||
<div class="space-y-2">
|
||||
<p class="font-sans text-xl font-bold ">
|
||||
Beschreibung
|
||||
</p>
|
||||
<p class="w-full text-lg md:w-3/5">
|
||||
{{ props.book.description }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col flex-wrap justify-start gap-x-32 gap-y-4 pt-4 md:flex-row">
|
||||
<div>
|
||||
<p class="font-sans text-lg font-bold ">
|
||||
Seiten
|
||||
</p>
|
||||
<p class="text-lg">
|
||||
{{ props.book.pages }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-sans text-lg font-bold">
|
||||
Erstveröffentlichung
|
||||
</p>
|
||||
<p class="text-lg">
|
||||
{{ dayjs(props.book.published_at).format('MMM YYYY') }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-sans text-lg font-bold">
|
||||
Empfohlen von
|
||||
</p>
|
||||
<p class="text-lg">
|
||||
{{ props.book.recommender.name }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-sans text-lg font-bold">
|
||||
Votes
|
||||
</p>
|
||||
<p class="text-lg">
|
||||
{{ props.book.votes.length }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<DeadlineTable :book-recommendation-id="props.book.id" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
67
nuxt/components/deadline/DeadlineTable.vue
Normal file
67
nuxt/components/deadline/DeadlineTable.vue
Normal file
@@ -0,0 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
import ConfirmUserDeadline from '~/components/modal/ConfirmUserDeadline.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
bookRecommendationId: number
|
||||
}>()
|
||||
const { $echo } = useNuxtApp()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const { refresh: deadlineRefresh, status: deadlineStatus, data: deadlines } = useFetch(() => `book-recommendations/${props.bookRecommendationId}/deadlines`)
|
||||
|
||||
const columns = [
|
||||
{
|
||||
label: 'Deadline',
|
||||
key: 'deadline',
|
||||
},
|
||||
{
|
||||
label: 'Ziel Seite',
|
||||
key: 'target_page',
|
||||
},
|
||||
{
|
||||
label: 'Ziel Kapitel',
|
||||
key: 'target_chapter',
|
||||
},
|
||||
{
|
||||
label: '',
|
||||
key: 'actions',
|
||||
},
|
||||
]
|
||||
|
||||
const dayjs = useDayjs()
|
||||
|
||||
const rows = computed(() => {
|
||||
return deadlines.value?.map((deadline: any) => {
|
||||
return {
|
||||
id: deadline.id,
|
||||
deadline: dayjs(deadline.deadline).format('DD.MM.YYYY'),
|
||||
target_page: deadline.target_page,
|
||||
target_chapter: deadline.target_chapter,
|
||||
user_deadline: deadline.user_deadlines[0],
|
||||
actions: null,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
$echo.private(`Deadline`)
|
||||
.listen('.DeadlineCreated', (e) => {
|
||||
deadlineRefresh()
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UTable :columns="columns" :rows="rows" :empty-state="{ icon: 'i-heroicons-calendar-solid', label: 'Keine Deadlines.' }">
|
||||
<template #actions-data="{ row }">
|
||||
<UBadge v-if="row.user_deadline.completed_at !== null">
|
||||
Abgeschlossen - {{ dayjs(row.user_deadline.completed_at).format('DD.MM.YYYY') }}
|
||||
</UBadge>
|
||||
<ConfirmUserDeadline v-if="row.user_deadline.completed_at === null" :user-deadline-id="row.id" @update="deadlineRefresh" />
|
||||
</template>
|
||||
</UTable>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
@@ -1,10 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import type { BookRecommendationStatusEnum } from '~/stores/book-recommendations'
|
||||
import { useBookRecommendationStore } from '~/stores/book-recommendations'
|
||||
|
||||
const props = defineProps<{
|
||||
row: {
|
||||
id: number
|
||||
book_name: string
|
||||
status: BookRecommendationStatusEnum
|
||||
}
|
||||
}>()
|
||||
|
||||
@@ -16,6 +18,10 @@ const state = reactive({
|
||||
book_recommendation_id: props.row.id,
|
||||
})
|
||||
|
||||
watch(() => props.row, (newRow) => {
|
||||
state.book_recommendation_id = newRow.id
|
||||
})
|
||||
|
||||
const bookRecommendationStore = useBookRecommendationStore()
|
||||
|
||||
const { refresh: onVote, status } = useFetch<any>(`vote`, {
|
||||
@@ -26,7 +32,7 @@ const { refresh: onVote, status } = useFetch<any>(`vote`, {
|
||||
async onResponse({ response }) {
|
||||
if (response.ok) {
|
||||
useToast().add({
|
||||
icon: 'i-heroicons-check-circle-20-solid',
|
||||
icon: 'i-heroicons-check-circle',
|
||||
title: 'Abstimmung erfolgreich.',
|
||||
color: 'emerald',
|
||||
})
|
||||
@@ -39,14 +45,14 @@ const { refresh: onVote, status } = useFetch<any>(`vote`, {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UButton icon="i-heroicons-star" size="sm" color="green" variant="solid" square :disabled="authStore.user.total_votes === 0" @click="isOpen = true" />
|
||||
<UButton v-if="props.row.status === 'PENDING'" class="transition-150 transform-gpu hover:scale-110" icon="i-heroicons-check-circle" size="sm" color="green" variant="solid" square :disabled="authStore.user.total_votes === 0" @click="isOpen = true" />
|
||||
<UDashboardModal
|
||||
v-model="isOpen"
|
||||
title="Buch Empfehlung löschen"
|
||||
:description="`Bist du dir sicher das du für die Buchempfehlung ${row.book_name} abstimmen möchtest?`"
|
||||
icon="i-heroicons-star"
|
||||
title="Für Buch abstimmen"
|
||||
:description="`Bist du dir sicher das du für die Buchempfehlung "${row.book_name}" abstimmen möchtest?`"
|
||||
icon="i-heroicons-check-circle"
|
||||
:ui="{
|
||||
icon: { base: 'text-green-500 dark:text-green-400' } as any,
|
||||
icon: { base: 'text-primary-500 dark:text-primary-400' } as any,
|
||||
footer: { base: 'ml-16' } as any,
|
||||
}"
|
||||
>
|
||||
|
||||
52
nuxt/components/modal/ConfirmUserDeadline.vue
Normal file
52
nuxt/components/modal/ConfirmUserDeadline.vue
Normal file
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
import party from 'party-js'
|
||||
|
||||
const props = defineProps<{
|
||||
userDeadlineId: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['update'])
|
||||
|
||||
const isOpen = ref(false)
|
||||
|
||||
const { refresh: onConfirmDeadline, status } = useFetch<any>(() => `user-deadlines/${props.userDeadlineId}`, {
|
||||
method: 'PUT',
|
||||
immediate: false,
|
||||
watch: false,
|
||||
async onResponse({ response }) {
|
||||
if (response.ok) {
|
||||
useToast().add({
|
||||
icon: 'i-heroicons-check-circle-20-solid',
|
||||
title: 'Deadline erfolgreich Abgeschlossen.',
|
||||
color: 'emerald',
|
||||
})
|
||||
emit('update')
|
||||
isOpen.value = false
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
function confirmDeadline(event) {
|
||||
party.confetti(event)
|
||||
onConfirmDeadline()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UButton class="transition-150 transform-gpu hover:scale-110" icon="i-heroicons-book-open" size="sm" variant="solid" color="primary" square @click="isOpen = true" />
|
||||
<UDashboardModal
|
||||
v-model="isOpen"
|
||||
title="Deadline abschließen"
|
||||
description="Bist du dir sicher das du die Deadline abschließen möchtest?"
|
||||
icon="i-heroicons-book-open"
|
||||
:ui="{
|
||||
icon: { base: 'text-primary dark:text-primary-400' } as any,
|
||||
footer: { base: 'ml-16' } as any,
|
||||
}"
|
||||
>
|
||||
<template #footer>
|
||||
<UButton color="primary" label="Abschließen" :loading="status === 'pending'" @click="confirmDeadline" />
|
||||
<UButton color="white" label="Abbrechen" @click="isOpen = false" />
|
||||
</template>
|
||||
</UDashboardModal>
|
||||
</template>
|
||||
@@ -5,13 +5,19 @@ const props = defineProps<{
|
||||
row: {
|
||||
id: number
|
||||
book_name: string
|
||||
recommender: {
|
||||
ulid: string
|
||||
}
|
||||
}
|
||||
}>()
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const isOpen = ref(false)
|
||||
|
||||
const bookRecommendationStore = useBookRecommendationStore()
|
||||
|
||||
const { refresh: onDelete, status } = useFetch<any>(`book-recommendations/${props.row.id}`, {
|
||||
const { refresh: onDelete, status } = useFetch<any>(() => `book-recommendations/${props.row.id}`, {
|
||||
method: 'DELETE',
|
||||
immediate: false,
|
||||
watch: false,
|
||||
@@ -31,7 +37,7 @@ const { refresh: onDelete, status } = useFetch<any>(`book-recommendations/${prop
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UButton icon="i-heroicons-trash" size="sm" color="red" variant="solid" square @click="isOpen = true" />
|
||||
<UButton v-if="authStore.user.roles.includes('admin')" class="transition-150 transform-gpu hover:scale-110" icon="i-heroicons-trash" size="sm" color="red" variant="solid" square @click="isOpen = true" />
|
||||
<UDashboardModal
|
||||
v-model="isOpen"
|
||||
title="Buch Empfehlung löschen"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { useBookRecommendationStore } from '~/stores/book-recommendations'
|
||||
import type { FormSubmitEvent } from '#ui/types'
|
||||
|
||||
const props = defineProps<{
|
||||
row: {
|
||||
@@ -9,16 +10,39 @@ const props = defineProps<{
|
||||
description: string
|
||||
isbn: string
|
||||
pages: number
|
||||
cover_image?: string
|
||||
cover_image?: string | File
|
||||
status: string
|
||||
published_at: string
|
||||
recommender: {
|
||||
ulid: string
|
||||
}
|
||||
}
|
||||
}>()
|
||||
|
||||
const dayjs = useDayjs()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const { $storage } = useNuxtApp()
|
||||
|
||||
const isOpen = ref(false)
|
||||
const loading = ref(false)
|
||||
|
||||
const form = ref()
|
||||
|
||||
const state = reactive({
|
||||
interface State {
|
||||
book_name: string
|
||||
author: string
|
||||
description: string
|
||||
isbn: string
|
||||
pages: number
|
||||
cover_image?: File | string
|
||||
status: string
|
||||
published_at: string
|
||||
// Index signature
|
||||
[key: string]: string | number | File | Date | undefined
|
||||
}
|
||||
|
||||
const state: State = reactive({
|
||||
book_name: props.row.book_name,
|
||||
author: props.row.author,
|
||||
description: props.row.description,
|
||||
@@ -26,16 +50,55 @@ const state = reactive({
|
||||
pages: props.row.pages,
|
||||
cover_image: props.row.cover_image,
|
||||
status: props.row.status,
|
||||
published_at: dayjs(props.row.published_at).format('YYYY-MM-DD'),
|
||||
})
|
||||
|
||||
watch(() => props.row, (newRow) => {
|
||||
state.book_name = newRow.book_name
|
||||
state.author = newRow.author
|
||||
state.description = newRow.description
|
||||
state.isbn = newRow.isbn
|
||||
state.pages = newRow.pages
|
||||
state.cover_image = newRow.cover_image
|
||||
state.status = newRow.status
|
||||
state.published_at = dayjs(newRow.published_at).format('YYYY-MM-DD')
|
||||
}, { deep: true, immediate: true })
|
||||
|
||||
function handleCoverImageInput(event: Event) {
|
||||
const file = (event.target as HTMLInputElement).files?.[0]
|
||||
if (file) {
|
||||
// Update the state with the selected file
|
||||
state.cover_image = file
|
||||
}
|
||||
}
|
||||
|
||||
const bookRecommendationStore = useBookRecommendationStore()
|
||||
|
||||
const { refresh: onSubmit, status } = useFetch<any>(`book-recommendations/${props.row.id}`, {
|
||||
async function onSubmit(event: FormSubmitEvent<any>) {
|
||||
form.value.clear()
|
||||
loading.value = true
|
||||
const formData = new FormData()
|
||||
for (const key in state) {
|
||||
const item = state[key]
|
||||
if (item === undefined) {
|
||||
continue
|
||||
}
|
||||
if (key === 'cover_image' && state[key] instanceof File) {
|
||||
formData.append(key, item as Blob, (state[key] as File).name)
|
||||
}
|
||||
else {
|
||||
const value = typeof item === 'string' ? item : String(item)
|
||||
if (key === 'cover_image') {
|
||||
continue
|
||||
}
|
||||
formData.append(key, value)
|
||||
}
|
||||
}
|
||||
const response = await $fetch<any>(`book-recommendations/${props.row.id}`, {
|
||||
method: 'PUT',
|
||||
body: state,
|
||||
immediate: false,
|
||||
watch: false,
|
||||
body: formData,
|
||||
async onResponse({ response }) {
|
||||
loading.value = false
|
||||
if (response?.status === 422) {
|
||||
form.value.setErrors(response._data?.errors)
|
||||
}
|
||||
@@ -45,16 +108,25 @@ const { refresh: onSubmit, status } = useFetch<any>(`book-recommendations/${prop
|
||||
title: 'Buchempfehlung wurde erfolgreich aktualisiert.',
|
||||
color: 'emerald',
|
||||
})
|
||||
await bookRecommendationStore.fetchRecommendations()
|
||||
isOpen.value = false
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
await bookRecommendationStore.fetchRecommendations()
|
||||
}
|
||||
|
||||
function isFile(value: any): value is File {
|
||||
return value instanceof File
|
||||
}
|
||||
|
||||
function getFileUrl(file: File) {
|
||||
return URL.createObjectURL(file)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<UButton icon="i-heroicons-pencil-square" size="sm" variant="solid" square @click="isOpen = true" />
|
||||
<UButton v-if="authStore.user.ulid === props.row.recommender.ulid || authStore.user.roles.includes('admin')" class="transition-150 transform-gpu hover:scale-110" icon="i-heroicons-pencil-square" size="sm" variant="solid" square @click="isOpen = true" />
|
||||
|
||||
<UModal v-model="isOpen">
|
||||
<UCard :ui="{ ring: '', divide: 'divide-y divide-gray-100 dark:divide-gray-800' }">
|
||||
@@ -67,16 +139,26 @@ const { refresh: onSubmit, status } = useFetch<any>(`book-recommendations/${prop
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<UForm ref="form" :state="state" class="space-y-4" @submit="onSubmit">
|
||||
<UForm ref="form" :state="state" class="space-y-4" enctype="multipart/form-data" @submit="onSubmit">
|
||||
<UFormGroup label="Name" name="book_name">
|
||||
<UInput v-model="state.book_name" />
|
||||
</UFormGroup>
|
||||
<UFormGroup label="Autor" name="author">
|
||||
<UInput v-model="state.author" />
|
||||
</UFormGroup>
|
||||
<UFormGroup label="Erstveröffentlichung">
|
||||
<UInput v-model="state.published_at" type="date" />
|
||||
</UFormGroup>
|
||||
<UFormGroup label="Beschreibung" name="description">
|
||||
<UTextarea v-model="state.description" />
|
||||
</UFormGroup>
|
||||
<img v-if="state.cover_image && typeof state.cover_image === 'string'" :src="$storage(state.cover_image)" alt="Cover" class="size-1/3 content-center rounded-lg">
|
||||
<ClientOnly>
|
||||
<img v-if="state.cover_image && isFile(state.cover_image)" :src="getFileUrl(state.cover_image)" alt="Cover" class="size-1/3 content-center rounded-lg">
|
||||
</ClientOnly>
|
||||
<UFormGroup label="Cover" name="cover_image">
|
||||
<UInput type="file" @change="handleCoverImageInput" />
|
||||
</UFormGroup>
|
||||
<UFormGroup label="ISBN" name="isbn">
|
||||
<UInput v-model="state.isbn" />
|
||||
</UFormGroup>
|
||||
@@ -86,7 +168,7 @@ const { refresh: onSubmit, status } = useFetch<any>(`book-recommendations/${prop
|
||||
<UFormGroup label="Status" name="status">
|
||||
<USelect v-model="state.status" :options="bookRecommendationStore.statusOptions" option-attribute="name" />
|
||||
</UFormGroup>
|
||||
<UButton size="md" type="submit" :loading="status === 'pending'">
|
||||
<UButton size="md" type="submit">
|
||||
Speichern
|
||||
</UButton>
|
||||
<UButton size="md" class="mx-4" color="white" label="Abbrechen" @click="isOpen = false" />
|
||||
|
||||
@@ -2,50 +2,102 @@
|
||||
import { useBookRecommendationStore } from '~/stores/book-recommendations'
|
||||
|
||||
const isOpen = ref(false)
|
||||
const loading = ref(false)
|
||||
|
||||
const dayjs = useDayjs()
|
||||
|
||||
const form = ref()
|
||||
|
||||
const state = reactive({
|
||||
book_name: null,
|
||||
author: null,
|
||||
description: null,
|
||||
isbn: null,
|
||||
pages: null,
|
||||
cover_image: null,
|
||||
status: null,
|
||||
interface State {
|
||||
book_name: string
|
||||
author: string
|
||||
description: string
|
||||
isbn: string
|
||||
pages: number
|
||||
cover_image?: File | string
|
||||
status: string
|
||||
published_at: string
|
||||
// Index signature
|
||||
[key: string]: string | number | File | undefined
|
||||
}
|
||||
|
||||
const state: State = reactive({
|
||||
book_name: '',
|
||||
author: '',
|
||||
description: '',
|
||||
isbn: '',
|
||||
pages: 0,
|
||||
cover_image: '',
|
||||
status: 'PENDING',
|
||||
published_at: dayjs().format('YYYY-MM-DD'),
|
||||
})
|
||||
|
||||
const bookRecommendationStore = useBookRecommendationStore()
|
||||
|
||||
const { refresh: onSubmit, status } = useFetch<any>(`book-recommendations`, {
|
||||
function handleCoverImageInput(event: Event) {
|
||||
const file = (event.target as HTMLInputElement).files?.[0]
|
||||
if (file) {
|
||||
// Update the state with the selected file
|
||||
state.cover_image = file
|
||||
}
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
loading.value = true
|
||||
const formData = new FormData()
|
||||
for (const key in state) {
|
||||
const item = state[key]
|
||||
if (item === undefined) {
|
||||
continue
|
||||
}
|
||||
if (key === 'cover_image' && state[key] instanceof File) {
|
||||
formData.append(key, item as Blob, (state[key] as File).name)
|
||||
}
|
||||
else {
|
||||
if (key === 'cover_image') {
|
||||
continue
|
||||
}
|
||||
const value = typeof item === 'string' ? item : String(item)
|
||||
formData.append(key, value)
|
||||
}
|
||||
}
|
||||
await $fetch<any>(`book-recommendations`, {
|
||||
method: 'POST',
|
||||
body: state,
|
||||
immediate: false,
|
||||
watch: false,
|
||||
body: formData,
|
||||
async onResponse({ response }) {
|
||||
loading.value = false
|
||||
if (response?.status === 422) {
|
||||
form.value.setErrors(response._data?.errors)
|
||||
}
|
||||
else if (response.ok) {
|
||||
useToast().add({
|
||||
icon: 'i-heroicons-check-circle-20-solid',
|
||||
title: 'Buchempfehlung wurde erfolgreich aktualisiert.',
|
||||
title: 'Buchempfehlung wurde erfolgreich angelegt.',
|
||||
color: 'emerald',
|
||||
})
|
||||
await bookRecommendationStore.fetchRecommendations()
|
||||
|
||||
state.book_name = null
|
||||
state.author = null
|
||||
state.description = null
|
||||
state.isbn = null
|
||||
state.pages = null
|
||||
state.cover_image = null
|
||||
state.status = null
|
||||
state.book_name = ''
|
||||
state.author = ''
|
||||
state.description = ''
|
||||
state.isbn = ''
|
||||
state.pages = 0
|
||||
state.cover_image = ''
|
||||
state.status = 'PENDING'
|
||||
state.published_at = dayjs().format('YYYY-MM-DD')
|
||||
|
||||
isOpen.value = false
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
function isFile(value: any): value is File {
|
||||
return value instanceof File
|
||||
}
|
||||
|
||||
function getFileUrl(file: File) {
|
||||
return URL.createObjectURL(file)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -72,9 +124,18 @@ const { refresh: onSubmit, status } = useFetch<any>(`book-recommendations`, {
|
||||
<UFormGroup label="Autor" name="author">
|
||||
<UInput v-model="state.author" />
|
||||
</UFormGroup>
|
||||
<UFormGroup label="Erstveröffentlichung">
|
||||
<UInput v-model="state.published_at" type="date" />
|
||||
</UFormGroup>
|
||||
<UFormGroup label="Beschreibung" name="description">
|
||||
<UTextarea v-model="state.description" />
|
||||
</UFormGroup>
|
||||
<ClientOnly>
|
||||
<img v-if="state.cover_image && isFile(state.cover_image)" :src="getFileUrl(state.cover_image)" alt="Cover" class="size-1/3 content-center rounded-lg">
|
||||
</ClientOnly>
|
||||
<UFormGroup label="Cover" name="cover_image">
|
||||
<UInput type="file" @change="handleCoverImageInput" />
|
||||
</UFormGroup>
|
||||
<UFormGroup label="ISBN" name="isbn">
|
||||
<UInput v-model="state.isbn" />
|
||||
</UFormGroup>
|
||||
|
||||
84
nuxt/components/modal/NewDeadline.vue
Normal file
84
nuxt/components/modal/NewDeadline.vue
Normal file
@@ -0,0 +1,84 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
bookRecommendationId: number
|
||||
}>()
|
||||
const isOpen = ref(false)
|
||||
const loading = ref(false)
|
||||
const form = ref()
|
||||
|
||||
interface NewDeadlineState {
|
||||
book_recommendation_id: number | null
|
||||
deadline: string
|
||||
target_page?: number
|
||||
target_chapter?: string
|
||||
}
|
||||
|
||||
const state: NewDeadlineState = reactive({
|
||||
book_recommendation_id: props.bookRecommendationId,
|
||||
deadline: '',
|
||||
target_page: undefined,
|
||||
target_chapter: undefined,
|
||||
})
|
||||
|
||||
async function onSubmit() {
|
||||
loading.value = true
|
||||
|
||||
await $fetch('deadlines', {
|
||||
method: 'POST',
|
||||
body: state,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
async onResponse({ response }) {
|
||||
loading.value = false
|
||||
if (response?.status === 422) {
|
||||
form.value.setErrors(response._data?.errors)
|
||||
}
|
||||
else if (response.ok) {
|
||||
useToast().add({
|
||||
icon: 'i-heroicons-check-circle-20-solid',
|
||||
title: 'Deadline erfolgreich erstellt.',
|
||||
color: 'emerald',
|
||||
})
|
||||
loading.value = false
|
||||
|
||||
isOpen.value = false
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<UButton icon="i-heroicons-plus" label="Neue Deadline" size="sm" variant="solid" color="green" square @click="isOpen = true" />
|
||||
<UModal v-model="isOpen">
|
||||
<UCard :ui="{ ring: '', divide: 'divide-y divide-gray-100 dark:divide-gray-800' }">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-base font-semibold leading-6 text-gray-900 dark:text-white">
|
||||
Neue Deadline
|
||||
</h3>
|
||||
<UButton color="gray" variant="ghost" icon="i-heroicons-x-mark-20-solid" class="-my-1" @click="isOpen = false" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<UForm ref="form" :state="state" class="space-y-4" @submit="onSubmit">
|
||||
<UFormGroup label="Deadline" name="deadline">
|
||||
<UInput v-model="state.deadline" type="date" />
|
||||
</UFormGroup>
|
||||
<UFormGroup label="Zielseite" name="target_page">
|
||||
<UInput v-model="state.target_page" type="number" />
|
||||
</UFormGroup>
|
||||
<UFormGroup label="Zielkapitel" name="target_chapter">
|
||||
<UInput v-model="state.target_chapter" />
|
||||
</UFormGroup>
|
||||
<UButton size="md" type="submit" :loading="loading">
|
||||
Erstellen
|
||||
</UButton>
|
||||
<UButton size="md" class="mx-4" color="white" label="Abbrechen" @click="isOpen = false" />
|
||||
</UForm>
|
||||
</UCard>
|
||||
</UModal>
|
||||
</div>
|
||||
</template>
|
||||
@@ -4,7 +4,9 @@
|
||||
<UPage>
|
||||
<template #left>
|
||||
<UAside class="lg:static">
|
||||
<UContainer>
|
||||
<Navigation />
|
||||
</UContainer>
|
||||
</UAside>
|
||||
</template>
|
||||
<UPageBody>
|
||||
|
||||
@@ -2,13 +2,17 @@ export default defineNuxtRouteMiddleware((to, from) => {
|
||||
const nuxtApp = useNuxtApp()
|
||||
const auth = useAuthStore()
|
||||
|
||||
if (process.server) {
|
||||
return
|
||||
}
|
||||
|
||||
if (auth.isLoggedIn && !auth.user.roles.includes('admin')) {
|
||||
return nuxtApp.runWithContext(() => {
|
||||
useToast().add({
|
||||
icon: "i-heroicons-exclamation-circle-solid",
|
||||
title: "Access denied.",
|
||||
color: "red",
|
||||
});
|
||||
icon: 'i-heroicons-exclamation-circle-solid',
|
||||
title: 'Access denied.',
|
||||
color: 'red',
|
||||
})
|
||||
|
||||
return navigateTo('/')
|
||||
})
|
||||
|
||||
@@ -2,13 +2,17 @@ export default defineNuxtRouteMiddleware((to, from) => {
|
||||
const nuxtApp = useNuxtApp()
|
||||
const auth = useAuthStore()
|
||||
|
||||
if (process.server) {
|
||||
return
|
||||
}
|
||||
|
||||
if (auth.isLoggedIn && !auth.user.roles.includes('user')) {
|
||||
return nuxtApp.runWithContext(() => {
|
||||
useToast().add({
|
||||
icon: "i-heroicons-exclamation-circle-solid",
|
||||
title: "Access denied.",
|
||||
color: "red",
|
||||
});
|
||||
icon: 'i-heroicons-exclamation-circle-solid',
|
||||
title: 'Access denied.',
|
||||
color: 'red',
|
||||
})
|
||||
|
||||
return navigateTo('/')
|
||||
})
|
||||
|
||||
13
nuxt/pages/admin/index.vue
Normal file
13
nuxt/pages/admin/index.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
definePageMeta({ middleware: ['role-admin'] })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
Admin
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
40
nuxt/pages/admin/jobs.vue
Normal file
40
nuxt/pages/admin/jobs.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
definePageMeta({ middleware: ['role-admin'] })
|
||||
|
||||
const serverLogs = ref<string>('')
|
||||
|
||||
async function runJobs(job: string) {
|
||||
switch (job) {
|
||||
case 'fetch_cover':
|
||||
serverLogs.value = await $fetch('jobs/fetch-cover', { method: 'POST' })
|
||||
break
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 md:flex-row">
|
||||
<UCard class="w-full md:w-1/2">
|
||||
<template #header>
|
||||
<h1 class="font-sans text-3xl font-bold">
|
||||
Server Jobs
|
||||
</h1>
|
||||
<UButton class="mt-4" @click="runJobs('fetch_cover')">
|
||||
Cover Bilder anfragen
|
||||
</UButton>
|
||||
</template>
|
||||
</UCard>
|
||||
<UCard class="w-full md:w-1/2">
|
||||
<template #header>
|
||||
<h1 class="font-sans text-3xl font-bold">
|
||||
Server Logs
|
||||
</h1>
|
||||
<UTextarea v-model="serverLogs" autoresize disabled class="mt-4" />
|
||||
</template>
|
||||
</UCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
22
nuxt/pages/admin/votes.vue
Normal file
22
nuxt/pages/admin/votes.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import AddVotes from '~/components/admin/AddVotes.vue'
|
||||
|
||||
definePageMeta({ middleware: ['role-admin'] })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<UCard class="w-full md:w-1/2">
|
||||
<template #header>
|
||||
<h1 class="font-sans text-3xl font-bold">
|
||||
Benutzer Aktionen
|
||||
</h1>
|
||||
</template>
|
||||
<AddVotes />
|
||||
</UCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
@@ -1,5 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import BookRecommendationTable from '~/components/book-recommendations/BookRecommendationTable.vue'
|
||||
|
||||
definePageMeta({ middleware: ['auth'] })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -1,13 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import BookInfoCard from '~/components/dashboard/BookInfoCard.vue'
|
||||
|
||||
definePageMeta({ middleware: ['auth'] })
|
||||
|
||||
const modal = useModal();
|
||||
const router = useRouter();
|
||||
const auth = useAuthStore();
|
||||
const bookRecommendationStore = useBookRecommendationStore()
|
||||
const authStore = useAuthStore()
|
||||
const { $echo } = useNuxtApp()
|
||||
|
||||
bookRecommendationStore.fetchActiveRecommendations()
|
||||
|
||||
onMounted(() => {
|
||||
$echo.private(`BookRecommendation`)
|
||||
.listen('.BookRecommendationUpdated', (e) => {
|
||||
bookRecommendationStore.updateRecommendationWS(e.bookRecommendation)
|
||||
})
|
||||
.listen('.BookRecommendationDeleted', (e) => {
|
||||
bookRecommendationStore.deleteRecommendationWS(e.bookRecommendation)
|
||||
})
|
||||
.listen('.BookRecommendationCreated', (e) => {
|
||||
bookRecommendationStore.createRecommendationWS(e.bookRecommendation)
|
||||
})
|
||||
authStore.socketId = $echo.socketId()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
This is the Page Content
|
||||
<div v-if="bookRecommendationStore.fetchActiveRecommendationsStatus !== 'pending'">
|
||||
<BookInfoCard v-for="book in bookRecommendationStore.recommendations" :key="book.id" :book="book" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,58 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
definePageMeta({ middleware: ['guest'], layout: 'auth' })
|
||||
|
||||
const config = useRuntimeConfig();
|
||||
const router = useRouter();
|
||||
const auth = useAuthStore();
|
||||
const form = ref();
|
||||
const config = useRuntimeConfig()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const form = ref()
|
||||
|
||||
type Provider = {
|
||||
name: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
loading?: boolean;
|
||||
};
|
||||
interface Provider {
|
||||
name: string
|
||||
icon: string
|
||||
color: string
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
const state = reactive({
|
||||
email: "",
|
||||
password: "",
|
||||
email: '',
|
||||
password: '',
|
||||
remember: false,
|
||||
});
|
||||
})
|
||||
|
||||
const { refresh: onSubmit, status: loginStatus } = useFetch<any>("login", {
|
||||
method: "POST",
|
||||
const { refresh: onSubmit, status: loginStatus } = useFetch<any>('login', {
|
||||
method: 'POST',
|
||||
body: state,
|
||||
immediate: false,
|
||||
watch: false,
|
||||
async onResponse({ response }) {
|
||||
if (response?.status === 422) {
|
||||
form.value.setErrors(response._data?.errors);
|
||||
} else if (response._data?.ok) {
|
||||
auth.token = response._data.token;
|
||||
|
||||
await auth.fetchUser();
|
||||
await router.push("/");
|
||||
form.value.setErrors(response._data?.errors)
|
||||
}
|
||||
}
|
||||
});
|
||||
else if (response._data?.ok) {
|
||||
auth.token = response._data.token
|
||||
|
||||
const providers = ref<{ [key: string]: Provider }>(config.public.providers);
|
||||
await auth.fetchUser()
|
||||
await router.push('/')
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const providers = ref<{ [key: string]: Provider }>(config.public.providers)
|
||||
|
||||
async function handleMessage(event: { data: any }): Promise<void> {
|
||||
const provider = event.data.provider as string;
|
||||
const provider = event.data.provider as string
|
||||
|
||||
if (Object.keys(providers.value).includes(provider) && event.data.token) {
|
||||
providers.value[provider].loading = false;
|
||||
auth.token = event.data.token;
|
||||
providers.value[provider].loading = false
|
||||
auth.token = event.data.token
|
||||
|
||||
await auth.fetchUser();
|
||||
await router.push("/");
|
||||
} else if (event.data.message) {
|
||||
await auth.fetchUser()
|
||||
await router.push('/')
|
||||
}
|
||||
else if (event.data.message) {
|
||||
useToast().add({
|
||||
icon: "i-heroicons-exclamation-circle-solid",
|
||||
color: "red",
|
||||
icon: 'i-heroicons-exclamation-circle-solid',
|
||||
color: 'red',
|
||||
title: event.data.message,
|
||||
});
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -63,9 +65,9 @@ async function handleMessage(event: { data: any }): Promise<void> {
|
||||
<div class="mx-auto flex min-h-screen w-full items-center justify-center">
|
||||
<UCard class="w-96">
|
||||
<template #header>
|
||||
<h1 class="text-center text-2xl font-bold">
|
||||
Login
|
||||
</h1>
|
||||
<div class="text-center text-4xl font-bold">
|
||||
<Logo />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<UForm ref="form" :state="state" class="space-y-4" @submit="onSubmit">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ofetch } from 'ofetch'
|
||||
import type { FetchOptions } from 'ofetch';
|
||||
import type { FetchOptions } from 'ofetch'
|
||||
|
||||
export default defineNuxtPlugin({
|
||||
name: 'app',
|
||||
@@ -10,10 +10,10 @@ export default defineNuxtPlugin({
|
||||
const auth = useAuthStore()
|
||||
|
||||
nuxtApp.provide('storage', (path: string): string => {
|
||||
if (!path) return ''
|
||||
if (!path) { return '' }
|
||||
|
||||
return path.startsWith('http://') || path.startsWith('https://') ?
|
||||
path
|
||||
return path.startsWith('http://') || path.startsWith('https://')
|
||||
? path
|
||||
: config.public.storageBase + path
|
||||
})
|
||||
|
||||
@@ -21,45 +21,53 @@ export default defineNuxtPlugin({
|
||||
// Initial headers with Accept
|
||||
const initialHeaders = {
|
||||
...headers,
|
||||
'Accept': 'application/json',
|
||||
};
|
||||
Accept: 'application/json',
|
||||
}
|
||||
|
||||
// Conditionally add server-specific headers
|
||||
if (process.server) {
|
||||
const serverHeaders = {
|
||||
'referer': useRequestURL().toString(),
|
||||
referer: useRequestURL().toString(),
|
||||
...useRequestHeaders(['x-forwarded-for', 'user-agent', 'referer']),
|
||||
};
|
||||
Object.assign(initialHeaders, serverHeaders);
|
||||
}
|
||||
Object.assign(initialHeaders, serverHeaders)
|
||||
}
|
||||
|
||||
// Conditionally add authorization header if logged in
|
||||
if (auth.isLoggedIn) {
|
||||
const authHeaders = {
|
||||
'Authorization': `Bearer ${auth.token}`,
|
||||
};
|
||||
Object.assign(initialHeaders, authHeaders);
|
||||
Authorization: `Bearer ${auth.token}`,
|
||||
}
|
||||
Object.assign(initialHeaders, authHeaders)
|
||||
}
|
||||
|
||||
return initialHeaders;
|
||||
// Conditionally add X-Socket-ID header if socket is connected
|
||||
if (auth.isLoggedIn && auth.socketId) {
|
||||
const socketHeaders = {
|
||||
'X-Socket-ID': auth.socketId,
|
||||
}
|
||||
Object.assign(initialHeaders, socketHeaders)
|
||||
}
|
||||
|
||||
return initialHeaders
|
||||
}
|
||||
|
||||
function buildBaseURL(baseURL: string): string {
|
||||
if (baseURL) return baseURL;
|
||||
if (baseURL) { return baseURL }
|
||||
|
||||
return process.server ?
|
||||
config.apiLocal + config.public.apiPrefix
|
||||
: config.public.apiBase + config.public.apiPrefix;
|
||||
return process.server
|
||||
? config.apiLocal + config.public.apiPrefix
|
||||
: config.public.apiBase + config.public.apiPrefix
|
||||
}
|
||||
|
||||
function buildSecureMethod(options: FetchOptions): void {
|
||||
if (process.server) return;
|
||||
if (process.server) { return }
|
||||
|
||||
const method = options.method?.toLowerCase() ?? 'get'
|
||||
|
||||
if (options.body instanceof FormData && method === 'put') {
|
||||
options.method = 'POST';
|
||||
options.body.append('_method', 'PUT');
|
||||
options.method = 'POST'
|
||||
options.body.append('_method', 'PUT')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,27 +75,27 @@ export default defineNuxtPlugin({
|
||||
return !baseURL
|
||||
&& !path.startsWith('/_nuxt')
|
||||
&& !path.startsWith('http://')
|
||||
&& !path.startsWith('https://');
|
||||
&& !path.startsWith('https://')
|
||||
}
|
||||
|
||||
globalThis.$fetch = ofetch.create({
|
||||
retry: false,
|
||||
|
||||
onRequest({ request, options }) {
|
||||
if (!isRequestWithAuth(options.baseURL ?? '', request.toString())) return
|
||||
if (!isRequestWithAuth(options.baseURL ?? '', request.toString())) { return }
|
||||
|
||||
options.credentials = 'include';
|
||||
options.credentials = 'include'
|
||||
|
||||
options.baseURL = buildBaseURL(options.baseURL ?? '');
|
||||
options.headers = buildHeaders(options.headers);
|
||||
options.baseURL = buildBaseURL(options.baseURL ?? '')
|
||||
options.headers = buildHeaders(options.headers)
|
||||
|
||||
buildSecureMethod(options);
|
||||
buildSecureMethod(options)
|
||||
},
|
||||
|
||||
onRequestError({ error }) {
|
||||
if (process.server) return;
|
||||
if (process.server) { return }
|
||||
|
||||
if (error.name === 'AbortError') return;
|
||||
if (error.name === 'AbortError') { return }
|
||||
|
||||
useToast().add({
|
||||
icon: 'i-heroicons-exclamation-circle-solid',
|
||||
@@ -110,7 +118,8 @@ export default defineNuxtPlugin({
|
||||
color: 'primary',
|
||||
})
|
||||
}
|
||||
} else if (response.status !== 422) {
|
||||
}
|
||||
else if (response.status !== 422) {
|
||||
if (process.client) {
|
||||
useToast().add({
|
||||
icon: 'i-heroicons-exclamation-circle-solid',
|
||||
@@ -119,11 +128,11 @@ export default defineNuxtPlugin({
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
} as FetchOptions)
|
||||
|
||||
if (auth.isLoggedIn) {
|
||||
await auth.fetchUser();
|
||||
await auth.fetchUser()
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
55
nuxt/plugins/echo.client.ts
Normal file
55
nuxt/plugins/echo.client.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import Echo from 'laravel-echo'
|
||||
import Pusher from 'pusher-js'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
Pusher: typeof Pusher
|
||||
}
|
||||
}
|
||||
|
||||
window.Pusher = Pusher
|
||||
|
||||
export default defineNuxtPlugin(() => {
|
||||
const runtimeConfig = useRuntimeConfig()
|
||||
const authStore = useAuthStore()
|
||||
const echo = new Echo({
|
||||
broadcaster: runtimeConfig.public.echo.broadcaster,
|
||||
key: runtimeConfig.public.echo.key,
|
||||
cluster: 'mt1',
|
||||
wsHost: runtimeConfig.public.echo.wsHost,
|
||||
wsPort: runtimeConfig.public.echo.wsPort,
|
||||
wssPort: runtimeConfig.public.echo.wsPort,
|
||||
forceTLS: false,
|
||||
encrypted: runtimeConfig.public.echo.encrypted,
|
||||
disableStats: runtimeConfig.public.echo.disableStats,
|
||||
enabledTransports: ['ws', 'wss'],
|
||||
authorizer: (channel: { name: any }) => {
|
||||
return {
|
||||
authorize: async (socketId: string, callback: (error: any, respnse?: any) => void) => {
|
||||
try {
|
||||
const response = await $fetch(`/broadcasting/auth`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
socket_id: socketId,
|
||||
channel_name: channel.name,
|
||||
},
|
||||
})
|
||||
|
||||
callback(null, response)
|
||||
}
|
||||
catch (error) {
|
||||
callback(error)
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
authStore.socketId = echo.socketId()
|
||||
|
||||
// Make Echo instance available through the Nuxt app
|
||||
return {
|
||||
provide: {
|
||||
echo,
|
||||
},
|
||||
}
|
||||
})
|
||||
49
nuxt/plugins/ui.ts
Normal file
49
nuxt/plugins/ui.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import colors from '#tailwind-config/theme/colors'
|
||||
import { hexToRgb } from '#ui/utils'
|
||||
|
||||
export default defineNuxtPlugin({
|
||||
enforce: 'post',
|
||||
setup() {
|
||||
const appConfig = useAppConfig()
|
||||
|
||||
const root = computed(() => {
|
||||
const primary: Record<string, string> | undefined = colors[appConfig.ui.primary]
|
||||
const gray: Record<string, string> | undefined = colors[appConfig.ui.gray]
|
||||
|
||||
return `:root {
|
||||
${Object.entries(primary || colors.green).map(([key, value]) => `--color-primary-${key}: ${hexToRgb(value)};`).join('\n')}
|
||||
--color-primary-DEFAULT: var(--color-primary-500);
|
||||
|
||||
${Object.entries(gray || colors.cool).map(([key, value]) => `--color-gray-${key}: ${hexToRgb(value)};`).join('\n')}
|
||||
}
|
||||
|
||||
.dark {
|
||||
--color-primary-DEFAULT: var(--color-primary-400);
|
||||
}
|
||||
`
|
||||
})
|
||||
|
||||
if (process.client) {
|
||||
watch(root, () => {
|
||||
window.localStorage.setItem('nuxt-ui-root', root.value)
|
||||
})
|
||||
|
||||
appConfig.ui.primary = window.localStorage.getItem('nuxt-ui-primary') || appConfig.ui.primary
|
||||
appConfig.ui.gray = window.localStorage.getItem('nuxt-ui-gray') || appConfig.ui.gray
|
||||
}
|
||||
if (process.server) {
|
||||
useHead({
|
||||
script: [
|
||||
{
|
||||
innerHTML: `
|
||||
if (localStorage.getItem('nuxt-ui-root')) {
|
||||
document.querySelector('style#nuxt-ui-colors').innerHTML = localStorage.getItem('nuxt-ui-root')
|
||||
}`.replace(/\s+/g, ' '),
|
||||
type: 'text/javascript',
|
||||
tagPriority: -1,
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -24,6 +24,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
maxAge: 60 * 60 * 24 * 365,
|
||||
})
|
||||
const isLoggedIn = computed(() => !!token.value)
|
||||
const socketId = ref('')
|
||||
|
||||
const { refresh: logout } = useFetch<any>('logout', {
|
||||
method: 'POST',
|
||||
@@ -49,5 +50,5 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
},
|
||||
})
|
||||
|
||||
return { user, isLoggedIn, logout, fetchUser, token }
|
||||
return { user, isLoggedIn, socketId, logout, fetchUser, token }
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
enum BookRecommendationStatusEnum {
|
||||
export enum BookRecommendationStatusEnum {
|
||||
PENDING = 'PENDING',
|
||||
REJECTED = 'REJECTED',
|
||||
ACTIVE = 'ACTIVE',
|
||||
@@ -21,8 +21,18 @@ export interface BookRecommendation {
|
||||
email: string
|
||||
avatar: string
|
||||
}
|
||||
votes?: Vote[]
|
||||
status: BookRecommendationStatusEnum
|
||||
cover_image?: string
|
||||
published_at?: string
|
||||
}
|
||||
|
||||
export interface Vote {
|
||||
book_recommendation_id: number
|
||||
id: number
|
||||
user_id: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export const useBookRecommendationStore = defineStore('bookRecommendations', () => {
|
||||
@@ -30,20 +40,25 @@ export const useBookRecommendationStore = defineStore('bookRecommendations', ()
|
||||
|
||||
const statusOptions = [
|
||||
{
|
||||
name: 'Pending',
|
||||
name: 'Ausstehend',
|
||||
value: BookRecommendationStatusEnum.PENDING,
|
||||
color: 'orange',
|
||||
},
|
||||
{
|
||||
name: 'Rejected',
|
||||
name: 'Abgelehnt',
|
||||
value: BookRecommendationStatusEnum.REJECTED,
|
||||
color: 'red',
|
||||
},
|
||||
{
|
||||
name: 'Active',
|
||||
name: 'Aktiv',
|
||||
value: BookRecommendationStatusEnum.ACTIVE,
|
||||
color: 'green',
|
||||
},
|
||||
{
|
||||
name: 'Completed',
|
||||
name: 'Abgeschlossen',
|
||||
value: BookRecommendationStatusEnum.COMPLETED,
|
||||
color: 'primary',
|
||||
|
||||
},
|
||||
]
|
||||
|
||||
@@ -57,29 +72,87 @@ export const useBookRecommendationStore = defineStore('bookRecommendations', ()
|
||||
},
|
||||
})
|
||||
|
||||
const deleteRecommendation = async (id: number) => {
|
||||
try {
|
||||
const { error } = await useFetch(`book-recommendations/${id}`, {
|
||||
method: 'DELETE',
|
||||
const { refresh: fetchActiveRecommendations, status: fetchActiveRecommendationsStatus } = useFetch<BookRecommendation[]>('book-recommendations?with=recommender,votes&status=ACTIVE', {
|
||||
immediate: false,
|
||||
onResponse({ response }) {
|
||||
if (response.status === 200) {
|
||||
recommendations.value = response._data
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
if (error.value) {
|
||||
console.error('Failed to delete book recommendation:', error.value)
|
||||
const updateRecommendationWS = async (data: Partial<BookRecommendation>) => {
|
||||
// This data can be Partial, the id should always be present. We need to only update the properties that are present in the data object.
|
||||
// We also have a special case for activeRecommendations, in this case we could have a new recommendation that needs to be added to the list. This should only happen if the status is ACTIVE.
|
||||
// If the Status is not ACTIVE, we need to remove the recommendation from the list.
|
||||
const index = recommendations.value.findIndex(r => r.id === data.id)
|
||||
if (index !== -1) {
|
||||
recommendations.value[index] = { ...recommendations.value[index], ...data }
|
||||
}
|
||||
else {
|
||||
recommendations.value = recommendations.value.filter(rec => rec.id !== id)
|
||||
switch (data.status) {
|
||||
case BookRecommendationStatusEnum.ACTIVE:
|
||||
const activeIndex = recommendations.value.findIndex(r => r.id === data.id)
|
||||
if (activeIndex === -1) {
|
||||
await createRecommendationWS(data)
|
||||
}
|
||||
break
|
||||
default:
|
||||
const inactiveIndex = recommendations.value.findIndex(r => r.id === data.id)
|
||||
if (inactiveIndex !== -1) {
|
||||
recommendations.value.splice(inactiveIndex, 1)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
console.error('An error occurred while deleting a book recommendation:', e)
|
||||
|
||||
const deleteRecommendationWS = async (data: Partial<BookRecommendation>) => {
|
||||
const index = recommendations.value.findIndex(r => r.id === data.id)
|
||||
if (index !== -1) {
|
||||
recommendations.value.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
const createRecommendationWS = async (data: Partial<BookRecommendation>) => {
|
||||
// Here we need to get the missing with data from the server
|
||||
await useFetch<BookRecommendation>(`book-recommendations/${data.id}?with=recommender,votes`, {
|
||||
onResponse({ response }) {
|
||||
if (response.status === 200) {
|
||||
recommendations.value.push(response._data)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const createVoteWS = async (data: Partial<Vote>) => {
|
||||
const index = recommendations.value.findIndex(r => r.id === data.book_recommendation_id)
|
||||
// check if vote already exists otherwise add an empty array
|
||||
if (index !== -1) {
|
||||
if (!recommendations.value[index]?.votes) {
|
||||
recommendations.value[index].votes = []
|
||||
}
|
||||
recommendations.value[index].votes.push(data)
|
||||
}
|
||||
}
|
||||
|
||||
function resetRecommendations() {
|
||||
recommendations.value = []
|
||||
}
|
||||
|
||||
return {
|
||||
recommendations,
|
||||
resetRecommendations,
|
||||
updateRecommendationWS,
|
||||
deleteRecommendationWS,
|
||||
createRecommendationWS,
|
||||
createVoteWS,
|
||||
statusOptions,
|
||||
fetchRecommendations,
|
||||
fetchRecommendationsStatus,
|
||||
deleteRecommendation,
|
||||
fetchActiveRecommendations,
|
||||
fetchActiveRecommendationsStatus,
|
||||
}
|
||||
})
|
||||
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.accept(acceptHMRUpdate(useBookRecommendationStore, import.meta.hot))
|
||||
}
|
||||
|
||||
10
package.json
10
package.json
@@ -1,12 +1,13 @@
|
||||
{
|
||||
"name": "laravel-nuxt",
|
||||
"version": "0.0.4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"serve": "cross-env PORT=3000 HOST=127.0.0.1 node .output/server/index.mjs",
|
||||
"build": "nuxi cleanup && nuxi build",
|
||||
"build": "GIT_HASH=$(git rev-parse --short HEAD) nuxi build",
|
||||
"cleanup": "nuxi cleanup",
|
||||
"dev": "nuxt dev --port=3000 --host=127.0.0.1",
|
||||
"dev": "GIT_HASH=$(git rev-parse --short HEAD) nuxt dev --port=3000 --host=127.0.0.1",
|
||||
"generate": "nuxt generate",
|
||||
"preview": "nuxt preview",
|
||||
"postinstall": "nuxt prepare",
|
||||
@@ -32,5 +33,10 @@
|
||||
},
|
||||
"resolutions": {
|
||||
"vue": "3.4.21"
|
||||
},
|
||||
"dependencies": {
|
||||
"laravel-echo": "^1.16.0",
|
||||
"party-js": "^2.2.0",
|
||||
"pusher-js": "8.4.0-rc2"
|
||||
}
|
||||
}
|
||||
30
pnpm-lock.yaml
generated
30
pnpm-lock.yaml
generated
@@ -7,6 +7,17 @@ settings:
|
||||
overrides:
|
||||
vue: 3.4.21
|
||||
|
||||
dependencies:
|
||||
laravel-echo:
|
||||
specifier: ^1.16.0
|
||||
version: 1.16.0
|
||||
party-js:
|
||||
specifier: ^2.2.0
|
||||
version: 2.2.0
|
||||
pusher-js:
|
||||
specifier: 8.4.0-rc2
|
||||
version: 8.4.0-rc2
|
||||
|
||||
devDependencies:
|
||||
'@antfu/eslint-config':
|
||||
specifier: ^2.8.3
|
||||
@@ -5910,6 +5921,11 @@ packages:
|
||||
resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==}
|
||||
dev: true
|
||||
|
||||
/laravel-echo@1.16.0:
|
||||
resolution: {integrity: sha512-BJGUa4tcKvYmTkzTmcBGMHiO2tq+k7Do5wPmLbRswWfzKwyfZEUR+J5iwBTPEfLLwNPZlA9Kjo6R/NV6pmyIpg==}
|
||||
engines: {node: '>=10'}
|
||||
dev: false
|
||||
|
||||
/launch-editor@2.6.1:
|
||||
resolution: {integrity: sha512-eB/uXmFVpY4zezmGp5XtU21kwo7GBbKB+EQ+UZeWtGb9yAM5xt/Evk+lYH3eRNAtId+ej4u7TYPFZ07w4s7rRw==}
|
||||
dependencies:
|
||||
@@ -7163,6 +7179,10 @@ packages:
|
||||
engines: {node: '>= 0.8'}
|
||||
dev: true
|
||||
|
||||
/party-js@2.2.0:
|
||||
resolution: {integrity: sha512-50hGuALCpvDTrQLPQ1fgUgxKIWAH28ShVkmeK/3zhO0YJyCqkhrZhQEkWPxDYLvbFJ7YAXyROmFEu35gKpZLtQ==}
|
||||
dev: false
|
||||
|
||||
/path-exists@4.0.0:
|
||||
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -7762,6 +7782,12 @@ packages:
|
||||
engines: {node: '>=6'}
|
||||
dev: true
|
||||
|
||||
/pusher-js@8.4.0-rc2:
|
||||
resolution: {integrity: sha512-d87GjOEEl9QgO5BWmViSqW0LOzPvybvX6WA9zLUstNdB57jVJuR27zHkRnrav2a3+zAMlHbP2Og8wug+rG8T+g==}
|
||||
dependencies:
|
||||
tweetnacl: 1.0.3
|
||||
dev: false
|
||||
|
||||
/queue-microtask@1.2.3:
|
||||
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
||||
dev: true
|
||||
@@ -8754,6 +8780,10 @@ packages:
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
/tweetnacl@1.0.3:
|
||||
resolution: {integrity: sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==}
|
||||
dev: false
|
||||
|
||||
/type-check@0.4.0:
|
||||
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
|
||||
14
resources/js/echo.js
Normal file
14
resources/js/echo.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import Echo from 'laravel-echo';
|
||||
|
||||
import Pusher from 'pusher-js';
|
||||
window.Pusher = Pusher;
|
||||
|
||||
window.Echo = new Echo({
|
||||
broadcaster: 'reverb',
|
||||
key: import.meta.env.VITE_REVERB_APP_KEY,
|
||||
wsHost: import.meta.env.VITE_REVERB_HOST,
|
||||
wsPort: import.meta.env.VITE_REVERB_PORT ?? 80,
|
||||
wssPort: import.meta.env.VITE_REVERB_PORT ?? 443,
|
||||
forceTLS: (import.meta.env.VITE_REVERB_SCHEME ?? 'https') === 'https',
|
||||
enabledTransports: ['ws', 'wss'],
|
||||
});
|
||||
@@ -3,6 +3,7 @@
|
||||
use App\Http\Controllers\AccountController;
|
||||
use App\Http\Controllers\AuthController;
|
||||
use App\Http\Controllers\BookRecommendationController;
|
||||
use App\Http\Controllers\DeadlineController;
|
||||
use App\Http\Controllers\UploadController;
|
||||
use App\Http\Controllers\VoteController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
@@ -26,15 +27,25 @@ Route::prefix('api/v1')->group(function () {
|
||||
Route::post('devices/disconnect', [AuthController::class, 'deviceDisconnect'])->name('devices.disconnect');
|
||||
Route::get('devices', [AuthController::class, 'devices'])->name('devices');
|
||||
Route::get('user', [AuthController::class, 'user'])->name('user');
|
||||
Route::match(['get', 'post'], '/broadcasting/auth', [\Illuminate\Broadcasting\BroadcastController::class, 'authenticate'])->withoutMiddleware(\Illuminate\Foundation\Http\Middleware\VerifyCsrfToken::class)->name('broadcasting.auth');
|
||||
|
||||
Route::resource('book-recommendations', BookRecommendationController::class);
|
||||
Route::get('/book-recommendations/{bookRecommendationId}/deadlines', [DeadlineController::class, 'deadlinesByBookRecommendation']);
|
||||
Route::post('/vote', [VoteController::class, 'castVote']);
|
||||
|
||||
Route::get('/deadlines', [DeadlineController::class, 'index']);
|
||||
Route::post('/deadlines', [DeadlineController::class, 'createDeadline']);
|
||||
Route::put('/user-deadlines/{deadlineId}', [DeadlineController::class, 'updateUserDeadline']);
|
||||
|
||||
Route::post('/admin/add-total-votes-all', [VoteController::class, 'addTotalVotesAll']);
|
||||
|
||||
Route::post('account/update', [AccountController::class, 'update'])->name('account.update');
|
||||
Route::post('account/password', [AccountController::class, 'password'])->name('account.password');
|
||||
|
||||
Route::post('jobs/fetch-cover', [BookRecommendationController::class, 'fetchCover'])->name('jobs.fetch-cover');
|
||||
|
||||
Route::middleware(['throttle:uploads'])->group(function () {
|
||||
Route::post('upload', [UploadController::class, 'image'])->name('upload.image');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
19
routes/channels.php
Normal file
19
routes/channels.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Broadcast;
|
||||
|
||||
Broadcast::channel('App.Models.User.{ulid}', function ($user, $ulid) {
|
||||
return $user->ulid === $ulid;
|
||||
});
|
||||
|
||||
Broadcast::channel('BookRecommendation', function ($user) {
|
||||
return $user;
|
||||
});
|
||||
|
||||
Broadcast::channel('Vote', function ($user) {
|
||||
return $user;
|
||||
});
|
||||
|
||||
Broadcast::channel('Deadline', function ($user) {
|
||||
return $user;
|
||||
});
|
||||
12
tailwind.config.ts
Normal file
12
tailwind.config.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { Config } from 'tailwindcss'
|
||||
import defaultTheme from 'tailwindcss/defaultTheme'
|
||||
|
||||
export default <Partial<Config>>{
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['DM Sans', ...defaultTheme.fontFamily.sans],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user