428 lines
No EOL
19 KiB
PHP
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 |