Compare commits

...

21 Commits

Author SHA1 Message Date
Flycro 8c595aa3c6 fix: remove unused variables 2024-03-20 18:16:47 +01:00
Flycro b001ea937f feat: Admin Page to Manage Vote Actions 2024-03-20 18:14:29 +01:00
Flycro fc63da0a3a feat: Theme Color from Nuxt UI Docs 2024-03-20 18:13:59 +01:00
Flycro 48a87c9b7b feat: reenabled nuxt security 2024-03-20 18:13:42 +01:00
Flycro 837441ecb8 feat: book-recommendations page 2024-03-20 18:13:17 +01:00
Flycro bd8225374a feat: Admin Page 2024-03-20 18:13:06 +01:00
Flycro 726c86dbcd chore: Current Information to DatabaseSeeder 2024-03-20 18:12:42 +01:00
Flycro e393ba5943 feat: Vote Functionality 2024-03-20 18:12:23 +01:00
Flycro 1d7f41e812 feat: Add Config + Set Default Font to DM Sans 2024-03-20 18:12:02 +01:00
Flycro 2953acd630 feat: Command to fetch Cover Art from OpenLib 2024-03-20 18:11:41 +01:00
Flycro 74d6f3efaa refactor: Navigation 2024-03-20 18:11:20 +01:00
Flycro 95ece0f614 feat: Add UserDeadline Relation 2024-03-20 18:10:53 +01:00
Flycro fffe1b4717 feat: BR Overview + Modal for New Recommendations 2024-03-20 18:10:02 +01:00
Flycro 923e41b396 feat: Dashboard Functionality 2024-03-20 18:09:26 +01:00
Flycro cbc54210f0 feat: Modal to Delete BookRecommendations 2024-03-20 18:08:31 +01:00
Flycro 1a836b71f0 feat: Modal to Edit BookRecommendations 2024-03-20 18:08:23 +01:00
Flycro 3857ff70d2 feat: UserDeadline Functionality 2024-03-20 18:07:58 +01:00
Flycro b97f8c785f feat: Deadline Functionality 2024-03-20 18:07:46 +01:00
Flycro 5ab2c46335 feat: BookRecommendation Functionality 2024-03-20 18:07:30 +01:00
Flycro 096698a8e1 feat: Routes for various Systems 2024-03-20 18:06:46 +01:00
Flycro daa80f4ff3 feat: Migrations for various Systems 2024-03-20 18:06:32 +01:00
39 changed files with 1311 additions and 160 deletions

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

@ -14,10 +14,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 +56,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,6 +130,9 @@ 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);
} }

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

@ -17,6 +17,7 @@ class BookRecommendation extends Model
'recommended_by', 'recommended_by',
'cover_image', 'cover_image',
'status', 'status',
'published_at',
]; ];
/** /**
@ -31,4 +32,9 @@ class BookRecommendation extends Model
{ {
return $this->hasMany(Vote::class); return $this->hasMany(Vote::class);
} }
public function deadlines()
{
return $this->hasMany(Deadline::class);
}
} }

27
app/Models/Deadline.php Normal file
View File

@ -0,0 +1,27 @@
<?php
namespace App\Models;
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',
];
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

@ -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-44273564-8',
'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

@ -30,11 +30,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: {

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,6 +11,7 @@ const links = [{
</template> </template>
<template #right> <template #right>
<ColorPicker />
<UColorModeButton /> <UColorModeButton />
<UserDropdown /> <UserDropdown />
</template> </template>

View File

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

View File

@ -1,5 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
const links = [ import type { NavigationTree } from '#ui-pro/types'
const authStore = useAuthStore()
const links: NavigationTree[] = [
{
label: 'Dashboard',
to: '/',
icon: 'i-heroicons-home',
},
{ {
label: 'Bücher', label: 'Bücher',
icon: 'i-heroicons-book-open', icon: 'i-heroicons-book-open',
@ -11,12 +20,27 @@ const links = [
}, },
], ],
}, },
{
label: 'People',
to: '/login',
icon: 'i-heroicons-user-group',
},
] ]
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-star',
},
],
})
}
</script> </script>
<template> <template>

View File

@ -1,15 +1,23 @@
<script setup lang="ts"> <script setup lang="ts">
const links = [ const authStore = useAuthStore()
{
const links = [{
label: 'Account', label: 'Account',
to: '/account', to: '/account',
icon: 'i-heroicons-user-solid', 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-20-solid',
title: 'Es wurden allen Nutzern 2 Votes hinzugefügt.',
color: 'emerald',
})
}
isOpen.value = false
},
})
</script>
<template>
<UButton icon="i-heroicons-star" 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-star"
: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

@ -19,6 +19,10 @@ const columns = [
key: 'author', key: 'author',
label: 'Autor', label: 'Autor',
}, },
{
key: 'published_at',
label: 'Erstveröffentlichung',
},
{ {
key: 'description', key: 'description',
label: 'Beschreibung', label: 'Beschreibung',
@ -42,27 +46,46 @@ const columns = [
{ {
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)
}
</script> </script>
<template> <template>
<div> <div>
<NewBookRecommendation /> <NewBookRecommendation />
<UTable :loading="bookRecommendationStore.fetchRecommendationsStatus === 'pending'" :columns="columns" :rows="bookRecommendationStore.recommendations"> <UTable :sort="sort" :loading="bookRecommendationStore.fetchRecommendationsStatus === 'pending'" :columns="columns" :rows="bookRecommendationStore.recommendations">
<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 #published_at-data="{ row }">
<div>{{ dayjs(row.published_at).format('DD.MM.YYYY') }}</div>
</template>
<template #description-data="{ row }"> <template #description-data="{ row }">
<div v-if="row.description">
{{ `${row.description.substring(0, 50)}...` }} {{ `${row.description.substring(0, 50)}...` }}
</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 space-x-2">
<CastVote :row="row" /> <CastVote :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-swatch-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,89 @@
<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-4/5 space-y-4">
<div class="space-y-2">
<h1 class="font-sans text-3xl font-bold">
{{ props.book.book_name }}
</h1>
<div class="flex justify-between">
<div>
<UBadge>
{{ props.book.author }}
</UBadge>
</div>
<div class="flex">
<NewDeadline v-if="auth.user?.roles.includes('admin')" :book-recommendation-id="props.book.id" />
</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,58 @@
<script setup lang="ts">
import ConfirmUserDeadline from '~/components/modal/ConfirmUserDeadline.vue'
const props = defineProps<{
bookRecommendationId: number
}>()
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,
}
})
})
</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`, {
@ -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-star" 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-star"
: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,45 @@
<script setup lang="ts">
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
}
},
})
</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="onConfirmDeadline" />
<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,16 +50,55 @@ 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>) {
form.value.clear()
loading.value = true
const formData = new FormData()
for (const key in state) {
const item = state[key]
if (item === undefined) {
continue
}
if (key === 'cover_image' && state[key] instanceof File) {
formData.append(key, item as Blob, (state[key] as File).name)
}
else {
const value = typeof item === 'string' ? item : String(item)
if (key === 'cover_image') {
continue
}
formData.append(key, value)
}
}
const response = await $fetch<any>(`book-recommendations/${props.row.id}`, {
method: 'PUT', method: 'PUT',
body: state, body: formData,
immediate: false,
watch: false,
async onResponse({ response }) { async onResponse({ response }) {
loading.value = false
if (response?.status === 422) { if (response?.status === 422) {
form.value.setErrors(response._data?.errors) form.value.setErrors(response._data?.errors)
} }
@ -45,16 +108,17 @@ const { refresh: onSubmit, status } = useFetch<any>(`book-recommendations/${prop
title: 'Buchempfehlung wurde erfolgreich aktualisiert.', title: 'Buchempfehlung wurde erfolgreich aktualisiert.',
color: 'emerald', color: 'emerald',
}) })
await bookRecommendationStore.fetchRecommendations()
isOpen.value = false isOpen.value = false
} }
}, },
}) })
await bookRecommendationStore.fetchRecommendations()
}
</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 +131,23 @@ 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" :src="$storage(state.cover_image)" alt="Cover" class="size-1/3 content-center rounded-lg">
<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 +157,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,95 @@
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) {
const file = (event.target as HTMLInputElement).files?.[0]
if (file) {
// Update the state with the selected file
state.cover_image = file
}
}
async function onSubmit() {
loading.value = true
const formData = new FormData()
for (const key in state) {
const item = state[key]
if (item === undefined) {
continue
}
if (key === 'cover_image' && state[key] instanceof File) {
formData.append(key, item as Blob, (state[key] as File).name)
}
else {
if (key === 'cover_image') {
continue
}
const value = typeof item === 'string' ? item : String(item)
formData.append(key, value)
}
}
await $fetch<any>(`book-recommendations`, {
method: 'POST', method: 'POST',
body: state, body: formData,
immediate: false,
watch: false,
async onResponse({ response }) { async onResponse({ response }) {
loading.value = false
if (response?.status === 422) { if (response?.status === 422) {
form.value.setErrors(response._data?.errors) form.value.setErrors(response._data?.errors)
} }
else if (response.ok) { else if (response.ok) {
useToast().add({ useToast().add({
icon: 'i-heroicons-check-circle-20-solid', icon: 'i-heroicons-check-circle-20-solid',
title: 'Buchempfehlung wurde erfolgreich aktualisiert.', title: 'Buchempfehlung wurde erfolgreich angelegt.',
color: 'emerald', color: 'emerald',
}) })
await bookRecommendationStore.fetchRecommendations() await bookRecommendationStore.fetchRecommendations()
state.book_name = null state.book_name = ''
state.author = null state.author = ''
state.description = null state.description = ''
state.isbn = null state.isbn = ''
state.pages = null state.pages = 0
state.cover_image = null state.cover_image = ''
state.status = null state.status = 'PENDING'
state.published_at = dayjs().format('YYYY-MM-DD')
isOpen.value = false isOpen.value = false
} }
}, },
}) })
}
</script> </script>
<template> <template>
@ -72,9 +117,15 @@ 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>
<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

@ -0,0 +1,13 @@
<script setup lang="ts">
definePageMeta({ middleware: ['role-admin'] })
</script>
<template>
<div>
Admin
</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-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,15 @@
<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 auth = useAuthStore(); bookRecommendationStore.fetchActiveRecommendations()
</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>

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

@ -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',
@ -23,6 +23,7 @@ export interface BookRecommendation {
} }
status: BookRecommendationStatusEnum status: BookRecommendationStatusEnum
cover_image?: string cover_image?: string
published_at?: string
} }
export const useBookRecommendationStore = defineStore('bookRecommendations', () => { export const useBookRecommendationStore = defineStore('bookRecommendations', () => {
@ -30,20 +31,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 +63,30 @@ 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) { function resetRecommendations() {
console.error('Failed to delete book recommendation:', error.value) recommendations.value = []
}
else {
recommendations.value = recommendations.value.filter(rec => rec.id !== id)
}
}
catch (e) {
console.error('An error occurred while deleting a book recommendation:', e)
}
} }
return { return {
recommendations, recommendations,
resetRecommendations,
statusOptions, statusOptions,
fetchRecommendations, fetchRecommendations,
fetchRecommendationsStatus, fetchRecommendationsStatus,
deleteRecommendation, fetchActiveRecommendations,
fetchActiveRecommendationsStatus,
} }
}) })
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useBookRecommendationStore, import.meta.hot))
}

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;
@ -28,8 +29,16 @@ Route::prefix('api/v1')->group(function () {
Route::get('user', [AuthController::class, 'user'])->name('user'); Route::get('user', [AuthController::class, 'user'])->name('user');
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');
@ -37,4 +46,3 @@ Route::prefix('api/v1')->group(function () {
Route::post('upload', [UploadController::class, 'image'])->name('upload.image'); Route::post('upload', [UploadController::class, 'image'])->name('upload.image');
}); });
}); });
});

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