initial commit
This commit is contained in:
11
resources/css/app.css
Normal file
11
resources/css/app.css
Normal file
@@ -0,0 +1,11 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
||||
@source '../../storage/framework/views/*.php';
|
||||
@source '../**/*.blade.php';
|
||||
@source '../**/*.js';
|
||||
|
||||
@theme {
|
||||
--font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
|
||||
'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
}
|
||||
1
resources/js/app.js
Normal file
1
resources/js/app.js
Normal file
@@ -0,0 +1 @@
|
||||
import './bootstrap';
|
||||
4
resources/js/bootstrap.js
vendored
Normal file
4
resources/js/bootstrap.js
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
import axios from 'axios';
|
||||
window.axios = axios;
|
||||
|
||||
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
|
||||
15
resources/views/contacts/create.blade.php
Normal file
15
resources/views/contacts/create.blade.php
Normal file
@@ -0,0 +1,15 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('content')
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-semibold">New Contact</h1>
|
||||
<p class="mt-1 text-sm text-zinc-600">Add a new contact to your list.</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-zinc-200 bg-white p-6">
|
||||
<form method="post" action="{{ route('contacts.store', $searchQueryParams ?? []) }}">
|
||||
@csrf
|
||||
@include('contacts.partials.form', ['submitLabel' => 'Create'])
|
||||
</form>
|
||||
</div>
|
||||
@endsection
|
||||
16
resources/views/contacts/edit.blade.php
Normal file
16
resources/views/contacts/edit.blade.php
Normal file
@@ -0,0 +1,16 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('content')
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-semibold">Edit Contact</h1>
|
||||
<p class="mt-1 text-sm text-zinc-600">Update contact details.</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-zinc-200 bg-white p-6">
|
||||
<form method="post" action="{{ route('contacts.update', ['contact' => $contact->uuid->toString(), ...($searchQueryParams ?? [])]) }}">
|
||||
@csrf
|
||||
@method('put')
|
||||
@include('contacts.partials.form', ['contact' => $contact, 'submitLabel' => 'Save changes'])
|
||||
</form>
|
||||
</div>
|
||||
@endsection
|
||||
55
resources/views/contacts/index.blade.php
Normal file
55
resources/views/contacts/index.blade.php
Normal file
@@ -0,0 +1,55 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('content')
|
||||
<div class="mb-6 flex items-center justify-end">
|
||||
<a href="{{ route('contacts.create', $searchQueryParams ?? []) }}" class="rounded-md bg-zinc-900 px-4 py-2 text-sm font-semibold text-white">
|
||||
New Contact
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@if ($contacts->count() === 0)
|
||||
<div class="rounded-lg border border-dashed border-zinc-300 bg-white p-8 text-center text-sm text-zinc-600">
|
||||
No contacts found.
|
||||
</div>
|
||||
@else
|
||||
<div class="overflow-hidden rounded-lg border border-zinc-200 bg-white">
|
||||
<table class="min-w-full divide-y divide-zinc-200 text-sm">
|
||||
<thead class="bg-zinc-50 text-left text-xs font-semibold uppercase tracking-wide text-zinc-500">
|
||||
<tr>
|
||||
<th class="px-4 py-3">Name</th>
|
||||
<th class="px-4 py-3">Email</th>
|
||||
<th class="px-4 py-3 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-zinc-200">
|
||||
@foreach ($contacts as $contact)
|
||||
<tr class="hover:bg-zinc-50">
|
||||
<td class="px-4 py-3">
|
||||
<div class="font-medium text-zinc-900">
|
||||
{{ trim(($contact->firstName ?? '') . ' ' . ($contact->lastName ?? '')) ?: '—' }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-zinc-700">
|
||||
{{ $contact->email }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<a href="{{ route('contacts.show', ['contact' => $contact->uuid->toString(), ...($searchQueryParams ?? [])]) }}" class="text-zinc-700 hover:text-zinc-900">
|
||||
View
|
||||
</a>
|
||||
<a href="{{ route('contacts.edit', ['contact' => $contact->uuid->toString(), ...($searchQueryParams ?? [])]) }}" class="text-zinc-700 hover:text-zinc-900">
|
||||
Edit
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
{{ $contacts->links() }}
|
||||
</div>
|
||||
@endif
|
||||
@endsection
|
||||
57
resources/views/contacts/partials/form.blade.php
Normal file
57
resources/views/contacts/partials/form.blade.php
Normal file
@@ -0,0 +1,57 @@
|
||||
@php
|
||||
$contact = $contact ?? null;
|
||||
@endphp
|
||||
|
||||
<div class="grid gap-4">
|
||||
<div>
|
||||
<label for="first_name" class="text-sm font-medium text-zinc-700">First name</label>
|
||||
<input
|
||||
id="first_name"
|
||||
name="first_name"
|
||||
type="text"
|
||||
value="{{ old('first_name', $contact?->firstName) }}"
|
||||
class="mt-2 w-full rounded-md border border-zinc-300 px-3 py-2 text-sm focus:border-zinc-900 focus:outline-none"
|
||||
/>
|
||||
@error('first_name')
|
||||
<p class="mt-1 text-xs text-red-600">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="last_name" class="text-sm font-medium text-zinc-700">Last name</label>
|
||||
<input
|
||||
id="last_name"
|
||||
name="last_name"
|
||||
type="text"
|
||||
value="{{ old('last_name', $contact?->lastName) }}"
|
||||
class="mt-2 w-full rounded-md border border-zinc-300 px-3 py-2 text-sm focus:border-zinc-900 focus:outline-none"
|
||||
/>
|
||||
@error('last_name')
|
||||
<p class="mt-1 text-xs text-red-600">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="email" class="text-sm font-medium text-zinc-700">Email</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
value="{{ old('email', $contact?->email) }}"
|
||||
class="mt-2 w-full rounded-md border border-zinc-300 px-3 py-2 text-sm focus:border-zinc-900 focus:outline-none"
|
||||
required
|
||||
/>
|
||||
@error('email')
|
||||
<p class="mt-1 text-xs text-red-600">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex flex-wrap items-center gap-3">
|
||||
<button type="submit" class="rounded-md bg-zinc-900 px-4 py-2 text-sm font-semibold text-white">
|
||||
{{ $submitLabel }}
|
||||
</button>
|
||||
<a href="{{ route('contacts.index') }}" class="text-sm text-zinc-700 hover:text-zinc-900">
|
||||
Cancel
|
||||
</a>
|
||||
</div>
|
||||
41
resources/views/contacts/show.blade.php
Normal file
41
resources/views/contacts/show.blade.php
Normal file
@@ -0,0 +1,41 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('content')
|
||||
<div class="mb-6 flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold">
|
||||
{{ trim(($contact->firstName ?? '') . ' ' . ($contact->lastName ?? '')) ?: 'Contact' }}
|
||||
</h1>
|
||||
<p class="mt-1 text-sm text-zinc-600">{{ $contact->email }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<a href="{{ route('contacts.edit', ['contact' => $contact->uuid->toString(), ...($searchQueryParams ?? [])]) }}" class="rounded-md border border-zinc-300 px-3 py-2 text-sm font-medium text-zinc-700">
|
||||
Edit
|
||||
</a>
|
||||
<form method="post" action="{{ route('contacts.destroy', ['contact' => $contact->uuid->toString(), ...($searchQueryParams ?? [])]) }}" onsubmit="return confirm('Delete this contact?')">
|
||||
@csrf
|
||||
@method('delete')
|
||||
<button type="submit" class="rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white">
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-zinc-200 bg-white p-6">
|
||||
<dl class="grid gap-4 text-sm">
|
||||
<div>
|
||||
<dt class="font-medium text-zinc-700">First name</dt>
|
||||
<dd class="mt-1 text-zinc-900">{{ $contact->firstName ?? '—' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="font-medium text-zinc-700">Last name</dt>
|
||||
<dd class="mt-1 text-zinc-900">{{ $contact->lastName ?? '—' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="font-medium text-zinc-700">Email</dt>
|
||||
<dd class="mt-1 text-zinc-900">{{ $contact->email }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
@endsection
|
||||
144
resources/views/import/show.blade.php
Normal file
144
resources/views/import/show.blade.php
Normal file
@@ -0,0 +1,144 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const statusEl = document.getElementById('upload-status');
|
||||
if (!statusEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const uuid = statusEl.dataset.importUuid;
|
||||
if (!uuid) {
|
||||
return;
|
||||
}
|
||||
|
||||
statusEl.style.display = 'block';
|
||||
|
||||
const formatDuration = (startIso, endIso) => {
|
||||
if (!startIso || !endIso) {
|
||||
return 'N/A';
|
||||
}
|
||||
|
||||
const start = new Date(startIso);
|
||||
const end = new Date(endIso);
|
||||
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
|
||||
return 'N/A';
|
||||
}
|
||||
|
||||
const totalSeconds = Math.max(0, Math.round((end - start) / 1000));
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
return minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`;
|
||||
};
|
||||
|
||||
const render = (data) => {
|
||||
const state = data.state || 'UNKNOWN';
|
||||
if (state !== 'DONE') {
|
||||
statusEl.textContent = `Processing (${state})`;
|
||||
return;
|
||||
}
|
||||
|
||||
const duration = formatDuration(data.started_at, data.finished_at);
|
||||
statusEl.innerHTML = `
|
||||
<div class="rounded-md border border-zinc-200 bg-zinc-50 p-4 text-sm">
|
||||
<div class="inline-flex items-center rounded-full border border-emerald-200 bg-emerald-100 px-2.5 py-1 text-xs font-semibold text-emerald-900">
|
||||
Import done
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<div class="grid w-full grid-cols-5 gap-2 text-right">
|
||||
<div class="rounded-md border border-zinc-200 bg-white px-3 py-2">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-wide text-zinc-500">Total</div>
|
||||
<div class="text-sm font-semibold text-zinc-900">${data.total_processed ?? 0}</div>
|
||||
</div>
|
||||
<div class="rounded-md border border-zinc-200 bg-white px-3 py-2">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-wide text-zinc-500">Imported</div>
|
||||
<div class="text-sm font-semibold text-zinc-900">${data.total_processed - data.errors - data.duplicates}</div>
|
||||
</div>
|
||||
<div class="rounded-md border border-zinc-200 bg-white px-3 py-2">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-wide text-zinc-500">Failed</div>
|
||||
<div class="text-sm font-semibold text-zinc-900">${data.errors ?? 0}</div>
|
||||
</div>
|
||||
<div class="rounded-md border border-zinc-200 bg-white px-3 py-2">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-wide text-zinc-500">Duplicates</div>
|
||||
<div class="text-sm font-semibold text-zinc-900">${data.duplicates ?? 0}</div>
|
||||
</div>
|
||||
<div class="rounded-md border border-zinc-200 bg-white px-3 py-2">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-wide text-zinc-500">Duration</div>
|
||||
<div class="text-sm font-semibold text-zinc-900">${duration}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
|
||||
const fetchStatus = async () => {
|
||||
const response = await fetch(`/api/imports/${uuid}`, {
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load import status');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
};
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const data = await fetchStatus();
|
||||
render(data);
|
||||
if (data.state === 'DONE' || data.state === 'FAILED') {
|
||||
document.getElementById('import-form').style.display = 'block';
|
||||
clearInterval(timer);
|
||||
}
|
||||
} catch (error) {
|
||||
statusEl.textContent = 'Failed to load import status.';
|
||||
document.getElementById('import-form').style.display = 'block';
|
||||
clearInterval(timer);
|
||||
}
|
||||
};
|
||||
|
||||
const timer = setInterval(poll, 2000);
|
||||
poll();
|
||||
});
|
||||
</script>
|
||||
|
||||
@section('content')
|
||||
<div class="mb-6">
|
||||
<p class="text-sm text-zinc-600">Upload an XML file to import contacts.</p>
|
||||
</div>
|
||||
|
||||
@if($importUuid !== null)
|
||||
<div id="upload-status" style="display: none;" data-import-uuid="{{ $importUuid }}">
|
||||
Loading...
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="rounded-lg border border-zinc-200 bg-white p-6" id="import-form" @if($importUuid !== null)style="display: none" @endif>
|
||||
<form method="post" action="{{ route('import.store') }}" enctype="multipart/form-data">
|
||||
@csrf
|
||||
<div>
|
||||
<label for="file" class="text-sm font-medium text-zinc-700">XML file</label>
|
||||
<div class="mt-2 flex items-center justify-between gap-4">
|
||||
<input
|
||||
id="file"
|
||||
name="file"
|
||||
type="file"
|
||||
accept=".xml,text/xml,application/xml"
|
||||
class="block w-full flex-1 text-sm text-zinc-700 file:mr-4 file:rounded-md file:border-0 file:bg-zinc-900 file:px-4 file:py-2 file:text-sm file:font-semibold file:text-white"
|
||||
/>
|
||||
<button type="submit" class="shrink-0 rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white hover:bg-red-700">
|
||||
Start import
|
||||
</button>
|
||||
</div>
|
||||
@error('file')
|
||||
<p class="mt-1 text-xs text-red-600">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@endsection
|
||||
79
resources/views/layouts/app.blade.php
Normal file
79
resources/views/layouts/app.blade.php
Normal file
@@ -0,0 +1,79 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{ $title ?? config('app.name', 'Laravel') }}</title>
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
</head>
|
||||
<body class="bg-zinc-50 text-zinc-900">
|
||||
<div class="min-h-screen">
|
||||
<div class="flex min-h-screen">
|
||||
<aside class="sticky top-0 hidden h-screen w-64 shrink-0 border-r border-zinc-200 bg-white px-4 py-6 lg:block">
|
||||
<div class="mb-6 text-xs font-semibold uppercase tracking-wide text-zinc-500">
|
||||
Ecomail
|
||||
</div>
|
||||
<nav class="space-y-1 text-sm">
|
||||
<a
|
||||
href="{{ route('contacts.index') }}"
|
||||
class="{{ request()->routeIs('contacts.*') ? 'bg-zinc-100 text-zinc-900' : 'text-zinc-700 hover:bg-zinc-100' }} flex items-center rounded-md px-3 py-2 font-medium"
|
||||
>
|
||||
Contacts
|
||||
</a>
|
||||
<a
|
||||
href="{{ route('import.index') }}"
|
||||
class="{{ request()->routeIs('import.*') ? 'bg-zinc-100 text-zinc-900' : 'text-zinc-700 hover:bg-zinc-100' }} flex items-center rounded-md px-3 py-2 font-medium"
|
||||
>
|
||||
Import
|
||||
</a>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<div class="flex-1">
|
||||
<header class="sticky top-0 z-10 border-b border-zinc-200 bg-white/80 px-4 backdrop-blur">
|
||||
<div class="mx-auto flex h-16 max-w-5xl items-center gap-4">
|
||||
<div class="lg:hidden">
|
||||
<span class="text-sm font-semibold text-zinc-900">Menu</span>
|
||||
</div>
|
||||
@if (request()->routeIs('contacts.*'))
|
||||
<form method="get" action="{{ route('contacts.search') }}" class="flex-1">
|
||||
@csrf
|
||||
@method('get')
|
||||
<label for="q" class="sr-only">Search contacts</label>
|
||||
<div class="relative">
|
||||
<span class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400">
|
||||
🔎
|
||||
</span>
|
||||
<input
|
||||
id="q"
|
||||
name="q"
|
||||
type="text"
|
||||
placeholder="Search contacts"
|
||||
value="{{ $searchQuery ?? '' }}"
|
||||
class="w-full rounded-md border border-zinc-300 bg-white px-9 py-2 text-sm focus:border-zinc-900 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
@elseif (request()->routeIs('import.*'))
|
||||
<h1 class="flex-1 text-center text-sm font-semibold text-zinc-900">
|
||||
Import Contacts
|
||||
</h1>
|
||||
@endif
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="mx-auto max-w-5xl px-4 py-8">
|
||||
|
||||
@if (session('status'))
|
||||
<div class="mb-6 rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900">
|
||||
{{ session('status') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@yield('content')
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
277
resources/views/welcome.blade.php
Normal file
277
resources/views/welcome.blade.php
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user