Compare commits

..

76 Commits

Author SHA1 Message Date
cae791ae27 style: Added Logo to Login
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone Build is passing
2024-03-24 19:06:42 +01:00
ee3d211af1 style: Added Easter Eggs 2024-03-24 19:01:47 +01:00
779bc7d91f style: fix Action Column
All checks were successful
continuous-integration/drone Build is passing
2024-03-24 14:47:49 +01:00
53628a319e fix: Git Hash #3
All checks were successful
continuous-integration/drone/push Build is passing
2024-03-24 14:36:50 +01:00
ec92f4bd5d fix: Git Hash #2
All checks were successful
continuous-integration/drone/push Build is passing
2024-03-24 14:33:12 +01:00
4c57faef52 fix: Git Hash #2
All checks were successful
continuous-integration/drone/push Build is passing
2024-03-24 14:29:09 +01:00
f9c83d6459 fix: Git Hash
All checks were successful
continuous-integration/drone/push Build is passing
2024-03-24 14:24:55 +01:00
91592b06d7 style: Mobile Styles
All checks were successful
continuous-integration/drone/push Build is passing
2024-03-24 14:16:13 +01:00
5534f00a7d style: Empty String to number
All checks were successful
continuous-integration/drone/push Build is passing
2024-03-24 01:38:56 +01:00
5c0dab3448 feat: Realtime for Votes + Deadlines 2024-03-24 01:36:16 +01:00
b1cb25c823 style: Change Star to Check
All checks were successful
continuous-integration/drone/push Build is passing
2024-03-24 00:28:57 +01:00
dbdc7c1540 fix: Hash in Build #4
All checks were successful
continuous-integration/drone/push Build is passing
2024-03-24 00:17:56 +01:00
4e65112476 fix: Hash in Build #4
All checks were successful
continuous-integration/drone/push Build is passing
2024-03-23 23:51:48 +01:00
b0ea02928a fix: Hash in Build #3
All checks were successful
continuous-integration/drone/push Build is passing
2024-03-23 23:45:56 +01:00
fb64c29015 fix: Hash in Build #2
All checks were successful
continuous-integration/drone/push Build is passing
2024-03-23 23:38:03 +01:00
1887add00d fix: Hash in Build
All checks were successful
continuous-integration/drone/push Build is passing
2024-03-23 23:30:06 +01:00
4bb02faf14 build: Adjust Build Path
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone Build is passing
2024-03-23 22:47:03 +01:00
18388246f8 build: Adjust Build Path
Some checks failed
continuous-integration/drone/push Build is failing
2024-03-23 22:42:14 +01:00
041232bcae feat: Added Footer
All checks were successful
continuous-integration/drone/push Build is passing
2024-03-23 22:26:05 +01:00
3e58347acb style: Changed Icon 2024-03-23 22:21:36 +01:00
73dab4dd6e feat: Changed Vote Icon to Check 2024-03-23 21:28:13 +01:00
c2a2ae0d07 feat: Added Jobs Admin Page 2024-03-23 21:27:57 +01:00
b6f9968a46 feat: Confetti on Deadline Confirm
All checks were successful
continuous-integration/drone/push Build is passing
2024-03-23 19:01:47 +01:00
f497b3af55 chore: Updated .env.example
All checks were successful
continuous-integration/drone/push Build is passing
2024-03-23 18:07:15 +01:00
5f1e3ee176 feat: Realtime Functionality
All checks were successful
continuous-integration/drone/push Build is passing
2024-03-23 17:40:59 +01:00
d6ec298e56 fix: Wrong ISBN 2024-03-22 23:42:49 +01:00
74c39c8c89 fix: Navigation Mobile
All checks were successful
continuous-integration/drone/push Build is passing
2024-03-21 13:14:10 +01:00
b07d0d3e9d feat: Add Sort to all Columns
All checks were successful
continuous-integration/drone/push Build is passing
2024-03-21 12:52:55 +01:00
ecc98924e3 feat: Add Preview for Local Files
All checks were successful
continuous-integration/drone/push Build is passing
2024-03-21 12:40:21 +01:00
999bd8e3d4 fix: Typo 2024-03-21 12:40:05 +01:00
78e3f0ff0c fix: Preview for local Files #4
All checks were successful
continuous-integration/drone/push Build is passing
2024-03-21 12:36:24 +01:00
9fd7b9f4c3 fix: Preview for local Files #3
All checks were successful
continuous-integration/drone/push Build is passing
2024-03-21 12:31:28 +01:00
1297ba88c5 fix: Preview for local Files
All checks were successful
continuous-integration/drone/push Build is passing
2024-03-21 12:25:08 +01:00
ac371e4451 fix: Preview for local Files
All checks were successful
continuous-integration/drone/push Build is passing
2024-03-21 12:18:10 +01:00
20d03b385b fix: Add Check for string before rendering image
All checks were successful
continuous-integration/drone/push Build is passing
2024-03-21 12:14:22 +01:00
f52415346e fix: Dont run Role Middleware on the Server
All checks were successful
continuous-integration/drone/push Build is passing
2024-03-21 11:47:16 +01:00
829fc93c98 wip: Try reenabe Nav Links + Client
All checks were successful
continuous-integration/drone/push Build is passing
2024-03-21 11:28:18 +01:00
70278dc6c0 wip: Try Running pm2 under flycro
All checks were successful
continuous-integration/drone/push Build is passing
2024-03-21 11:20:53 +01:00
8d276ee0d7 wip: Remove Admin Menu for now
All checks were successful
continuous-integration/drone/push Build is passing
2024-03-21 02:47:12 +01:00
e97c3bcf83 fix: Hopefully fix SSR in Navigation #2
All checks were successful
continuous-integration/drone/push Build is passing
2024-03-21 02:41:02 +01:00
05898fbd9b fix: Hopefully fix SSR in Navigation
All checks were successful
continuous-integration/drone/push Build is passing
2024-03-21 02:36:31 +01:00
13a4730c15 wip: Testing SSR + Changed Build
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone Build is passing
2024-03-21 02:12:30 +01:00
aff6a6b6e4 fix: Auth Dependent Button on Client
All checks were successful
continuous-integration/drone/push Build is passing
2024-03-21 01:59:20 +01:00
0c42894960 wip: try nuxi cleanup before build
All checks were successful
continuous-integration/drone/push Build is passing
2024-03-21 01:28:59 +01:00
09ef7f2c9e wip: .js to .cjs
All checks were successful
continuous-integration/drone/push Build is passing
2024-03-21 01:20:18 +01:00
62042f3457 wip: Do Permission Stuff at the End
Some checks failed
continuous-integration/drone/push Build is failing
2024-03-21 01:16:19 +01:00
bffcd2f414 wip: Reordered Build Steps and added Dockerignore
Some checks failed
continuous-integration/drone/push Build is failing
2024-03-21 01:13:53 +01:00
6330268d20 wip: Forgot Compose in Docker
Some checks failed
continuous-integration/drone/push Build is failing
2024-03-21 01:06:31 +01:00
87b8d48b84 wip: Wrong Paths
Some checks failed
continuous-integration/drone/push Build is failing
2024-03-21 01:03:00 +01:00
517e166547 wip: Install all Packages for now
Some checks failed
continuous-integration/drone/push Build is failing
2024-03-21 01:00:57 +01:00
ae83ec3ff6 fix: Fixed Problems in Docker Files + deploy script
Some checks failed
continuous-integration/drone Build is failing
2024-03-21 00:57:04 +01:00
e1092d616a fix: Fixed Problems in Docker Files 2024-03-21 00:35:35 +01:00
32ff5f22dd fix: Moved Routes to auth:sanctum 2024-03-21 00:25:48 +01:00
28d15cd26a build: Docker Files for Production 2024-03-20 21:05:15 +01:00
5a546f8ef2 build: Docker Files for Production 2024-03-20 20:56:09 +01:00
8c595aa3c6 fix: remove unused variables 2024-03-20 18:16:47 +01:00
b001ea937f feat: Admin Page to Manage Vote Actions 2024-03-20 18:14:29 +01:00
fc63da0a3a feat: Theme Color from Nuxt UI Docs 2024-03-20 18:13:59 +01:00
48a87c9b7b feat: reenabled nuxt security 2024-03-20 18:13:42 +01:00
837441ecb8 feat: book-recommendations page 2024-03-20 18:13:17 +01:00
bd8225374a feat: Admin Page 2024-03-20 18:13:06 +01:00
726c86dbcd chore: Current Information to DatabaseSeeder 2024-03-20 18:12:42 +01:00
e393ba5943 feat: Vote Functionality 2024-03-20 18:12:23 +01:00
1d7f41e812 feat: Add Config + Set Default Font to DM Sans 2024-03-20 18:12:02 +01:00
2953acd630 feat: Command to fetch Cover Art from OpenLib 2024-03-20 18:11:41 +01:00
74d6f3efaa refactor: Navigation 2024-03-20 18:11:20 +01:00
95ece0f614 feat: Add UserDeadline Relation 2024-03-20 18:10:53 +01:00
fffe1b4717 feat: BR Overview + Modal for New Recommendations 2024-03-20 18:10:02 +01:00
923e41b396 feat: Dashboard Functionality 2024-03-20 18:09:26 +01:00
cbc54210f0 feat: Modal to Delete BookRecommendations 2024-03-20 18:08:31 +01:00
1a836b71f0 feat: Modal to Edit BookRecommendations 2024-03-20 18:08:23 +01:00
3857ff70d2 feat: UserDeadline Functionality 2024-03-20 18:07:58 +01:00
b97f8c785f feat: Deadline Functionality 2024-03-20 18:07:46 +01:00
5ab2c46335 feat: BookRecommendation Functionality 2024-03-20 18:07:30 +01:00
096698a8e1 feat: Routes for various Systems 2024-03-20 18:06:46 +01:00
daa80f4ff3 feat: Migrations for various Systems 2024-03-20 18:06:32 +01:00
70 changed files with 2530 additions and 318 deletions

3
.dockerignore Normal file
View File

@@ -0,0 +1,3 @@
**/node_modules/
**/dist
.git

33
.drone.yml Normal file
View 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

View File

@@ -45,7 +45,7 @@ CACHE_PREFIX=
MEMCACHED_HOST=127.0.0.1 MEMCACHED_HOST=127.0.0.1
REDIS_CLIENT=phpredis REDIS_CLIENT=predis
REDIS_HOST=redis REDIS_HOST=redis
REDIS_PASSWORD=null REDIS_PASSWORD=null
REDIS_PORT=6379 REDIS_PORT=6379

11
Dockerfile Normal file
View 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"]

View 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.');
}
}
}
}

View 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'),
];
}
}

View 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'),
];
}
}

View 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'),
];
}
}

View 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'),
];
}
}

View 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'),
];
}
}

View File

@@ -4,6 +4,7 @@ namespace App\Http\Controllers;
use App\Models\BookRecommendation; use App\Models\BookRecommendation;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
class BookRecommendationController extends Controller class BookRecommendationController extends Controller
@@ -14,10 +15,16 @@ class BookRecommendationController extends Controller
public function index() public function index()
{ {
$relations = []; $relations = [];
$bookRecommendations = BookRecommendation::query();
if (request()->has('with')) { if (request()->has('with')) {
$relations = explode(',', request()->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; $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); return response()->json($bookRecommendation, 201);
} }
@@ -124,7 +131,16 @@ class BookRecommendationController extends Controller
if ($bookRecommendation->recommended_by !== auth()->id() && !(auth()->user()->hasRole('admin')) ) { if ($bookRecommendation->recommended_by !== auth()->id() && !(auth()->user()->hasRole('admin')) ) {
return response()->json(['message' => 'Keine Berechtigung.'], 403); return response()->json(['message' => 'Keine Berechtigung.'], 403);
} }
if ($bookRecommendation->cover_image) {
Storage::delete($bookRecommendation->cover_image);
}
$bookRecommendation->delete(); $bookRecommendation->delete();
return response()->json(null, 204); return response()->json(null, 204);
} }
public function fetchCover(Request $request)
{
Artisan::call('book:open-library-fetch-cover-art');
return response()->json(Artisan::output());
}
} }

View 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.']);
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\BookRecommendation; use App\Models\BookRecommendation;
use App\Models\User;
use App\Models\Vote; use App\Models\Vote;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -29,4 +30,22 @@ class VoteController extends Controller
return response()->json(['message' => 'No remaining votes.'], 403); 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.']);
}
} }

View File

@@ -2,7 +2,12 @@
namespace App\Models; 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\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class BookRecommendation extends Model class BookRecommendation extends Model
{ {
@@ -17,18 +22,39 @@ class BookRecommendation extends Model
'recommended_by', 'recommended_by',
'cover_image', 'cover_image',
'status', '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. * Get the user that recommended the book.
*/ */
public function recommender() public function recommender(): BelongsTo
{ {
return $this->belongsTo(User::class, 'recommended_by'); return $this->belongsTo(User::class, 'recommended_by');
} }
public function votes() public function votes(): HasMany
{ {
return $this->hasMany(Vote::class); return $this->hasMany(Vote::class);
} }
public function deadlines(): HasMany
{
return $this->hasMany(Deadline::class);
}
} }

35
app/Models/Deadline.php Normal file
View 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);
}
}

View File

@@ -63,6 +63,11 @@ class User extends Authenticatable implements MustVerifyEmail
return $this->hasMany(Vote::class); return $this->hasMany(Vote::class);
} }
public function deadlinesProgress()
{
return $this->hasMany(UserDeadline::class);
}
public function mustVerifyEmail(): bool public function mustVerifyEmail(): bool
{ {
return $this instanceof MustVerifyEmail && !$this->hasVerifiedEmail(); return $this instanceof MustVerifyEmail && !$this->hasVerifiedEmail();

View 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);
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Models; namespace App\Models;
use App\Events\Vote\VoteCreated;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
@@ -14,6 +15,13 @@ class Vote extends Model
'book_recommendation_id', 'book_recommendation_id',
]; ];
protected static function booted() :void
{
static::created(static function ($vote) {
broadcast(new VoteCreated($vote))->toOthers();
});
}
public function user() public function user()
{ {
return $this->belongsTo(User::class); return $this->belongsTo(User::class);

View File

@@ -14,6 +14,7 @@ return Application::configure(basePath: dirname(__DIR__))
api: __DIR__.'/../routes/api.php', api: __DIR__.'/../routes/api.php',
apiPrefix: '', apiPrefix: '',
commands: __DIR__.'/../routes/console.php', commands: __DIR__.'/../routes/console.php',
channels: __DIR__.'/../routes/channels.php',
health: '/up', health: '/up',
) )
->withMiddleware(function (Middleware $middleware) { ->withMiddleware(function (Middleware $middleware) {

View File

@@ -18,6 +18,7 @@
"laravel/tinker": "^2.9", "laravel/tinker": "^2.9",
"league/flysystem-aws-s3-v3": "^3.24", "league/flysystem-aws-s3-v3": "^3.24",
"predis/predis": "*", "predis/predis": "*",
"pusher/pusher-php-server": "^7.2",
"spatie/laravel-permission": "^6.4" "spatie/laravel-permission": "^6.4"
}, },
"require-dev": { "require-dev": {

359
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "ec31886b4063db8aa1efc22c187f614a", "content-hash": "572762c5d2b5b91e9a8012b47f3cd07f",
"packages": [ "packages": [
{ {
"name": "aws/aws-crt-php", "name": "aws/aws-crt-php",
@@ -62,16 +62,16 @@
}, },
{ {
"name": "aws/aws-sdk-php", "name": "aws/aws-sdk-php",
"version": "3.301.1", "version": "3.301.6",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/aws/aws-sdk-php.git", "url": "https://github.com/aws/aws-sdk-php.git",
"reference": "0a910d2b35e7087337cdf3569dc9b6ce232aafba" "reference": "18c0ebd71d3071304f1ea02aa9af75f95863177a"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/0a910d2b35e7087337cdf3569dc9b6ce232aafba", "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/18c0ebd71d3071304f1ea02aa9af75f95863177a",
"reference": "0a910d2b35e7087337cdf3569dc9b6ce232aafba", "reference": "18c0ebd71d3071304f1ea02aa9af75f95863177a",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -151,9 +151,9 @@
"support": { "support": {
"forum": "https://forums.aws.amazon.com/forum.jspa?forumID=80", "forum": "https://forums.aws.amazon.com/forum.jspa?forumID=80",
"issues": "https://github.com/aws/aws-sdk-php/issues", "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", "name": "brick/math",
@@ -1408,16 +1408,16 @@
}, },
{ {
"name": "intervention/image", "name": "intervention/image",
"version": "3.4.0", "version": "3.5.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/Intervention/image.git", "url": "https://github.com/Intervention/image.git",
"reference": "fe1b0e2e64157133322974c28b44c25c2770a0c5" "reference": "408d3655c7705339e8c79731ea7efb51546cfa10"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/Intervention/image/zipball/fe1b0e2e64157133322974c28b44c25c2770a0c5", "url": "https://api.github.com/repos/Intervention/image/zipball/408d3655c7705339e8c79731ea7efb51546cfa10",
"reference": "fe1b0e2e64157133322974c28b44c25c2770a0c5", "reference": "408d3655c7705339e8c79731ea7efb51546cfa10",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -1428,7 +1428,7 @@
"require-dev": { "require-dev": {
"mockery/mockery": "^1.6", "mockery/mockery": "^1.6",
"phpstan/phpstan": "^1", "phpstan/phpstan": "^1",
"phpunit/phpunit": "^9", "phpunit/phpunit": "^10.0",
"slevomat/coding-standard": "~8.0", "slevomat/coding-standard": "~8.0",
"squizlabs/php_codesniffer": "^3.8" "squizlabs/php_codesniffer": "^3.8"
}, },
@@ -1464,7 +1464,7 @@
], ],
"support": { "support": {
"issues": "https://github.com/Intervention/image/issues", "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": [ "funding": [
{ {
@@ -1476,20 +1476,20 @@
"type": "github" "type": "github"
} }
], ],
"time": "2024-02-14T15:11:21+00:00" "time": "2024-03-13T16:26:15+00:00"
}, },
{ {
"name": "jaybizzle/crawler-detect", "name": "jaybizzle/crawler-detect",
"version": "v1.2.116", "version": "v1.2.117",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/JayBizzle/Crawler-Detect.git", "url": "https://github.com/JayBizzle/Crawler-Detect.git",
"reference": "97e9fe30219e60092e107651abb379a38b342921" "reference": "6785557f03d0fa9e2205352ebae9a12a4484cc8e"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/JayBizzle/Crawler-Detect/zipball/97e9fe30219e60092e107651abb379a38b342921", "url": "https://api.github.com/repos/JayBizzle/Crawler-Detect/zipball/6785557f03d0fa9e2205352ebae9a12a4484cc8e",
"reference": "97e9fe30219e60092e107651abb379a38b342921", "reference": "6785557f03d0fa9e2205352ebae9a12a4484cc8e",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -1526,9 +1526,9 @@
], ],
"support": { "support": {
"issues": "https://github.com/JayBizzle/Crawler-Detect/issues", "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", "name": "laminas/laminas-diactoros",
@@ -1617,16 +1617,16 @@
}, },
{ {
"name": "laravel/framework", "name": "laravel/framework",
"version": "v11.0.7", "version": "v11.0.8",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/laravel/framework.git", "url": "https://github.com/laravel/framework.git",
"reference": "28eabe9dcdcb017a21ce226eda4538c5c8c93b1c" "reference": "0379a7ccb77e2029c43ce508fa76e251a0d68fce"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/laravel/framework/zipball/28eabe9dcdcb017a21ce226eda4538c5c8c93b1c", "url": "https://api.github.com/repos/laravel/framework/zipball/0379a7ccb77e2029c43ce508fa76e251a0d68fce",
"reference": "28eabe9dcdcb017a21ce226eda4538c5c8c93b1c", "reference": "0379a7ccb77e2029c43ce508fa76e251a0d68fce",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -1729,7 +1729,7 @@
"league/flysystem-sftp-v3": "^3.0", "league/flysystem-sftp-v3": "^3.0",
"mockery/mockery": "^1.6", "mockery/mockery": "^1.6",
"nyholm/psr7": "^1.2", "nyholm/psr7": "^1.2",
"orchestra/testbench-core": "^9.0", "orchestra/testbench-core": "^9.0.6",
"pda/pheanstalk": "^5.0", "pda/pheanstalk": "^5.0",
"phpstan/phpstan": "^1.4.7", "phpstan/phpstan": "^1.4.7",
"phpunit/phpunit": "^10.5|^11.0", "phpunit/phpunit": "^10.5|^11.0",
@@ -1818,7 +1818,7 @@
"issues": "https://github.com/laravel/framework/issues", "issues": "https://github.com/laravel/framework/issues",
"source": "https://github.com/laravel/framework" "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", "name": "laravel/octane",
@@ -2414,16 +2414,16 @@
}, },
{ {
"name": "league/flysystem", "name": "league/flysystem",
"version": "3.25.0", "version": "3.25.1",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/thephpleague/flysystem.git", "url": "https://github.com/thephpleague/flysystem.git",
"reference": "4c44347133618cccd9b3df1729647a1577b4ad99" "reference": "abbd664eb4381102c559d358420989f835208f18"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/thephpleague/flysystem/zipball/4c44347133618cccd9b3df1729647a1577b4ad99", "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/abbd664eb4381102c559d358420989f835208f18",
"reference": "4c44347133618cccd9b3df1729647a1577b4ad99", "reference": "abbd664eb4381102c559d358420989f835208f18",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -2488,7 +2488,7 @@
], ],
"support": { "support": {
"issues": "https://github.com/thephpleague/flysystem/issues", "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": [ "funding": [
{ {
@@ -2500,20 +2500,20 @@
"type": "github" "type": "github"
} }
], ],
"time": "2024-03-09T17:06:45+00:00" "time": "2024-03-16T12:53:19+00:00"
}, },
{ {
"name": "league/flysystem-aws-s3-v3", "name": "league/flysystem-aws-s3-v3",
"version": "3.24.0", "version": "3.25.1",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/thephpleague/flysystem-aws-s3-v3.git", "url": "https://github.com/thephpleague/flysystem-aws-s3-v3.git",
"reference": "809474e37b7fb1d1f8bcc0f8a98bc1cae99aa513" "reference": "6a5be0e6d6a93574e80805c9cc108a4b63c824d8"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/809474e37b7fb1d1f8bcc0f8a98bc1cae99aa513", "url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/6a5be0e6d6a93574e80805c9cc108a4b63c824d8",
"reference": "809474e37b7fb1d1f8bcc0f8a98bc1cae99aa513", "reference": "6a5be0e6d6a93574e80805c9cc108a4b63c824d8",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -2553,7 +2553,7 @@
"storage" "storage"
], ],
"support": { "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": [ "funding": [
{ {
@@ -2565,20 +2565,20 @@
"type": "github" "type": "github"
} }
], ],
"time": "2024-01-26T18:43:21+00:00" "time": "2024-03-15T19:58:44+00:00"
}, },
{ {
"name": "league/flysystem-local", "name": "league/flysystem-local",
"version": "3.23.1", "version": "3.25.1",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/thephpleague/flysystem-local.git", "url": "https://github.com/thephpleague/flysystem-local.git",
"reference": "b884d2bf9b53bb4804a56d2df4902bb51e253f00" "reference": "61a6a90d6e999e4ddd9ce5adb356de0939060b92"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/b884d2bf9b53bb4804a56d2df4902bb51e253f00", "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/61a6a90d6e999e4ddd9ce5adb356de0939060b92",
"reference": "b884d2bf9b53bb4804a56d2df4902bb51e253f00", "reference": "61a6a90d6e999e4ddd9ce5adb356de0939060b92",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -2612,8 +2612,7 @@
"local" "local"
], ],
"support": { "support": {
"issues": "https://github.com/thephpleague/flysystem-local/issues", "source": "https://github.com/thephpleague/flysystem-local/tree/3.25.1"
"source": "https://github.com/thephpleague/flysystem-local/tree/3.23.1"
}, },
"funding": [ "funding": [
{ {
@@ -2625,7 +2624,7 @@
"type": "github" "type": "github"
} }
], ],
"time": "2024-01-26T18:25:23+00:00" "time": "2024-03-15T19:58:44+00:00"
}, },
{ {
"name": "league/mime-type-detection", "name": "league/mime-type-detection",
@@ -3570,6 +3569,142 @@
], ],
"time": "2024-03-06T16:17:14+00:00" "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", "name": "phpoption/phpoption",
"version": "1.9.2", "version": "1.9.2",
@@ -4120,16 +4255,16 @@
}, },
{ {
"name": "psy/psysh", "name": "psy/psysh",
"version": "v0.12.1", "version": "v0.12.2",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/bobthecow/psysh.git", "url": "https://github.com/bobthecow/psysh.git",
"reference": "39621c73e0754328252f032c6997b983afc50431" "reference": "9185c66c2165bbf4d71de78a69dccf4974f9538d"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/bobthecow/psysh/zipball/39621c73e0754328252f032c6997b983afc50431", "url": "https://api.github.com/repos/bobthecow/psysh/zipball/9185c66c2165bbf4d71de78a69dccf4974f9538d",
"reference": "39621c73e0754328252f032c6997b983afc50431", "reference": "9185c66c2165bbf4d71de78a69dccf4974f9538d",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -4193,9 +4328,70 @@
], ],
"support": { "support": {
"issues": "https://github.com/bobthecow/psysh/issues", "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", "name": "ralouphie/getallheaders",
@@ -7438,16 +7634,16 @@
}, },
{ {
"name": "composer/pcre", "name": "composer/pcre",
"version": "3.1.2", "version": "3.1.3",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/composer/pcre.git", "url": "https://github.com/composer/pcre.git",
"reference": "4775f35b2d70865807c89d32c8e7385b86eb0ace" "reference": "5b16e25a5355f1f3afdfc2f954a0a80aec4826a8"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/composer/pcre/zipball/4775f35b2d70865807c89d32c8e7385b86eb0ace", "url": "https://api.github.com/repos/composer/pcre/zipball/5b16e25a5355f1f3afdfc2f954a0a80aec4826a8",
"reference": "4775f35b2d70865807c89d32c8e7385b86eb0ace", "reference": "5b16e25a5355f1f3afdfc2f954a0a80aec4826a8",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -7489,7 +7685,7 @@
], ],
"support": { "support": {
"issues": "https://github.com/composer/pcre/issues", "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": [ "funding": [
{ {
@@ -7505,7 +7701,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2024-03-07T15:38:35+00:00" "time": "2024-03-19T10:26:25+00:00"
}, },
{ {
"name": "doctrine/deprecations", "name": "doctrine/deprecations",
@@ -7807,16 +8003,16 @@
}, },
{ {
"name": "laravel/sail", "name": "laravel/sail",
"version": "v1.29.0", "version": "v1.29.1",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/laravel/sail.git", "url": "https://github.com/laravel/sail.git",
"reference": "e40cc7ffb5186c45698dbd47e9477e0e429396d0" "reference": "8be4a31150eab3b46af11a2e7b2c4632eefaad7e"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/laravel/sail/zipball/e40cc7ffb5186c45698dbd47e9477e0e429396d0", "url": "https://api.github.com/repos/laravel/sail/zipball/8be4a31150eab3b46af11a2e7b2c4632eefaad7e",
"reference": "e40cc7ffb5186c45698dbd47e9477e0e429396d0", "reference": "8be4a31150eab3b46af11a2e7b2c4632eefaad7e",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -7824,6 +8020,7 @@
"illuminate/contracts": "^9.52.16|^10.0|^11.0", "illuminate/contracts": "^9.52.16|^10.0|^11.0",
"illuminate/support": "^9.52.16|^10.0|^11.0", "illuminate/support": "^9.52.16|^10.0|^11.0",
"php": "^8.0", "php": "^8.0",
"symfony/console": "^6.0|^7.0",
"symfony/yaml": "^6.0|^7.0" "symfony/yaml": "^6.0|^7.0"
}, },
"require-dev": { "require-dev": {
@@ -7865,7 +8062,7 @@
"issues": "https://github.com/laravel/sail/issues", "issues": "https://github.com/laravel/sail/issues",
"source": "https://github.com/laravel/sail" "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", "name": "laravel/telescope",
@@ -7937,16 +8134,16 @@
}, },
{ {
"name": "mockery/mockery", "name": "mockery/mockery",
"version": "1.6.9", "version": "1.6.11",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/mockery/mockery.git", "url": "https://github.com/mockery/mockery.git",
"reference": "0cc058854b3195ba21dc6b1f7b1f60f4ef3a9c06" "reference": "81a161d0b135df89951abd52296adf97deb0723d"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/mockery/mockery/zipball/0cc058854b3195ba21dc6b1f7b1f60f4ef3a9c06", "url": "https://api.github.com/repos/mockery/mockery/zipball/81a161d0b135df89951abd52296adf97deb0723d",
"reference": "0cc058854b3195ba21dc6b1f7b1f60f4ef3a9c06", "reference": "81a161d0b135df89951abd52296adf97deb0723d",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -7958,8 +8155,8 @@
"phpunit/phpunit": "<8.0" "phpunit/phpunit": "<8.0"
}, },
"require-dev": { "require-dev": {
"phpunit/phpunit": "^8.5 || ^9.6.10", "phpunit/phpunit": "^8.5 || ^9.6.17",
"symplify/easy-coding-standard": "^12.0.8" "symplify/easy-coding-standard": "^12.1.14"
}, },
"type": "library", "type": "library",
"autoload": { "autoload": {
@@ -8016,7 +8213,7 @@
"security": "https://github.com/mockery/mockery/security/advisories", "security": "https://github.com/mockery/mockery/security/advisories",
"source": "https://github.com/mockery/mockery" "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", "name": "myclabs/deep-copy",
@@ -8405,16 +8602,16 @@
}, },
{ {
"name": "phpstan/phpdoc-parser", "name": "phpstan/phpdoc-parser",
"version": "1.26.0", "version": "1.27.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/phpstan/phpdoc-parser.git", "url": "https://github.com/phpstan/phpdoc-parser.git",
"reference": "231e3186624c03d7e7c890ec662b81e6b0405227" "reference": "86e4d5a4b036f8f0be1464522f4c6b584c452757"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/231e3186624c03d7e7c890ec662b81e6b0405227", "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/86e4d5a4b036f8f0be1464522f4c6b584c452757",
"reference": "231e3186624c03d7e7c890ec662b81e6b0405227", "reference": "86e4d5a4b036f8f0be1464522f4c6b584c452757",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -8446,9 +8643,9 @@
"description": "PHPDoc parser with support for nullable, intersection and generic types", "description": "PHPDoc parser with support for nullable, intersection and generic types",
"support": { "support": {
"issues": "https://github.com/phpstan/phpdoc-parser/issues", "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", "name": "phpunit/php-code-coverage",
@@ -8773,16 +8970,16 @@
}, },
{ {
"name": "phpunit/phpunit", "name": "phpunit/phpunit",
"version": "10.5.13", "version": "10.5.15",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/sebastianbergmann/phpunit.git", "url": "https://github.com/sebastianbergmann/phpunit.git",
"reference": "20a63fc1c6db29b15da3bd02d4b6cf59900088a7" "reference": "86376e05e8745ed81d88232ff92fee868247b07b"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/20a63fc1c6db29b15da3bd02d4b6cf59900088a7", "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/86376e05e8745ed81d88232ff92fee868247b07b",
"reference": "20a63fc1c6db29b15da3bd02d4b6cf59900088a7", "reference": "86376e05e8745ed81d88232ff92fee868247b07b",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -8854,7 +9051,7 @@
"support": { "support": {
"issues": "https://github.com/sebastianbergmann/phpunit/issues", "issues": "https://github.com/sebastianbergmann/phpunit/issues",
"security": "https://github.com/sebastianbergmann/phpunit/security/policy", "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": [ "funding": [
{ {
@@ -8870,7 +9067,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2024-03-12T15:37:41+00:00" "time": "2024-03-22T04:17:47+00:00"
}, },
{ {
"name": "sebastian/cli-parser", "name": "sebastian/cli-parser",

82
config/broadcasting.php Normal file
View 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',
],
],
];

View File

@@ -21,6 +21,7 @@ return new class extends Migration
$table->string('cover_image')->nullable(); $table->string('cover_image')->nullable();
$table->unsignedBigInteger('recommended_by'); $table->unsignedBigInteger('recommended_by');
$table->enum('status', ['PENDING', 'COMPLETED', 'REJECTED','ACTIVE'])->default('PENDING'); $table->enum('status', ['PENDING', 'COMPLETED', 'REJECTED','ACTIVE'])->default('PENDING');
$table->dateTime('published_at')->nullable();
$table->timestamps(); $table->timestamps();
$table->foreign('recommended_by')->references('id')->on('users')->onDelete('cascade'); $table->foreign('recommended_by')->references('id')->on('users')->onDelete('cascade');

View File

@@ -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');
}
};

View File

@@ -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');
}
};

View File

@@ -14,7 +14,7 @@ class BookRecommendationsTableSeeder extends Seeder
public function run(): void public function run(): void
{ {
// Let's say we want to create 50 fake book recommendations // 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([ BookRecommendation::create([
'book_name' => fake()->sentence($nbWords = 3, $variableNbWords = true), 'book_name' => fake()->sentence($nbWords = 3, $variableNbWords = true),
'author' => fake()->name, 'author' => fake()->name,
@@ -24,6 +24,107 @@ class BookRecommendationsTableSeeder extends Seeder
'recommended_by' => 1, // Adjust the range as necessary 'recommended_by' => 1, // Adjust the range as necessary
'cover_image' => null, // You could also simulate image paths if needed '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'
]);
} }
} }

View File

@@ -25,8 +25,8 @@ class DatabaseSeeder extends Seeder
// create admin user // create admin user
$user = \App\Models\User::factory()->create([ $user = \App\Models\User::factory()->create([
'name' => 'Super', 'name' => 'Philipp',
'email' => 'admin@admin.com', 'email' => 'hi@flycro.me',
'password' => Hash::make('password'), 'password' => Hash::make('password'),
]); ]);
@@ -36,6 +36,40 @@ class DatabaseSeeder extends Seeder
$user->assignRole($role1); $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); $this->call(BookRecommendationsTableSeeder::class);
} }
} }

View 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
View 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,
}
}
]
}

View File

@@ -1,4 +1,6 @@
// https://nuxt.com/docs/api/configuration/nuxt-config // https://nuxt.com/docs/api/configuration/nuxt-config
import pkg from './package.json'
export default defineNuxtConfig({ export default defineNuxtConfig({
srcDir: 'nuxt/', srcDir: 'nuxt/',
@@ -11,7 +13,7 @@ export default defineNuxtConfig({
app: { app: {
head: { head: {
title: 'Laravel/Nuxt Boilerplate', title: 'BookStack',
meta: [ meta: [
{ charset: 'utf-8' }, { charset: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' }, { 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 * @see https://v3.nuxtjs.org/api/configuration/nuxt.config#modules
*/ */
extends: ['@nuxt/ui-pro'], extends: ['@nuxt/ui-pro'],
modules: [ modules: [
'@nuxt/ui', '@nuxt/ui',
'@nuxt/image', '@nuxt/image',
'@pinia/nuxt', '@pinia/nuxt',
'dayjs-nuxt', 'dayjs-nuxt',
'nuxt-security',
], ],
ui: { ui: {
@@ -74,6 +78,8 @@ export default defineNuxtConfig({
apiBase: process.env.API_URL, apiBase: process.env.API_URL,
apiPrefix: '/api/v1', apiPrefix: '/api/v1',
storageBase: `${process.env.API_URL}/storage/`, storageBase: `${process.env.API_URL}/storage/`,
packageVersion: pkg.version,
gitHash: process.env.GIT_HASH,
providers: { providers: {
google: { google: {
name: 'Google', name: 'Google',
@@ -81,6 +87,17 @@ export default defineNuxtConfig({
color: 'gray', 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'],
},
}, },
}, },
}) })

View File

@@ -2,9 +2,12 @@
</script> </script>
<template> <template>
<NuxtLayout> <UMain>
<NuxtPage /> <NuxtLayout>
</NuxtLayout> <NuxtPage />
</NuxtLayout>
</UMain>
<Footer />
<UNotifications /> <UNotifications />
</template> </template>

View 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>

View File

@@ -1,18 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
const links = [{ import ColorPicker from '~/components/color-picker/ColorPicker.vue'
label: 'Documentation',
icon: 'i-heroicons-book-open', const links = []
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',
}]
</script> </script>
<template> <template>
@@ -22,12 +11,13 @@ const links = [{
</template> </template>
<template #right> <template #right>
<ColorPicker />
<UColorModeButton /> <UColorModeButton />
<UserDropdown /> <UserDropdown />
</template> </template>
<template #panel> <template #panel>
<UNavigationTree :links="links" /> <Navigation />
</template> </template>
</UHeader> </UHeader>
</template> </template>

View File

@@ -1,5 +1,5 @@
<template> <template>
<div> <div>
<span class="text-primary">Nuxt</span> Breeze <span class="text-primary">Book</span> Stack
</div> </div>
</template> </template>

View 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>

View File

@@ -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>

View 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>

View File

@@ -1,15 +1,23 @@
<script setup lang="ts"> <script setup lang="ts">
const links = [ const authStore = useAuthStore()
{
label: 'Account', const links = [{
to: '/account', label: 'Account',
icon: 'i-heroicons-user-solid', to: '/account',
}, icon: 'i-heroicons-user-solid',
{ }, {
label: 'Logout', label: 'Logout',
to: '/logout', to: '/logout',
icon: 'i-heroicons-arrow-left-on-rectangle', 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> </script>
<template> <template>

View 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>

View File

@@ -6,6 +6,8 @@ import NewBookRecommendation from '~/components/modal/NewBookRecommendation.vue'
import CastVote from '~/components/modal/CastVote.vue' import CastVote from '~/components/modal/CastVote.vue'
const dayjs = useDayjs() const dayjs = useDayjs()
const { $echo } = useNuxtApp()
const authStore = useAuthStore()
const bookRecommendationStore = useBookRecommendationStore() const bookRecommendationStore = useBookRecommendationStore()
bookRecommendationStore.fetchRecommendations() bookRecommendationStore.fetchRecommendations()
@@ -14,10 +16,17 @@ const columns = [
{ {
key: 'book_name', key: 'book_name',
label: 'Name', label: 'Name',
sortable: true,
}, },
{ {
key: 'author', key: 'author',
label: 'Autor', label: 'Autor',
sortable: true,
},
{
key: 'published_at',
label: 'Erstveröffentlichung',
sortable: true,
}, },
{ {
key: 'description', key: 'description',
@@ -26,45 +35,94 @@ const columns = [
{ {
key: 'pages', key: 'pages',
label: 'Seiten', label: 'Seiten',
sortable: true,
}, },
{ {
key: 'recommender.name', key: 'recommender.name',
label: 'Empfohlen von', label: 'Empfohlen von',
sortable: true,
}, },
{ {
key: 'created_at', key: 'created_at',
label: 'Erstellt am', label: 'Erstellt am',
sortable: true,
}, },
{ {
key: 'status', key: 'status',
label: 'Status', label: 'Status',
sortable: true,
}, },
{ {
key: 'votes', key: 'votes',
label: 'Votes', label: 'Votes',
sortable: true,
}, },
{ {
key: 'actions', key: 'actions',
label: '', 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> </script>
<template> <template>
<div> <div>
<NewBookRecommendation /> <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 }"> <template #created_at-data="{ row }">
<div>{{ dayjs(row.created_at).format('DD.MM.YYYY') }}</div> <div>{{ dayjs(row.created_at).format('DD.MM.YYYY') }}</div>
</template> </template>
<template #description-data="{ row }"> <template #published_at-data="{ row }">
{{ `${row.description.substring(0, 50)}...` }} <div>{{ dayjs(row.published_at).format('DD.MM.YYYY') }}</div>
</template> </template>
<template #votes-data="{ row }"> <template #votes-data="{ row }">
{{ row.votes.length }} {{ row.votes.length }}
</template> </template>
<template #status-data="{ row }">
<UBadge :color="resolveStatus(row.status)?.color">
{{ resolveStatus(row.status)?.name }}
</UBadge>
</template>
<template #actions-data="{ row }"> <template #actions-data="{ row }">
<div class="flex space-x-2"> <div class="flex max-w-full space-x-2">
<CastVote :row="row" /> <CastVote :row="row" />
<EditBookRecommendation :row="row" /> <EditBookRecommendation :row="row" />
<DeleteBookRecommendation :row="row" /> <DeleteBookRecommendation :row="row" />

View 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>

View 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>

View 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>

View 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>

View File

@@ -1,10 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import type { BookRecommendationStatusEnum } from '~/stores/book-recommendations'
import { useBookRecommendationStore } from '~/stores/book-recommendations' import { useBookRecommendationStore } from '~/stores/book-recommendations'
const props = defineProps<{ const props = defineProps<{
row: { row: {
id: number id: number
book_name: string book_name: string
status: BookRecommendationStatusEnum
} }
}>() }>()
@@ -16,6 +18,10 @@ const state = reactive({
book_recommendation_id: props.row.id, book_recommendation_id: props.row.id,
}) })
watch(() => props.row, (newRow) => {
state.book_recommendation_id = newRow.id
})
const bookRecommendationStore = useBookRecommendationStore() const bookRecommendationStore = useBookRecommendationStore()
const { refresh: onVote, status } = useFetch<any>(`vote`, { const { refresh: onVote, status } = useFetch<any>(`vote`, {
@@ -26,7 +32,7 @@ const { refresh: onVote, status } = useFetch<any>(`vote`, {
async onResponse({ response }) { async onResponse({ response }) {
if (response.ok) { if (response.ok) {
useToast().add({ useToast().add({
icon: 'i-heroicons-check-circle-20-solid', icon: 'i-heroicons-check-circle',
title: 'Abstimmung erfolgreich.', title: 'Abstimmung erfolgreich.',
color: 'emerald', color: 'emerald',
}) })
@@ -39,14 +45,14 @@ const { refresh: onVote, status } = useFetch<any>(`vote`, {
</script> </script>
<template> <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 <UDashboardModal
v-model="isOpen" v-model="isOpen"
title="Buch Empfehlung löschen" title="Für Buch abstimmen"
:description="`Bist du dir sicher das du für die Buchempfehlung ${row.book_name} abstimmen möchtest?`" :description="`Bist du dir sicher das du für die Buchempfehlung &quot;${row.book_name}&quot; abstimmen möchtest?`"
icon="i-heroicons-star" icon="i-heroicons-check-circle"
:ui="{ :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, footer: { base: 'ml-16' } as any,
}" }"
> >

View 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>

View File

@@ -5,13 +5,19 @@ const props = defineProps<{
row: { row: {
id: number id: number
book_name: string book_name: string
recommender: {
ulid: string
}
} }
}>() }>()
const authStore = useAuthStore()
const isOpen = ref(false) const isOpen = ref(false)
const bookRecommendationStore = useBookRecommendationStore() 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', method: 'DELETE',
immediate: false, immediate: false,
watch: false, watch: false,
@@ -31,7 +37,7 @@ const { refresh: onDelete, status } = useFetch<any>(`book-recommendations/${prop
</script> </script>
<template> <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 <UDashboardModal
v-model="isOpen" v-model="isOpen"
title="Buch Empfehlung löschen" title="Buch Empfehlung löschen"

View File

@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { useBookRecommendationStore } from '~/stores/book-recommendations' import { useBookRecommendationStore } from '~/stores/book-recommendations'
import type { FormSubmitEvent } from '#ui/types'
const props = defineProps<{ const props = defineProps<{
row: { row: {
@@ -9,16 +10,39 @@ const props = defineProps<{
description: string description: string
isbn: string isbn: string
pages: number pages: number
cover_image?: string cover_image?: string | File
status: string status: string
published_at: string
recommender: {
ulid: string
}
} }
}>() }>()
const dayjs = useDayjs()
const authStore = useAuthStore()
const { $storage } = useNuxtApp()
const isOpen = ref(false) const isOpen = ref(false)
const loading = ref(false)
const form = ref() 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, book_name: props.row.book_name,
author: props.row.author, author: props.row.author,
description: props.row.description, description: props.row.description,
@@ -26,35 +50,83 @@ const state = reactive({
pages: props.row.pages, pages: props.row.pages,
cover_image: props.row.cover_image, cover_image: props.row.cover_image,
status: props.row.status, 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 bookRecommendationStore = useBookRecommendationStore()
const { refresh: onSubmit, status } = useFetch<any>(`book-recommendations/${props.row.id}`, { async function onSubmit(event: FormSubmitEvent<any>) {
method: 'PUT', form.value.clear()
body: state, loading.value = true
immediate: false, const formData = new FormData()
watch: false, for (const key in state) {
async onResponse({ response }) { const item = state[key]
if (response?.status === 422) { if (item === undefined) {
form.value.setErrors(response._data?.errors) continue
} }
else if (response.ok) { if (key === 'cover_image' && state[key] instanceof File) {
useToast().add({ formData.append(key, item as Blob, (state[key] as File).name)
icon: 'i-heroicons-check-circle-20-solid',
title: 'Buchempfehlung wurde erfolgreich aktualisiert.',
color: 'emerald',
})
await bookRecommendationStore.fetchRecommendations()
isOpen.value = false
} }
}, 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: 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.',
color: 'emerald',
})
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> </script>
<template> <template>
<div> <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"> <UModal v-model="isOpen">
<UCard :ui="{ ring: '', divide: 'divide-y divide-gray-100 dark:divide-gray-800' }"> <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> </div>
</template> </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"> <UFormGroup label="Name" name="book_name">
<UInput v-model="state.book_name" /> <UInput v-model="state.book_name" />
</UFormGroup> </UFormGroup>
<UFormGroup label="Autor" name="author"> <UFormGroup label="Autor" name="author">
<UInput v-model="state.author" /> <UInput v-model="state.author" />
</UFormGroup> </UFormGroup>
<UFormGroup label="Erstveröffentlichung">
<UInput v-model="state.published_at" type="date" />
</UFormGroup>
<UFormGroup label="Beschreibung" name="description"> <UFormGroup label="Beschreibung" name="description">
<UTextarea v-model="state.description" /> <UTextarea v-model="state.description" />
</UFormGroup> </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"> <UFormGroup label="ISBN" name="isbn">
<UInput v-model="state.isbn" /> <UInput v-model="state.isbn" />
</UFormGroup> </UFormGroup>
@@ -86,7 +168,7 @@ const { refresh: onSubmit, status } = useFetch<any>(`book-recommendations/${prop
<UFormGroup label="Status" name="status"> <UFormGroup label="Status" name="status">
<USelect v-model="state.status" :options="bookRecommendationStore.statusOptions" option-attribute="name" /> <USelect v-model="state.status" :options="bookRecommendationStore.statusOptions" option-attribute="name" />
</UFormGroup> </UFormGroup>
<UButton size="md" type="submit" :loading="status === 'pending'"> <UButton size="md" type="submit">
Speichern Speichern
</UButton> </UButton>
<UButton size="md" class="mx-4" color="white" label="Abbrechen" @click="isOpen = false" /> <UButton size="md" class="mx-4" color="white" label="Abbrechen" @click="isOpen = false" />

View File

@@ -2,50 +2,102 @@
import { useBookRecommendationStore } from '~/stores/book-recommendations' import { useBookRecommendationStore } from '~/stores/book-recommendations'
const isOpen = ref(false) const isOpen = ref(false)
const loading = ref(false)
const dayjs = useDayjs()
const form = ref() const form = ref()
const state = reactive({ interface State {
book_name: null, book_name: string
author: null, author: string
description: null, description: string
isbn: null, isbn: string
pages: null, pages: number
cover_image: null, cover_image?: File | string
status: null, 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 bookRecommendationStore = useBookRecommendationStore()
const { refresh: onSubmit, status } = useFetch<any>(`book-recommendations`, { function handleCoverImageInput(event: Event) {
method: 'POST', const file = (event.target as HTMLInputElement).files?.[0]
body: state, if (file) {
immediate: false, // Update the state with the selected file
watch: false, state.cover_image = file
async onResponse({ response }) { }
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.',
color: 'emerald',
})
await bookRecommendationStore.fetchRecommendations()
state.book_name = null async function onSubmit() {
state.author = null loading.value = true
state.description = null const formData = new FormData()
state.isbn = null for (const key in state) {
state.pages = null const item = state[key]
state.cover_image = null if (item === undefined) {
state.status = null continue
isOpen.value = false
} }
}, 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: 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 angelegt.',
color: 'emerald',
})
await bookRecommendationStore.fetchRecommendations()
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> </script>
<template> <template>
@@ -72,9 +124,18 @@ const { refresh: onSubmit, status } = useFetch<any>(`book-recommendations`, {
<UFormGroup label="Autor" name="author"> <UFormGroup label="Autor" name="author">
<UInput v-model="state.author" /> <UInput v-model="state.author" />
</UFormGroup> </UFormGroup>
<UFormGroup label="Erstveröffentlichung">
<UInput v-model="state.published_at" type="date" />
</UFormGroup>
<UFormGroup label="Beschreibung" name="description"> <UFormGroup label="Beschreibung" name="description">
<UTextarea v-model="state.description" /> <UTextarea v-model="state.description" />
</UFormGroup> </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"> <UFormGroup label="ISBN" name="isbn">
<UInput v-model="state.isbn" /> <UInput v-model="state.isbn" />
</UFormGroup> </UFormGroup>

View 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>

View File

@@ -4,7 +4,9 @@
<UPage> <UPage>
<template #left> <template #left>
<UAside class="lg:static"> <UAside class="lg:static">
<Navigation /> <UContainer>
<Navigation />
</UContainer>
</UAside> </UAside>
</template> </template>
<UPageBody> <UPageBody>

View File

@@ -2,13 +2,17 @@ export default defineNuxtRouteMiddleware((to, from) => {
const nuxtApp = useNuxtApp() const nuxtApp = useNuxtApp()
const auth = useAuthStore() const auth = useAuthStore()
if (process.server) {
return
}
if (auth.isLoggedIn && !auth.user.roles.includes('admin')) { if (auth.isLoggedIn && !auth.user.roles.includes('admin')) {
return nuxtApp.runWithContext(() => { return nuxtApp.runWithContext(() => {
useToast().add({ useToast().add({
icon: "i-heroicons-exclamation-circle-solid", icon: 'i-heroicons-exclamation-circle-solid',
title: "Access denied.", title: 'Access denied.',
color: "red", color: 'red',
}); })
return navigateTo('/') return navigateTo('/')
}) })

View File

@@ -2,13 +2,17 @@ export default defineNuxtRouteMiddleware((to, from) => {
const nuxtApp = useNuxtApp() const nuxtApp = useNuxtApp()
const auth = useAuthStore() const auth = useAuthStore()
if (process.server) {
return
}
if (auth.isLoggedIn && !auth.user.roles.includes('user')) { if (auth.isLoggedIn && !auth.user.roles.includes('user')) {
return nuxtApp.runWithContext(() => { return nuxtApp.runWithContext(() => {
useToast().add({ useToast().add({
icon: "i-heroicons-exclamation-circle-solid", icon: 'i-heroicons-exclamation-circle-solid',
title: "Access denied.", title: 'Access denied.',
color: "red", color: 'red',
}); })
return navigateTo('/') return navigateTo('/')
}) })

View 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
View 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>

View 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>

View File

@@ -1,5 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import BookRecommendationTable from '~/components/book-recommendations/BookRecommendationTable.vue' import BookRecommendationTable from '~/components/book-recommendations/BookRecommendationTable.vue'
definePageMeta({ middleware: ['auth'] })
</script> </script>
<template> <template>

View File

@@ -1,13 +1,31 @@
<script setup lang="ts"> <script setup lang="ts">
import BookInfoCard from '~/components/dashboard/BookInfoCard.vue'
definePageMeta({ middleware: ['auth'] }) definePageMeta({ middleware: ['auth'] })
const modal = useModal(); const bookRecommendationStore = useBookRecommendationStore()
const router = useRouter(); const authStore = useAuthStore()
const auth = 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> </script>
<template> <template>
<div> <div v-if="bookRecommendationStore.fetchActiveRecommendationsStatus !== 'pending'">
This is the Page Content <BookInfoCard v-for="book in bookRecommendationStore.recommendations" :key="book.id" :book="book" />
</div> </div>
</template> </template>

View File

@@ -1,58 +1,60 @@
<script setup lang="ts"> <script setup lang="ts">
definePageMeta({ middleware: ['guest'], layout: 'auth' }) definePageMeta({ middleware: ['guest'], layout: 'auth' })
const config = useRuntimeConfig(); const config = useRuntimeConfig()
const router = useRouter(); const router = useRouter()
const auth = useAuthStore(); const auth = useAuthStore()
const form = ref(); const form = ref()
type Provider = { interface Provider {
name: string; name: string
icon: string; icon: string
color: string; color: string
loading?: boolean; loading?: boolean
}; }
const state = reactive({ const state = reactive({
email: "", email: '',
password: "", password: '',
remember: false, remember: false,
}); })
const { refresh: onSubmit, status: loginStatus } = useFetch<any>("login", { const { refresh: onSubmit, status: loginStatus } = useFetch<any>('login', {
method: "POST", method: 'POST',
body: state, body: state,
immediate: false, immediate: false,
watch: false, watch: false,
async onResponse({ response }) { async onResponse({ response }) {
if (response?.status === 422) { if (response?.status === 422) {
form.value.setErrors(response._data?.errors); form.value.setErrors(response._data?.errors)
} else if (response._data?.ok) {
auth.token = response._data.token;
await auth.fetchUser();
await router.push("/");
} }
} 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> { 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) { if (Object.keys(providers.value).includes(provider) && event.data.token) {
providers.value[provider].loading = false; providers.value[provider].loading = false
auth.token = event.data.token; auth.token = event.data.token
await auth.fetchUser(); await auth.fetchUser()
await router.push("/"); await router.push('/')
} else if (event.data.message) { }
else if (event.data.message) {
useToast().add({ useToast().add({
icon: "i-heroicons-exclamation-circle-solid", icon: 'i-heroicons-exclamation-circle-solid',
color: "red", color: 'red',
title: event.data.message, title: event.data.message,
}); })
} }
} }
</script> </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"> <div class="mx-auto flex min-h-screen w-full items-center justify-center">
<UCard class="w-96"> <UCard class="w-96">
<template #header> <template #header>
<h1 class="text-center text-2xl font-bold"> <div class="text-center text-4xl font-bold">
Login <Logo />
</h1> </div>
</template> </template>
<UForm ref="form" :state="state" class="space-y-4" @submit="onSubmit"> <UForm ref="form" :state="state" class="space-y-4" @submit="onSubmit">

View File

@@ -1,5 +1,5 @@
import { ofetch } from 'ofetch' import { ofetch } from 'ofetch'
import type { FetchOptions } from 'ofetch'; import type { FetchOptions } from 'ofetch'
export default defineNuxtPlugin({ export default defineNuxtPlugin({
name: 'app', name: 'app',
@@ -10,10 +10,10 @@ export default defineNuxtPlugin({
const auth = useAuthStore() const auth = useAuthStore()
nuxtApp.provide('storage', (path: string): string => { nuxtApp.provide('storage', (path: string): string => {
if (!path) return '' if (!path) { return '' }
return path.startsWith('http://') || path.startsWith('https://') ? return path.startsWith('http://') || path.startsWith('https://')
path ? path
: config.public.storageBase + path : config.public.storageBase + path
}) })
@@ -21,45 +21,53 @@ export default defineNuxtPlugin({
// Initial headers with Accept // Initial headers with Accept
const initialHeaders = { const initialHeaders = {
...headers, ...headers,
'Accept': 'application/json', Accept: 'application/json',
}; }
// Conditionally add server-specific headers // Conditionally add server-specific headers
if (process.server) { if (process.server) {
const serverHeaders = { const serverHeaders = {
'referer': useRequestURL().toString(), referer: useRequestURL().toString(),
...useRequestHeaders(['x-forwarded-for', 'user-agent', 'referer']), ...useRequestHeaders(['x-forwarded-for', 'user-agent', 'referer']),
}; }
Object.assign(initialHeaders, serverHeaders); Object.assign(initialHeaders, serverHeaders)
} }
// Conditionally add authorization header if logged in // Conditionally add authorization header if logged in
if (auth.isLoggedIn) { if (auth.isLoggedIn) {
const authHeaders = { const authHeaders = {
'Authorization': `Bearer ${auth.token}`, Authorization: `Bearer ${auth.token}`,
}; }
Object.assign(initialHeaders, authHeaders); 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 { function buildBaseURL(baseURL: string): string {
if (baseURL) return baseURL; if (baseURL) { return baseURL }
return process.server ? return process.server
config.apiLocal + config.public.apiPrefix ? config.apiLocal + config.public.apiPrefix
: config.public.apiBase + config.public.apiPrefix; : config.public.apiBase + config.public.apiPrefix
} }
function buildSecureMethod(options: FetchOptions): void { function buildSecureMethod(options: FetchOptions): void {
if (process.server) return; if (process.server) { return }
const method = options.method?.toLowerCase() ?? 'get' const method = options.method?.toLowerCase() ?? 'get'
if (options.body instanceof FormData && method === 'put') { if (options.body instanceof FormData && method === 'put') {
options.method = 'POST'; options.method = 'POST'
options.body.append('_method', 'PUT'); options.body.append('_method', 'PUT')
} }
} }
@@ -67,27 +75,27 @@ export default defineNuxtPlugin({
return !baseURL return !baseURL
&& !path.startsWith('/_nuxt') && !path.startsWith('/_nuxt')
&& !path.startsWith('http://') && !path.startsWith('http://')
&& !path.startsWith('https://'); && !path.startsWith('https://')
} }
globalThis.$fetch = ofetch.create({ globalThis.$fetch = ofetch.create({
retry: false, retry: false,
onRequest({ request, options }) { 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.baseURL = buildBaseURL(options.baseURL ?? '')
options.headers = buildHeaders(options.headers); options.headers = buildHeaders(options.headers)
buildSecureMethod(options); buildSecureMethod(options)
}, },
onRequestError({ error }) { onRequestError({ error }) {
if (process.server) return; if (process.server) { return }
if (error.name === 'AbortError') return; if (error.name === 'AbortError') { return }
useToast().add({ useToast().add({
icon: 'i-heroicons-exclamation-circle-solid', icon: 'i-heroicons-exclamation-circle-solid',
@@ -110,7 +118,8 @@ export default defineNuxtPlugin({
color: 'primary', color: 'primary',
}) })
} }
} else if (response.status !== 422) { }
else if (response.status !== 422) {
if (process.client) { if (process.client) {
useToast().add({ useToast().add({
icon: 'i-heroicons-exclamation-circle-solid', icon: 'i-heroicons-exclamation-circle-solid',
@@ -119,11 +128,11 @@ export default defineNuxtPlugin({
}) })
} }
} }
} },
} as FetchOptions) } as FetchOptions)
if (auth.isLoggedIn) { if (auth.isLoggedIn) {
await auth.fetchUser(); await auth.fetchUser()
} }
}, },
}) })

View 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
View 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,
},
],
})
}
},
})

View File

@@ -24,6 +24,7 @@ export const useAuthStore = defineStore('auth', () => {
maxAge: 60 * 60 * 24 * 365, maxAge: 60 * 60 * 24 * 365,
}) })
const isLoggedIn = computed(() => !!token.value) const isLoggedIn = computed(() => !!token.value)
const socketId = ref('')
const { refresh: logout } = useFetch<any>('logout', { const { refresh: logout } = useFetch<any>('logout', {
method: 'POST', method: 'POST',
@@ -49,5 +50,5 @@ export const useAuthStore = defineStore('auth', () => {
}, },
}) })
return { user, isLoggedIn, logout, fetchUser, token } return { user, isLoggedIn, socketId, logout, fetchUser, token }
}) })

View File

@@ -1,6 +1,6 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
enum BookRecommendationStatusEnum { export enum BookRecommendationStatusEnum {
PENDING = 'PENDING', PENDING = 'PENDING',
REJECTED = 'REJECTED', REJECTED = 'REJECTED',
ACTIVE = 'ACTIVE', ACTIVE = 'ACTIVE',
@@ -21,8 +21,18 @@ export interface BookRecommendation {
email: string email: string
avatar: string avatar: string
} }
votes?: Vote[]
status: BookRecommendationStatusEnum status: BookRecommendationStatusEnum
cover_image?: string 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', () => { export const useBookRecommendationStore = defineStore('bookRecommendations', () => {
@@ -30,20 +40,25 @@ export const useBookRecommendationStore = defineStore('bookRecommendations', ()
const statusOptions = [ const statusOptions = [
{ {
name: 'Pending', name: 'Ausstehend',
value: BookRecommendationStatusEnum.PENDING, value: BookRecommendationStatusEnum.PENDING,
color: 'orange',
}, },
{ {
name: 'Rejected', name: 'Abgelehnt',
value: BookRecommendationStatusEnum.REJECTED, value: BookRecommendationStatusEnum.REJECTED,
color: 'red',
}, },
{ {
name: 'Active', name: 'Aktiv',
value: BookRecommendationStatusEnum.ACTIVE, value: BookRecommendationStatusEnum.ACTIVE,
color: 'green',
}, },
{ {
name: 'Completed', name: 'Abgeschlossen',
value: BookRecommendationStatusEnum.COMPLETED, value: BookRecommendationStatusEnum.COMPLETED,
color: 'primary',
}, },
] ]
@@ -57,29 +72,87 @@ export const useBookRecommendationStore = defineStore('bookRecommendations', ()
}, },
}) })
const deleteRecommendation = async (id: number) => { const { refresh: fetchActiveRecommendations, status: fetchActiveRecommendationsStatus } = useFetch<BookRecommendation[]>('book-recommendations?with=recommender,votes&status=ACTIVE', {
try { immediate: false,
const { error } = await useFetch(`book-recommendations/${id}`, { onResponse({ response }) {
method: 'DELETE', if (response.status === 200) {
}) recommendations.value = response._data
}
},
})
if (error.value) { const updateRecommendationWS = async (data: Partial<BookRecommendation>) => {
console.error('Failed to delete book recommendation:', error.value) // 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.
else { // If the Status is not ACTIVE, we need to remove the recommendation from the list.
recommendations.value = recommendations.value.filter(rec => rec.id !== id) const index = recommendations.value.findIndex(r => r.id === data.id)
} if (index !== -1) {
recommendations.value[index] = { ...recommendations.value[index], ...data }
} }
catch (e) { switch (data.status) {
console.error('An error occurred while deleting a book recommendation:', e) 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
} }
} }
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 { return {
recommendations, recommendations,
resetRecommendations,
updateRecommendationWS,
deleteRecommendationWS,
createRecommendationWS,
createVoteWS,
statusOptions, statusOptions,
fetchRecommendations, fetchRecommendations,
fetchRecommendationsStatus, fetchRecommendationsStatus,
deleteRecommendation, fetchActiveRecommendations,
fetchActiveRecommendationsStatus,
} }
}) })
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useBookRecommendationStore, import.meta.hot))
}

View File

@@ -1,12 +1,13 @@
{ {
"name": "laravel-nuxt", "name": "laravel-nuxt",
"version": "0.0.4",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"serve": "cross-env PORT=3000 HOST=127.0.0.1 node .output/server/index.mjs", "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", "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", "generate": "nuxt generate",
"preview": "nuxt preview", "preview": "nuxt preview",
"postinstall": "nuxt prepare", "postinstall": "nuxt prepare",
@@ -32,5 +33,10 @@
}, },
"resolutions": { "resolutions": {
"vue": "3.4.21" "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
View File

@@ -7,6 +7,17 @@ settings:
overrides: overrides:
vue: 3.4.21 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: devDependencies:
'@antfu/eslint-config': '@antfu/eslint-config':
specifier: ^2.8.3 specifier: ^2.8.3
@@ -5910,6 +5921,11 @@ packages:
resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==} resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==}
dev: true dev: true
/laravel-echo@1.16.0:
resolution: {integrity: sha512-BJGUa4tcKvYmTkzTmcBGMHiO2tq+k7Do5wPmLbRswWfzKwyfZEUR+J5iwBTPEfLLwNPZlA9Kjo6R/NV6pmyIpg==}
engines: {node: '>=10'}
dev: false
/launch-editor@2.6.1: /launch-editor@2.6.1:
resolution: {integrity: sha512-eB/uXmFVpY4zezmGp5XtU21kwo7GBbKB+EQ+UZeWtGb9yAM5xt/Evk+lYH3eRNAtId+ej4u7TYPFZ07w4s7rRw==} resolution: {integrity: sha512-eB/uXmFVpY4zezmGp5XtU21kwo7GBbKB+EQ+UZeWtGb9yAM5xt/Evk+lYH3eRNAtId+ej4u7TYPFZ07w4s7rRw==}
dependencies: dependencies:
@@ -7163,6 +7179,10 @@ packages:
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
dev: true dev: true
/party-js@2.2.0:
resolution: {integrity: sha512-50hGuALCpvDTrQLPQ1fgUgxKIWAH28ShVkmeK/3zhO0YJyCqkhrZhQEkWPxDYLvbFJ7YAXyROmFEu35gKpZLtQ==}
dev: false
/path-exists@4.0.0: /path-exists@4.0.0:
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -7762,6 +7782,12 @@ packages:
engines: {node: '>=6'} engines: {node: '>=6'}
dev: true 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: /queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
dev: true dev: true
@@ -8754,6 +8780,10 @@ packages:
dev: true dev: true
optional: true optional: true
/tweetnacl@1.0.3:
resolution: {integrity: sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==}
dev: false
/type-check@0.4.0: /type-check@0.4.0:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}

14
resources/js/echo.js Normal file
View 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'],
});

View File

@@ -3,6 +3,7 @@
use App\Http\Controllers\AccountController; use App\Http\Controllers\AccountController;
use App\Http\Controllers\AuthController; use App\Http\Controllers\AuthController;
use App\Http\Controllers\BookRecommendationController; use App\Http\Controllers\BookRecommendationController;
use App\Http\Controllers\DeadlineController;
use App\Http\Controllers\UploadController; use App\Http\Controllers\UploadController;
use App\Http\Controllers\VoteController; use App\Http\Controllers\VoteController;
use Illuminate\Support\Facades\Route; 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::post('devices/disconnect', [AuthController::class, 'deviceDisconnect'])->name('devices.disconnect');
Route::get('devices', [AuthController::class, 'devices'])->name('devices'); Route::get('devices', [AuthController::class, 'devices'])->name('devices');
Route::get('user', [AuthController::class, 'user'])->name('user'); 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::resource('book-recommendations', BookRecommendationController::class);
Route::get('/book-recommendations/{bookRecommendationId}/deadlines', [DeadlineController::class, 'deadlinesByBookRecommendation']);
Route::post('/vote', [VoteController::class, 'castVote']); 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/update', [AccountController::class, 'update'])->name('account.update');
Route::post('account/password', [AccountController::class, 'password'])->name('account.password'); 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::middleware(['throttle:uploads'])->group(function () {
Route::post('upload', [UploadController::class, 'image'])->name('upload.image'); Route::post('upload', [UploadController::class, 'image'])->name('upload.image');
}); });
}); });
}); });

19
routes/channels.php Normal file
View 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
View 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],
},
},
},
}