Compare commits

...

10 Commits

Author SHA1 Message Date
ffee3b257d
- add error message for request 2023-08-05 01:28:17 +02:00
957e90aff6
- add empty states 2023-08-05 00:08:47 +02:00
252f5ee6b7
- add delete for messages 2023-08-04 16:45:15 +02:00
2a2869e2c6
- add send CV status 2023-08-04 16:25:09 +02:00
6a033d108c
- wip 2023-08-03 22:37:57 +02:00
620217e1d5
- prepare messages 2023-08-03 18:32:27 +02:00
a7e93681f3
- wip 2023-08-03 12:26:42 +02:00
9d7da548e3
- fix notes 2023-08-01 23:57:09 +02:00
2349008131
- add notes 2023-08-01 23:54:03 +02:00
908b1e4bec
- wip 2023-08-01 18:13:45 +02:00
32 changed files with 686 additions and 74 deletions

View File

@ -14,3 +14,6 @@ DB_PASSWORD=password
EXTERNAL_WEBSERVER_PORT=80
CURRENT_UID=1000
XDG_CONFIG_HOME=/tmp
VITE_CV_APP_URL=https://cv.kamilcraft.com
VITE_PORT=3001

View File

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\MessageRequest;
use App\Models\Message;
use Illuminate\Http\JsonResponse;
class MessageController extends Controller
{
public function store(MessageRequest $request): JsonResponse
{
$data = $request->toArray();
Message::query()->create([
'message' => $data['message'],
'email' => $data['email'],
'sender' => $data['sender'],
]);
return response()->json([
'message' => 'Dziękuję za wiadomość! Odpowiem możliwie najszybciej.'
]);
}
}

View File

@ -49,12 +49,24 @@ class CVController extends Controller
'mission' => ($mission = $request->get('mission')) === [''] ? [] : $mission,
'rodo' => ($rodo =$request->get('rodo')) === '' ? null : $rodo,
'position' => $request->get('position'),
'notes' => $request->get('notes'),
]);
return redirect()
->route('admin.cv.store')
->with('success', 'Utworzono nowe CV dla firmy ' . $request->get('recipient'));
}
public function updateSendStatus(CV $cv): RedirectResponse
{
$cv->update([
'sended' => true,
'sended_timestamp' => now()
]);
return redirect()
->route('admin.cv.show', ['cv' => $cv])
->with('success', 'Status wysłania ustawiono jako "wysłano".');
}
public function edit(CV $cv): InertiaResponse
{
return inertia('CV/Edit', [
@ -64,15 +76,30 @@ class CVController extends Controller
public function update(CVRequest $request, CV $cv): RedirectResponse
{
$cv->update([
'recipient' => $request->get('recipient'),
'email' => $request->get('email'),
'phone_number' => $request->get('phone_number'),
'locations' => ($locations = $request->get('locations')) === [''] ? [] : $locations,
'mission' => ($mission = $request->get('mission')) === [''] ? [] : $mission,
'rodo' => ($rodo =$request->get('rodo')) === '' ? null : $rodo,
'position' => $request->get('position'),
$toUpdate = [
'recipient' => $request->get('recipient'),
'email' => $request->get('email'),
'phone_number' => $request->get('phone_number'),
'locations' => ($locations = $request->get('locations')) === [''] ? [] : $locations,
'mission' => ($mission = $request->get('mission')) === [''] ? [] : $mission,
'rodo' => ($rodo =$request->get('rodo')) === '' ? null : $rodo,
'position' => $request->get('position'),
'notes' => $request->get('notes'),
];
if ($cv->sended && ! $request->boolean('sended')) {
$toUpdate = array_merge($toUpdate, [
'sended' => false,
'sended_timestamp' => null,
]);
} else if (! $cv->sended && $request->boolean('sended')) {
$toUpdate = array_merge($toUpdate, [
'sended' => true,
'sended_timestamp' => now(),
]);
}
$cv->update($toUpdate);
return redirect()
->back()
->with('success', 'Zaktualizowano CV dla firmy ' . $request->get('recipient'));

View File

@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Dashboard;
use App\Http\Controllers\Controller;
use App\Http\Resources\MessageCollection;
use App\Http\Resources\MessageResource;
use App\Models\Message;
use Illuminate\Http\RedirectResponse;
use Inertia\Response as InertiaResponse;
class MessageController extends Controller
{
public function index() : InertiaResponse {
return inertia('Messages/Index', [
'messages' => new MessageCollection(Message::query()->orderByDesc('id')->get()),
]);
}
public function show(Message $message) : InertiaResponse
{
return inertia('Messages/Show', [
'message' => new MessageResource($message),
]);
}
public function delete(Message $message) : InertiaResponse
{
return inertia('Messages/ConfirmDelete', [
'message' => new MessageResource($message),
]);
}
public function destroy(Message $message) : RedirectResponse
{
$sender = $message->sender;
$message->delete();
return redirect()
->route('admin.message.index')
->with(['success' => 'Wiadomość od '. $sender .' została usunięta']);
}
}

View File

@ -18,7 +18,6 @@ class Kernel extends HttpKernel
\App\Http\Middleware\TrimStrings::class,
\Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class,
Core::class,
\App\Http\Middleware\HandleInertiaRequests::class,
];
protected $middlewareGroups = [
@ -29,6 +28,7 @@ class Kernel extends HttpKernel
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
\Illuminate\Foundation\Http\Middleware\VerifyCsrfToken::class,
\Illuminate\Routing\Middleware\SubstituteBindings::class,
\App\Http\Middleware\HandleInertiaRequests::class,
],
'api' => [

View File

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Illuminate\Http\Middleware\TrustProxies as Middleware;

View File

@ -18,6 +18,20 @@ class CVRequest extends FormRequest
'mission' => 'nullable|string',
'rodo' => 'nullable|string',
'position' => 'nullable|string',
'notes' => 'nullable|string',
'sended' => 'nullable|boolean',
];
}
protected function prepareForValidation(): void
{
$this->merge([
'sended' => $this->toBoolean($this->sended),
]);
}
private function toBoolean($booleable): bool
{
return filter_var($booleable, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
}
}

View File

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class MessageRequest extends FormRequest
{
public function rules(): array
{
return [
'message' => 'required|string|min:3|max:500',
'sender' => 'required|string|min:3|max:50',
'email' => 'required|email|max:250',
];
}
public function messages(): array
{
return [
'message.required' => 'Pole wiadomości jest wymagane.',
'sender.required' => 'Pole nadawcy jest wymagane.',
'email.required' => 'Pole e-mail jest wymagane.',
'message.min' => 'Pole wiadomości wymaga 3 znaki.',
'sender.min' => 'Pole nadawcy wymaga 3 znaki.',
'message.max' => 'Pole wiadomości może mieć maksymalnie 50 znaków.',
'sender.max' => 'Pole nadawcy może mieć maksymalnie 500 znaków.',
'email.email' => 'Pole musi być e-mailem.',
'email.max' => 'Pole e-mail może mieć maksymalnie 250 znaków.',
];
}
}

View File

@ -23,7 +23,7 @@ class ProjectRequest extends FormRequest
'project_url' => 'nullable|string',
'project_version' => 'nullable|string',
'description' => 'nullable|string',
'description' => 'required|string|min:3',
'visible' => 'required|boolean'
];
}

View File

@ -21,9 +21,16 @@ class FullCVResource extends JsonResource
'locations' => $this->locations,
'views' => $this->resource->info()->select('id')->get()->count(),
'registeredViews' => $this->views,
'sended' => [
'status' => $this->sended,
'datetime' => $this->sended_timestamp?->format('d-m-Y H:i:s'),
],
'position' => $this->position,
'mission' => explode(PHP_EOL, $this->mission ?? '', 5),
'rodo' => $this->rodo,
'position' => $this->position,
'notes' => $this->notes,
'created' => $this->created_at->format('d-m-Y H:i:s'),
'updated' => $this->updated_at->format('d-m-Y H:i:s'),
];
}
}

View File

@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use Illuminate\Http\Resources\Json\ResourceCollection;
class MessageCollection extends ResourceCollection
{
public function toArray($request): array
{
return [
'data' => $this->collection,
];
}
}

View File

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use Illuminate\Http\Resources\Json\JsonResource;
class MessageResource extends JsonResource
{
public static $wrap = null;
public function toArray($request): array
{
return [
'id' => $this->id,
'sender' => $this->sender,
'email' => $this->email,
'message' => $this->message,
];
}
}

View File

@ -19,7 +19,9 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
* @property string|null $mission
* @property string|null $rodo
* @property string|null $position
* @property string|null $notes
* @property int $views
* @property bool $sended
*/
class CV extends Model
{
@ -30,6 +32,8 @@ class CV extends Model
protected $casts = [
'locations' => 'array',
'views' => 'integer',
'sended' => 'boolean',
'sended_timestamp' => 'datetime',
];
protected function phoneNumber(): Attribute

23
app/Models/Message.php Normal file
View File

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* @param int $id
* @param string $message
* @param string $email
* @param string $sender
*/
class Message extends Model
{
use HasFactory,
SoftDeletes;
protected $guarded = [];
}

View File

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('cvs', function (Blueprint $table) {
$table->text('notes')->nullable();
});
}
public function down(): void
{
Schema::table('cvs', function (Blueprint $table) {
$table->dropColumn('notes');
});
}
};

View File

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('messages', function (Blueprint $table) {
$table->id();
$table->string('message', 500);
$table->string('email', 250);
$table->string('sender', 50);
$table->softDeletes();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('messages');
}
};

View File

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('cvs', function (Blueprint $table) {
$table->boolean('sended')->nullable()->default(false)->after('views');
$table->timestamp('sended_timestamp')->nullable()->after('sended');
});
}
public function down(): void
{
Schema::table('cvs', function (Blueprint $table) {
$table->dropColumn('sended');
$table->dropColumn('sended_timestamp');
});
}
};

View File

@ -39,7 +39,7 @@ services:
entrypoint: [ 'npm' ]
ports:
- '3000:3000'
- '3001:3001'
- '${VITE_PORT:-3001}:${VITE_PORT:-3001}'
volumes:
- .:/application
networks:

View File

@ -32,6 +32,7 @@ const form = useForm({
mission: missionToString,
rodo: null,
position: null,
notes: null,
});
function createCV() {
@ -85,15 +86,23 @@ function createCV() {
/>
<Input
id="position"
label="Stanowisko"
label="Stanowisko (opcjonalne)"
placeholder="Stanowisko na jakie jest rekrutacja."
v-model="form.position"
:error="form.errors.position"
/>
<Input
id="notes"
type="textarea"
label="Notatki (opcjonalne)"
placeholder="Notatka dla administratora"
v-model="form.notes"
:error="form.errors.notes"
/>
<Input
id="mission"
type="textarea"
label="Misja - wstęp"
label="Misja - wstęp (opcjonalne)"
placeholder="Krótki opis, list motywacyjny."
v-model="form.mission"
:error="form.errors.mission"
@ -101,7 +110,7 @@ function createCV() {
<Input
id="rodo"
type="textarea"
label="RODO"
label="RODO (opcjonalne)"
placeholder="Klauzula informacyjna RODO"
v-model="form.rodo"
:error="form.errors.rodo"

View File

@ -39,6 +39,8 @@ const form = useForm({
mission: missionToString,
rodo: props.cv.rodo,
position: props.cv.position,
notes: props.cv.notes,
sended: props.cv.sended.status,
});
function updateCV() {
@ -103,6 +105,14 @@ function updateCV() {
v-model="form.position"
:error="form.errors.position"
/>
<Input
id="notes"
type="textarea"
label="Notatki (opcjonalne)"
placeholder="Notatka dla administratora"
v-model="form.notes"
:error="form.errors.notes"
/>
<Input
id="mission"
type="textarea"
@ -119,7 +129,20 @@ function updateCV() {
v-model="form.rodo"
:error="form.errors.rodo"
/>
<button class="px-0.5 py-1 rounded-lg bg-[#436da7] border-4 border-[#436da7] text-white text-lg hover:bg-transparent hover:text-[#436da7]">Edytuj CV</button>
<Input
id="sended"
label="Wysłany"
type="checkbox"
v-model="form.sended"
/>
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-3 sm:gap-2 items-center">
<InertiaLink
as="button"
:href="`/dashboard/cv/${cv.token}`"
class="col-span-1 flex justify-center items-center gap-3 w-full px-2 py-1 border-t-4 border-b-4 border-transparent hover:border-b-black"
><FontAwesomeIcon :icon="['fas', 'backward']" />Anuluj</InertiaLink>
<button class="col-span-1 md:col-span-2 px-0.5 py-1 rounded-lg bg-[#436da7] border-4 border-[#436da7] text-white text-lg hover:bg-transparent hover:text-[#436da7]">Edytuj CV</button>
</div>
</form>
</div>
</div>

View File

@ -1,4 +1,6 @@
<script setup>
import EmptyState from '@/Share/Components/EmptyState.vue';
defineProps({
cvs: {
type: Object,
@ -39,7 +41,7 @@ function copySlug(slug) {
><FontAwesomeIcon :icon="['fas', 'plus']" /></InertiaLink>
</header>
<div class="overflow-x-auto">
<table v-if="cvs.data.length" class="w-full min-w-[600px] border-separate border-spacing-y-2">
<table v-if="cvs.data.length" class="w-full min-w-[600px] border-separate border-spacing-y-2 cursor-pointer">
<colgroup>
<col class="w-min" />
</colgroup>
@ -72,19 +74,20 @@ function copySlug(slug) {
as="button"
class="px-3 py-3 text-lime-600 hover:text-lime-800 border-t-2 border-b-2 border-transparent hover:border-b-lime-600"
:href="`/dashboard/cv/${cv.token}/edit`"
title="Edytuj projekt"><FontAwesomeIcon :icon="['fas', 'pen-to-square']" /></InertiaLink>
title="Edytuj CV"><FontAwesomeIcon :icon="['fas', 'pen-to-square']" /></InertiaLink>
<InertiaLink
as="button"
class="px-3 py-3 text-red-600 hover:text-red-800"
:href="`/dashboard/cv/${cv.token}/delete`"
title="Usuń projekt z listy"><FontAwesomeIcon :icon="['fas', 'trash']" /></InertiaLink>
title="Usuń CV z listy"><FontAwesomeIcon :icon="['fas', 'trash']" /></InertiaLink>
</td>
</InertiaLink>
</tbody>
</table>
<div v-else>
Pusta lista
</div>
<EmptyState v-else :icon="['fas', 'file']">
<template #title>Nie znaleziono</template>
<template #text>Nie dodano jeszcze CV do listy.</template>
</EmptyState>
</div>
</div>
</template>

View File

@ -1,5 +1,8 @@
<script setup>
defineProps({
import { computed } from 'vue';
import { router } from '@inertiajs/inertia';
const props = defineProps({
cv: {
type: Object,
required: true,
@ -9,10 +12,25 @@ defineProps({
required: true,
},
});
const CV_URL = import.meta.env.VITE_CV_APP_URL;
const cvNotes = computed(() => {
const notes = props.cv.notes;
return notes ? props.cv.notes.split("\n") : null;
});
</script>
<template>
<InertiaHead title="Szczegóły CV" />
<div class="px-3 py-2">
<InertiaLink
v-if="!cv.sended.status"
as="button"
method="post"
:href="`/dashboard/cv/${cv.token}/sended`"
class="w-full px-0.5 py-1 rounded-lg bg-[#436da7] border-4 border-[#436da7] text-white text-lg hover:bg-transparent hover:text-[#436da7]"
title="Ustaw jako wysłane">Ustaw jako wysłane do odbiorcy.</InertiaLink>
</div>
<div class="p-4">
<header class="flex justify-between items-center pb-4">
<div class="flex items-center gap-2">
@ -23,27 +41,30 @@ defineProps({
title="Wróć do listy CV"><FontAwesomeIcon :icon="['fas', 'caret-left']" /></InertiaLink>
<h1 class="text-3xl font-roboto font-light">Szczegóły CV</h1>
</div>
<div class="flex gap-2">
<div class="flex gap-3 sm:gap-2">
<a
class="px-2 py-1 text-blue-600 hover:text-blue-800"
:href="`https://cv.kamilcraft.com/show/${cv.token}`"
class="flex items-center gap-2 px-2 py-1 text-blue-600 hover:text-blue-800"
:href="`${CV_URL}/show/${cv.token}`"
target="_blank"
rel="noopener noreferrer"
title="Przekieruj do CV"><FontAwesomeIcon :icon="['fas', 'arrow-up-right-from-square']" /></a>
title="Przekieruj do CV"><FontAwesomeIcon :icon="['fas', 'arrow-up-right-from-square']" /><span class="hidden sm:inline-block">Przejdź do CV</span></a>
<InertiaLink
as="button"
:href="`/dashboard/cv/${cv.token}/edit`"
class="flex items-center gap-2 px-2 py-1 text-lime-600 hover:text-white hover:bg-lime-600 rounded-md"
title="Usuń CV"
><FontAwesomeIcon :icon="['fas', 'pen-to-square']" />Edytuj</InertiaLink>
><FontAwesomeIcon :icon="['fas', 'pen-to-square']" /><span class="hidden sm:inline-block">Edytuj</span></InertiaLink>
<InertiaLink
as="button"
:href="`/dashboard/cv/${cv.token}/delete`"
class="flex items-center gap-2 px-2 py-1 text-red-600 hover:text-white hover:bg-red-600 rounded-md"
title="Usuń CV"
><FontAwesomeIcon :icon="['fas', 'trash']" />Usuń</InertiaLink>
><FontAwesomeIcon :icon="['fas', 'trash']" /><span class="hidden sm:inline-block">Usuń</span></InertiaLink>
</div>
</header>
<div v-if="cv.sended.status" class="max-w-screen-lg my-2 lg:mx-auto px-2 py-3 rounded-md bg-yellow-100 text-yellow-600 text-center">
CV jest oznaczone jako wysłane - {{ cv.sended.datetime }}
</div>
<div class="mb-4">
<header>
<h2 class="text-2xl font-roboto font-light pb-3">Podstawowe informacje</h2>
@ -73,6 +94,30 @@ defineProps({
<div class="text-gray-500 pb-0.5">Lokalizacje</div>
<p class="w-full min-w-full max-w-full px-2.5 py-2 border-b-2 rounded-md bg-white">{{ cv.locations.join(' / ') }}</p>
</div>
<div v-if="cvNotes" class="md:col-span-2">
<div class="text-gray-500 pb-0.5">Notatki</div>
<div class="w-full min-w-full max-w-full px-2.5 py-2 border-b-2 rounded-md bg-white">
<p
v-for="(noteLine, key) in cvNotes"
:key="key"
>{{ noteLine }}</p>
</div>
</div>
</div>
</div>
<div class="mb-4">
<header>
<h2 class="text-2xl font-roboto font-light pb-3">Statystyka</h2>
</header>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<div class="text-gray-500 pb-0.5">Utworzono</div>
<p class="w-full min-w-full max-w-full px-2.5 py-2 border-b-2 rounded-md bg-white">{{ cv.created }}</p>
</div>
<div>
<div class="text-gray-500 pb-0.5">Zmodyfikowano</div>
<p class="w-full min-w-full max-w-full px-2.5 py-2 border-b-2 rounded-md bg-white">{{ cv.updated }}</p>
</div>
<div>
<div class="text-gray-500 pb-0.5">Zachowane wyświetlenia</div>
<p class="w-full min-w-full max-w-full px-2.5 py-2 border-b-2 rounded-md bg-white">{{ cv.views }}</p>

View File

@ -0,0 +1,37 @@
<script setup>
import { router } from '@inertiajs/vue3';
const props = defineProps({
message: {
type: Object,
required: true,
},
});
function confirmDelete() {
router.delete(`/dashboard/message/${props.message.id}/delete`);
}
</script>
<template>
<InertiaHead title="Usuwanie wiadomości" />
<div class="p-4">
<header class="pb-4">
<h1 class="text-3xl font-roboto font-light">Usuwanie wiadomości</h1>
</header>
<div class="max-w-[600px]">
<p class="mb-4">Na pewno usunąć wiadomość od {{ message.sender }}?</p>
<div class="grid grid-cols-3 gap-2">
<InertiaLink
as="button"
:href="`/dashboard/message/${message.id}`"
class="col-span-1 flex justify-center items-center gap-3 w-full px-2 py-1 border-t-4 border-b-4 border-transparent hover:border-b-black"
><FontAwesomeIcon :icon="['fas', 'backward']" />Anuluj</InertiaLink>
<button
@click.prevent="confirmDelete"
class="col-span-2 flex justify-center items-center gap-3 w-full px-2 py-1 rounded-md bg-red-600 border-4 border-red-600 text-white text-lg hover:bg-transparent hover:text-red-600"
><FontAwesomeIcon :icon="['fas', 'trash']" /><span class="whitespace-nowrap overflow-hidden overflow-ellipsis">Usuń wiadomość od {{ message.sender }}</span></button>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,65 @@
<script setup>
import EmptyState from '@/Share/Components/EmptyState.vue';
defineProps({
messages: {
type: Object,
default: {},
},
});
</script>
<template>
<InertiaHead title="Wiadomości" />
<div class="p-4">
<header class="flex justify-between items-center pb-4">
<div class="flex items-center gap-2">
<InertiaLink
as="button"
href="/dashboard"
class="px-2 text-xl text-gray-700 hover:text-black"
title="Wróc do dashboard"><FontAwesomeIcon :icon="['fas', 'caret-left']" /></InertiaLink>
<h1 class="text-3xl font-roboto font-light">Wiadomości</h1>
</div>
</header>
<div class="overflow-x-auto">
<table v-if="messages.data.length" class="table-fixed w-full min-w-[600px] border-separate border-spacing-y-2 cursor-pointer">
<colgroup>
<col class="w-[40px] max-w-[60px]" />
<col class="w-[250px]" />
<col class="w-auto" />
<col class="w-[50px]" />
</colgroup>
<thead class="text-left bg-gray-100">
<th class="w-[40px] max-w-[60px] p-2 text-center">ID</th>
<th class="w-[250px] p-2">Wysyłający</th>
<th class="p-2">E-mail</th>
<th class="w-[50px] p-2"></th>
</thead>
<tbody>
<InertiaLink
as="tr"
v-for="(message, key) in messages.data"
:key="key"
class="px-3 py-2 bg-white hover:bg-neutral-200 rounded-md z-10"
:href="`/dashboard/message/${message.id}`">
<td class="p-2 w-[60px] text-center">#{{ message.id }}</td>
<td class="p-2 whitespace-nowrap overflow-hidden overflow-ellipsis">{{ message.sender }}</td>
<td class="p-2">{{ message.email }}</td>
<td class="flex items-center justify-end gap-2 p-3 z-50">
<InertiaLink
as="button"
class="px-3 py-3 text-red-600 hover:text-red-800"
:href="`/dashboard/message/${message.id}/delete`"
title="Usuń wiadomość z listy"><FontAwesomeIcon :icon="['fas', 'trash']" /></InertiaLink>
</td>
</InertiaLink>
</tbody>
</table>
<EmptyState v-else :icon="['fas', 'message']">
<template #title>Brak wiadomości</template>
<template #text>Nie przesłano jeszcze żadnej wiadomości.</template>
</EmptyState>
</div>
</div>
</template>

View File

@ -0,0 +1,71 @@
<script setup>
import { computed } from 'vue';
const props = defineProps({
message: {
type: Object,
required: true,
},
});
const splitMessage = computed(() => props.message.message.split("\n"));
</script>
<template>
<InertiaHead title="Szczegóły wiadomości" />
<div class="p-4">
<header class="flex justify-between items-center pb-4">
<div class="flex items-center gap-2">
<InertiaLink
as="button"
href="/dashboard/message"
class="px-2 text-xl text-gray-700 hover:text-black"
title="Wróć do listy wiadomości"><FontAwesomeIcon :icon="['fas', 'caret-left']" /></InertiaLink>
<h1 class="text-3xl font-roboto font-light">Szczegóły wiadomości</h1>
</div>
<div class="flex gap-3 sm:gap-2">
<InertiaLink
as="button"
:href="`/dashboard/message/${message.id}/delete`"
class="flex items-center gap-2 px-2 py-1 text-red-600 hover:text-white hover:bg-red-600 rounded-md"
title="Usuń wiadomość"
><FontAwesomeIcon :icon="['fas', 'trash']" /><span class="hidden sm:inline-block">Usuń</span></InertiaLink>
</div>
</header>
<div class="mb-4">
<header>
<h2 class="text-2xl font-roboto font-light pb-3">Podstawowe informacje</h2>
</header>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<div class="text-gray-500 pb-0.5">ID</div>
<p class="w-full min-w-full max-w-full px-2.5 py-2 border-b-2 rounded-md bg-white whitespace-nowrap overflow-hidden overflow-ellipsis">{{ message.id }}</p>
</div>
<div>
<div class="text-gray-500 pb-0.5">Nadawca</div>
<p class="w-full min-w-full max-w-full px-2.5 py-2 border-b-2 rounded-md bg-white">{{ message.sender }}</p>
</div>
<div>
<div class="text-gray-500 pb-0.5">E-mail</div>
<p class="w-full min-w-full max-w-full px-2.5 py-2 border-b-2 rounded-md bg-white">{{ message.email }}</p>
</div>
</div>
</div>
<div class="mb-4">
<header>
<h2 class="text-2xl font-roboto font-light pb-3">Treść wiadomości</h2>
</header>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="col-span-1 sm:col-span-2">
<div class="text-gray-500 pb-0.5">Wiadomość</div>
<div class="w-full min-w-full max-w-full px-2.5 py-2 border-b-2 rounded-md bg-white">
<p
v-for="(messageLine, key) in splitMessage"
:key="key"
>{{ messageLine }}</p>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@ -1,4 +1,6 @@
<script setup>
import EmptyState from '@/Share/Components/EmptyState.vue';
defineProps({
categories: {
type: Array,
@ -16,7 +18,7 @@ defineProps({
href="/dashboard/category/create"
class="bg-blue-400 hover:bg-blue-500 text-white px-2.5 py-1 rounded-full"><FontAwesomeIcon :icon="['fas', 'plus']" /></InertiaLink>
</header>
<ul class="flex flex-col gap-2">
<ul v-if="categories.length" class="flex flex-col gap-2">
<li
v-for="(category, key) in categories"
:key="key"
@ -38,5 +40,9 @@ defineProps({
</div>
</li>
</ul>
<EmptyState v-else :icon="['fas', 'list']">
<template #title>Brak kategorii</template>
<template #text>Nie dodano jeszcze żadnej kategorii.</template>
</EmptyState>
</section>
</template>

View File

@ -0,0 +1,31 @@
<script setup>
defineProps({
icon: {
type: String,
defaut: ['far', 'folder-open'],
},
showDescription: {
type: Boolean,
default: true,
},
});
</script>
<template>
<div class="text-center my-5 text-gray-500">
<slot name="head">
<FontAwesomeIcon :icon="icon" class="mx-auto w-12 h-12" />
</slot>
<h3 class="mt-2 text-sm font-medium">
<slot name="title">Brak danych</slot>
</h3>
<p
v-if="showDescription"
class="text-sm"
>
<slot name="text">
Nie znaleziono danych.
</slot>
</p>
</div>
</template>

View File

@ -27,13 +27,13 @@ defineProps({
</InertiaLink>
<nav>
<ul class="flex gap-3 items-center font-bold">
<li><InertiaLink class="text-white active:text-kamilcraft-green hover:text-black hover:underline" href="/dashboard">Dashboard</InertiaLink></li>
<li><InertiaLink class="text-white active:text-kamilcraft-green hover:text-black hover:underline" href="/dashboard/cv">CV</InertiaLink></li>
<li><InertiaLink class="text-white active:text-kamilcraft-green hover:text-black hover:underline" href="/dashboard/message">Msg</InertiaLink></li>
</ul>
</nav>
</div>
</header>
<div v-if="messages?.info" class="max-w-screen-lg mx-2 lg:mx-auto mt-2 px-2 py-3 rounded-md bg-yellow-100 text-yellow-600 text-center">
<div v-if="messages?.info" class="max-w-screen-lg mx-2 lg:mx-auto mt-2 px-2 py-3 rounded-md bg-yellow-100 text-yellow-600 text-center">
{{ messages.info }}
</div>
<div v-if="messages?.error" class="max-w-screen-lg mx-2 lg:mx-auto mt-2 px-2 py-3 rounded-md bg-red-100 text-red-600 text-center">

View File

@ -1,4 +1,6 @@
<script setup>
import EmptyState from '@/Share/Components/EmptyState.vue';
defineProps({
projects: {
type: Array,
@ -38,8 +40,9 @@ defineProps({
</div>
</li>
</ul>
<div v-else>
Empty
</div>
<EmptyState v-else :icon="['fas', 'bars-progress']">
<template #title>Brak projektów</template>
<template #text>Nie dodano jeszcze żadnego projektu.</template>
</EmptyState>
</section>
</template>

View File

@ -19,3 +19,5 @@ Route::prefix('project')->group(function() {
});
Route::get('cv/{cv}', 'CVController@show');
Route::post('message', 'MessageController@store');

View File

@ -5,9 +5,19 @@ declare(strict_types=1);
use Illuminate\Support\Facades\Route;
Route::name('admin.')->group(function () {
Route::namespace('Dashboard')->middleware('auth')->group(function () {
Route::namespace('Dashboard')->middleware('auth')->group(function (): void {
Route::get('', 'AdminPanelController')->name('home');
Route::name('cv.')->prefix('cv')->group(function () {
Route::name('message.')->prefix('message')->group(function (): void {
Route::get('', 'MessageController@index')
->name('index');
Route::get('{message}', 'MessageController@show')
->name('show');
Route::get('{message}/delete', 'MessageController@delete')
->name('delete');
Route::delete('{message}/delete', 'MessageController@destroy')
->name('destroy');
});
Route::name('cv.')->prefix('cv')->group(function (): void {
Route::get('', 'CVController@index')
->name('index');
Route::get('create', 'CVController@create')
@ -16,6 +26,8 @@ Route::name('admin.')->group(function () {
->name('store');
Route::get('{cv}', 'CVController@show')
->name('show');
Route::post('{cv}/sended', 'CVController@updateSendStatus')
->name('sended');
Route::post('', 'CVController@store')
->name('store');
Route::get('{cv}/edit', 'CVController@edit')
@ -27,7 +39,7 @@ Route::name('admin.')->group(function () {
Route::delete('{cv}/delete', 'CVController@destroy')
->name('destroy');
});
Route::name('category.')->prefix('category')->group(function () {
Route::name('category.')->prefix('category')->group(function (): void {
Route::get('create', 'CategoryController@create')
->name('create');
Route::post('', 'CategoryController@store')
@ -44,7 +56,7 @@ Route::name('admin.')->group(function () {
->name('destroy');
});
Route::name('project.')->prefix('project')->group(function () {
Route::name('project.')->prefix('project')->group(function (): void {
Route::get('create', 'ProjectController@create')
->name('create');
Route::post('', 'ProjectController@store')
@ -62,7 +74,7 @@ Route::name('admin.')->group(function () {
});
});
Route::name('auth.')->namespace('Auth')->group(function () {
Route::name('auth.')->namespace('Auth')->group(function (): void {
Route::get('login', 'LoginController@login')
->name('login');
Route::post('login', 'LoginController@authenticate')

71
vite.config.js vendored
View File

@ -1,40 +1,43 @@
import { defineConfig } from 'vite';
import { defineConfig, loadEnv } from 'vite';
import vue from '@vitejs/plugin-vue';
import laravel from 'laravel-vite-plugin';
import { networkInterfaces } from 'os'
import { networkInterfaces } from 'os';
export default defineConfig({
server: {
host: Object.values(networkInterfaces()).flat().find(i => i.family === 'IPv4' && !i.internal).address,
port: 3001,
hmr: {
host: 'localhost',
},
},
resolve: {
alias: {
'@': '/resources/js',
},
},
plugins: [
vue({
template: {
transformAssetUrls: {
base: null,
includeAbsolute: false,
},
export default defineConfig((mode) => {
const env = loadEnv(mode, process.cwd(), "");
return {
server: {
host: Object.values(networkInterfaces()).flat().find(i => i.family === 'IPv4' && !i.internal).address,
port: parseInt(env.VITE_PORT ?? 3001),
hmr: {
host: 'localhost',
},
}),
laravel({
input: 'resources/js/app.js',
ssr: 'resources/js/ssr.js',
refresh: true,
}),
],
ssr: {
noExternal: [
'@inertiajs/server',
'@vue/runtime-dom'
},
resolve: {
alias: {
'@': '/resources/js',
},
},
plugins: [
vue({
template: {
transformAssetUrls: {
base: null,
includeAbsolute: false,
},
},
}),
laravel({
input: 'resources/js/app.js',
ssr: 'resources/js/ssr.js',
refresh: true,
}),
],
},
ssr: {
noExternal: [
'@inertiajs/server',
'@vue/runtime-dom'
],
},
}
});