la_bloger/resources/views/admin/posts/edit.blade.php
2026-05-22 17:42:10 +02:00

428 lines
No EOL
19 KiB
PHP

@extends('admin::layouts.app')
@section('lock-channel-selector', '1')
@section('title', $post->title)
@section('content')
<div class="flex items-center justify-between mb-6">
<h1 class="text-lg font-semibold text-gray-900 truncate max-w-lg">{{ $post->title }}</h1>
<div class="flex items-center gap-3 shrink-0 ml-4">
<button type="button" x-data
@click="$store.sidebar.hidden = !$store.sidebar.hidden"
class="text-gray-400 hover:text-gray-600 transition-colors p-1 rounded hover:bg-gray-100"
title="Toggle sidebar">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
<line x1="15" y1="3" x2="15" y2="21"/>
</svg>
</button>
<button type="button" x-data x-on:click="$dispatch('open-duplicate-modal')"
class="text-sm text-gray-500 hover:text-gray-700 border border-gray-300 rounded px-3 py-1">
Duplicate to…
</button>
<a href="{{ route('admin.posts.index') }}" class="text-sm text-gray-500 hover:text-gray-700"> Posts</a>
</div>
</div>
<form method="POST" action="{{ route('admin.posts.update', $post) }}" id="post-form"
x-data="{ focusActive: false }"
@focus-mode-changed.window="focusActive = $event.detail.active">
@csrf @method('PUT')
<div class="grid grid-cols-12 gap-6 items-start transition-all duration-300">
{{-- Left column: content --}}
<div class="space-y-4 transition-all duration-300"
:class="$store.sidebar.hidden ? 'col-span-12' : (focusActive ? 'col-span-9' : 'col-span-8')">
{{-- Title + slug --}}
<div class="bg-white rounded shadow p-5"
x-data="{ manual: {{ $post->slug ? 'true' : 'false' }} }">
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">Title</label>
<input type="text" name="title" id="post-title"
value="{{ old('title', $post->title) }}"
@input="if(!manual) {
document.getElementById('post-slug').value = $event.target.value
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
}"
class="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 @error('title') border-red-500 @enderror">
@error('title') <p class="mt-1 text-xs text-red-600">{{ $message }}</p> @enderror
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
Slug <span class="font-normal text-gray-400">(optional)</span>
</label>
<input type="text" name="slug" id="post-slug"
value="{{ old('slug', $post->slug) }}"
@input="manual = true"
class="w-full border border-gray-300 rounded px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-indigo-500">
<p class="mt-1 text-xs text-gray-400">Leave blank to use post ID.</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Prefix</label>
<select name="prefix_id"
class="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500">
@foreach($prefixes as $prefix)
<option value="{{ $prefix->id }}"
{{ old('prefix_id', $post->prefix_id) == $prefix->id ? 'selected' : '' }}>
/{{ $prefix->slug }}
</option>
@endforeach
</select>
</div>
</div>
</div>
{{-- Block editor --}}
<div class="bg-white rounded shadow p-5">
<div class="flex justify-between w-full">
<h2 class="text-sm font-semibold text-gray-700 mb-4">Content</h2>
{{-- Sidebar toggle button --}}
<button type="button" x-data
@click="$store.sidebar.hidden = !$store.sidebar.hidden"
class="text-gray-400 hover:text-gray-600 transition-colors p-1 rounded hover:bg-gray-100"
title="Toggle sidebar">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
<line x1="15" y1="3" x2="15" y2="21"/>
</svg>
</button>
</div>
@include('admin::components.block-editor', ['publication' => $post, 'channelTerms' => $channelTerms])
</div>
</div>
{{-- Right sidebar --}}
<div class="space-y-4 transition-all duration-300"
x-show="!$store.sidebar.hidden"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 -translate-x-2"
x-transition:enter-end="opacity-100 translate-x-0"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 translate-x-0"
x-transition:leave-end="opacity-0 translate-x-2"
:class="focusActive ? 'col-span-3' : 'col-span-4'">
{{-- Publish panel --}}
<div class="bg-white rounded shadow p-4"
x-data="{ status: '{{ old('status', $post->status) }}' }">
<h3 class="text-sm font-semibold text-gray-700 mb-3">Publish</h3>
<div class="mb-3">
<label class="block text-xs font-medium text-gray-600 mb-1">Status</label>
<select name="status" x-model="status"
class="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500">
<option value="draft">Draft</option>
<option value="published">Published</option>
<option value="scheduled">Scheduled</option>
</select>
</div>
<div class="mb-4" x-show="status === 'scheduled' || status === 'published'" x-cloak>
<label class="block text-xs font-medium text-gray-600 mb-1">Publish date/time</label>
<input type="datetime-local" name="published_at"
value="{{ old('published_at', $post->published_at?->format('Y-m-d\TH:i')) }}"
class="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500">
</div>
<button type="submit"
class="w-full bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-medium py-2 px-4 rounded">
Save
</button>
@if($post->slug || $post->post_id)
<a href="{{ $post->url }}" target="_blank"
class="block text-center mt-2 text-xs text-indigo-600 hover:underline">
View
</a>
@endif
</div>
{{-- SEO panel --}}
<div class="bg-white rounded shadow p-4">
<h3 class="text-sm font-semibold text-gray-700 mb-3">SEO</h3>
<div class="mb-3">
<label class="block text-xs font-medium text-gray-600 mb-1">Meta title</label>
<input type="text" name="meta_title"
value="{{ old('meta_title', $post->meta_title) }}"
class="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500">
</div>
<div class="mb-3">
<label class="block text-xs font-medium text-gray-600 mb-1">Meta description</label>
<textarea name="meta_description" rows="3"
class="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500">{{ old('meta_description', $post->meta_description) }}</textarea>
</div>
<div class="mb-3">
<label class="block text-xs font-medium text-gray-600 mb-1">OG image URL</label>
<input type="text" name="og_image"
value="{{ old('og_image', $post->og_image) }}"
class="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500">
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Canonical URL</label>
<input type="text" name="canonical_url"
value="{{ old('canonical_url', $post->canonical_url) }}"
class="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500">
<p class="mt-1 text-xs text-gray-400">Leave blank to auto-generate.</p>
</div>
</div>
{{-- Taxonomy panel --}}
@if($channelTerms->isNotEmpty())
<div class="bg-white rounded shadow p-4">
<h3 class="text-sm font-semibold text-gray-700 mb-3">
{{ ucfirst($channel->taxonomy_slug) }}
</h3>
@foreach($channelTerms as $term)
<label class="flex items-center gap-2 mb-2 cursor-pointer">
<input type="checkbox" name="taxonomy_terms[]" value="{{ $term->id }}"
{{ in_array($term->id, old('taxonomy_terms', $post->taxonomyTerms->pluck('id')->toArray())) ? 'checked' : '' }}
class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
<span class="text-sm text-gray-700">{{ $term->title }}</span>
</label>
@endforeach
</div>
@else
<div class="bg-white rounded shadow p-4">
<h3 class="text-sm font-semibold text-gray-700 mb-2">{{ ucfirst($channel->taxonomy_slug) }}</h3>
<p class="text-xs text-gray-400">
No terms yet.
<a href="{{ route('admin.taxonomy.create') }}" class="text-indigo-600 hover:underline">Add one</a>.
</p>
</div>
@endif
</div>
</div>
</form>
<div x-data="duplicateModal()"
x-on:open-duplicate-modal.window="open = true"
x-show="open" x-cloak
class="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div @click.outside="open = false"
class="bg-white rounded shadow-lg p-6 w-full max-w-sm">
<h2 class="text-sm font-semibold text-gray-800 mb-4">Duplicate post to…</h2>
<form method="POST" action="{{ route('admin.posts.duplicate', $post) }}">
@csrf
<div class="mb-3">
<label class="block text-xs font-medium text-gray-600 mb-1">Channel</label>
<select name="channel_id" x-model="channelId" @change="syncPrefix()"
class="w-full border border-gray-300 rounded px-3 py-2 text-sm">
@foreach($allChannels as $ch)
<option value="{{ $ch->id }}" {{ $ch->id === $post->channel_id ? 'selected' : '' }}>
{{ $ch->name }}
</option>
@endforeach
</select>
</div>
<div class="mb-4">
<label class="block text-xs font-medium text-gray-600 mb-1">Prefix</label>
<select name="prefix_id" x-model="prefixId"
class="w-full border border-gray-300 rounded px-3 py-2 text-sm">
<template x-for="p in availablePrefixes" :key="p.id">
<option :value="p.id" :selected="p.id == prefixId" x-text="'/' + p.slug"></option>
</template>
</select>
</div>
<p x-show="!sameChannel" x-cloak class="text-xs text-amber-600 mb-4">
Taxonomy terms won't be copied to a different channel.
</p>
<div class="flex gap-2 justify-end">
<button type="button" @click="open = false"
class="text-sm text-gray-500 hover:text-gray-700 px-3 py-2">Cancel</button>
<button type="submit"
class="bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-medium px-4 py-2 rounded">
Duplicate
</button>
</div>
</form>
</div>
</div>
@php
$channelsData = $allChannels->map(fn ($c) => [
'id' => $c->id,
'prefixes' => $c->prefixes->map(fn ($p) => ['id' => $p->id, 'slug' => $p->slug])->values(),
])->values();
@endphp
<script>
function duplicateModal() {
return {
open: false,
channelId: {{ $post->channel_id }},
prefixId: {{ $post->prefix_id ?? 'null' }},
channels: @json($channelsData),
get availablePrefixes() {
const ch = this.channels.find(c => c.id == this.channelId);
return ch ? ch.prefixes : [];
},
get sameChannel() {
return this.channelId == {{ $post->channel_id }};
},
syncPrefix() {
const prefixes = this.availablePrefixes;
this.prefixId = prefixes.length ? prefixes[0].id : null;
},
}
}
</script>
<script>
document.addEventListener('alpine:init', () => {
Alpine.store('sidebar', { hidden: false });
});
</script>
<style>
[x-cloak] { display: none !important; }
/* ---- Slim toolbar ---- */
.tbtn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
border-radius: 0.25rem;
font-size: 0.75rem;
color: #4b5563;
transition: background 0.1s;
cursor: pointer;
border: none;
background: transparent;
line-height: 1;
}
.tbtn:hover:not(:disabled) { background: #f3f4f6; }
.tbtn:disabled { opacity: 0.3; cursor: not-allowed; }
.tbtn-active { background: #dbeafe !important; color: #1d4ed8 !important; }
.tdiv {
display: inline-block;
width: 1px;
height: 1.25rem;
background: #e5e7eb;
margin: 0 0.125rem;
vertical-align: middle;
align-self: center;
}
/* ---- Bubble menu ---- */
.bubble-menu {
transform: translateY(-100%);
animation: bubble-in 0.15s ease-out;
}
@keyframes bubble-in {
from { opacity: 0; transform: translateY(-80%); }
to { opacity: 1; transform: translateY(-100%); }
}
.bubble-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
border-radius: 0.25rem;
font-size: 0.75rem;
color: #e5e7eb;
transition: background 0.1s, color 0.1s;
cursor: pointer;
border: none;
background: transparent;
line-height: 1;
}
.bubble-btn:hover { background: rgba(255,255,255,0.15); color: #fff; }
/* ---- Block cards ---- */
.block-card {
transition: box-shadow 0.3s, opacity 0.3s, border-color 0.3s;
}
.block-handle {
transition: opacity 0.15s;
}
/* ---- ProseMirror editor ---- */
.tiptap-editor .ProseMirror {
outline: none;
min-height: 12rem;
padding: 0.75rem;
transition: min-height 0.3s;
}
.tiptap-editor .ProseMirror p { margin: 0.75rem 0; }
.tiptap-editor .ProseMirror p:first-child { margin-top: 0; }
.tiptap-editor .ProseMirror p:last-child { margin-bottom: 0; }
.tiptap-editor .ProseMirror h2 { font-size: 1.5rem; font-weight: 700; margin: 1.5rem 0 0.5rem; line-height: 1.25; }
.tiptap-editor .ProseMirror h3 { font-size: 1.25rem; font-weight: 600; margin: 1.25rem 0 0.5rem; line-height: 1.375; }
.tiptap-editor .ProseMirror h4 { font-size: 1.125rem; font-weight: 600; margin: 1rem 0 0.5rem; line-height: 1.375; }
.tiptap-editor .ProseMirror h5 { font-size: 1rem; font-weight: 600; margin: 1rem 0 0.5rem; }
.tiptap-editor .ProseMirror h6 { font-size: 0.875rem; font-weight: 600; margin: 1rem 0 0.5rem; color: #4b5563; }
.tiptap-editor .ProseMirror ul,
.tiptap-editor .ProseMirror ol { padding-left: 1.5rem; margin: 1rem 0; }
.tiptap-editor .ProseMirror ul { list-style-type: disc; }
.tiptap-editor .ProseMirror ol { list-style-type: decimal; }
.tiptap-editor .ProseMirror li { margin: 0.25rem 0; }
.tiptap-editor .ProseMirror blockquote {
border-left: 4px solid #d1d5db;
padding-left: 1rem;
margin-left: 0;
font-style: italic;
color: #4b5563;
}
.tiptap-editor .ProseMirror pre {
background: #f3f4f6;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
padding: 0.75rem;
margin: 1rem 0;
font-family: monospace;
font-size: 0.875rem;
}
.tiptap-editor .ProseMirror code {
background: #f3f4f6;
color: #dc2626;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
font-size: 0.875rem;
}
.tiptap-editor .ProseMirror pre code {
background: transparent;
color: #111827;
padding: 0;
}
.tiptap-editor .ProseMirror a { color: #2563eb; text-decoration: underline; cursor: pointer; }
.tiptap-editor .ProseMirror a:hover { color: #1d4ed8; }
.tiptap-editor .ProseMirror hr { border: none; border-top: 2px solid #d1d5db; margin: 2rem 0; }
/* Image sizes */
.tiptap-editor .ProseMirror img.img-xs { width: clamp(48px, 10%, 120px); }
.tiptap-editor .ProseMirror img.img-sm { width: clamp(120px, 25%, 380px); }
.tiptap-editor .ProseMirror img.img-md { width: clamp(200px, 50%, 680px); }
.tiptap-editor .ProseMirror img.img-lg { width: clamp(300px, 75%, 1000px); }
.tiptap-editor .ProseMirror img.img-full { width: 100%; }
/* Tables */
.tiptap-editor .ProseMirror table { border-collapse: collapse; margin: 1rem 0; table-layout: fixed; width: 100%; }
.tiptap-editor .ProseMirror table td,
.tiptap-editor .ProseMirror table th { border: 1px solid #d1d5db; padding: 0.5rem; vertical-align: top; min-width: 50px; position: relative; }
.tiptap-editor .ProseMirror table th { background: #f3f4f6; font-weight: 600; text-align: left; }
.tiptap-editor .ProseMirror .selectedCell { background: #e5e7eb; }
</style>
@endsection