404 lines
24 KiB
Plaintext
404 lines
24 KiB
Plaintext
package blog
|
|
|
|
import (
|
|
"gobeyhan/database/models"
|
|
"fmt"
|
|
)
|
|
|
|
templ List(posts []models.Post) {
|
|
@Layout("Blog Posts") {
|
|
<div class="px-4 py-6 sm:px-0">
|
|
<div class="flex justify-between items-center mb-6">
|
|
<h1 class="text-2xl font-semibold text-gray-900">Blog Posts</h1>
|
|
<a href="/admin/blog/new" class="bg-indigo-600 text-white px-4 py-2 rounded-md hover:bg-indigo-700">Add Post</a>
|
|
</div>
|
|
|
|
<div class="bg-white shadow overflow-hidden sm:rounded-lg">
|
|
<table class="min-w-full divide-y divide-gray-200">
|
|
<thead class="bg-gray-50">
|
|
<tr>
|
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">ID</th>
|
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Title</th>
|
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Categories</th>
|
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Created</th>
|
|
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="bg-white divide-y divide-gray-200">
|
|
for _, post := range posts {
|
|
<tr>
|
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{ fmt.Sprintf("%d", post.ID) }</td>
|
|
<td class="px-6 py-4 text-sm font-medium text-gray-900">
|
|
<div class="max-w-xs truncate">{ post.Title }</div>
|
|
</td>
|
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
|
if len(post.Categories) > 0 {
|
|
for i, cat := range post.Categories {
|
|
if i > 0 {
|
|
<span>, </span>
|
|
}
|
|
<span class="text-xs bg-blue-100 text-blue-800 px-2 py-1 rounded">{ cat.Title }</span>
|
|
}
|
|
} else {
|
|
<span class="text-gray-400">-</span>
|
|
}
|
|
</td>
|
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
|
if post.IsActive {
|
|
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800">Active</span>
|
|
} else {
|
|
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-red-100 text-red-800">Inactive</span>
|
|
}
|
|
if post.IsFront {
|
|
<span class="ml-1 px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-purple-100 text-purple-800">Front</span>
|
|
}
|
|
</td>
|
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
|
{ post.CreatedAt.Format("2006-01-02") }
|
|
</td>
|
|
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
|
<a href={ templ.SafeURL(fmt.Sprintf("/admin/blog/%d/edit", post.ID)) } class="text-indigo-600 hover:text-indigo-900 mr-4">Edit</a>
|
|
<form id={ fmt.Sprintf("delete-blog-%d", post.ID) } action={ templ.SafeURL(fmt.Sprintf("/admin/blog/%d/delete", post.ID)) } method="POST" class="inline">
|
|
<button type="button" class="text-red-600 hover:text-red-900" onclick={ templ.ComponentScript{ Call: fmt.Sprintf("confirmDelete('Delete Post', 'Are you sure you want to delete this post?', 'delete-blog-%d')", post.ID) } }>Delete</button>
|
|
</form>
|
|
</td>
|
|
</tr>
|
|
}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
}
|
|
}
|
|
|
|
templ Create(categories []models.Category, tags []models.Tag, errors map[string]string) {
|
|
@Layout("Create Blog Post") {
|
|
<div class="px-4 py-6 sm:px-0">
|
|
<div class="max-w-4xl mx-auto">
|
|
<h1 class="text-2xl font-semibold text-gray-900 mb-6">Create New Post</h1>
|
|
<form id="blog-form" action="/admin/blog" method="POST" enctype="multipart/form-data" class="space-y-6 bg-white p-6 rounded-lg shadow">
|
|
<div>
|
|
<label for="title" class="block text-sm font-medium text-gray-700">Title *</label>
|
|
<input type="text" name="title" id="title" required
|
|
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm p-2 border" />
|
|
if errors["title"] != "" {
|
|
<p class="mt-2 text-sm text-red-600">{ errors["title"] }</p>
|
|
}
|
|
</div>
|
|
|
|
<div>
|
|
<label for="slug" class="block text-sm font-medium text-gray-700">Slug</label>
|
|
<input type="text" name="slug" id="slug"
|
|
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm p-2 border" />
|
|
<p class="mt-1 text-xs text-gray-400 italic">Leave blank to generate automatically from title.</p>
|
|
</div>
|
|
|
|
<div>
|
|
<label for="content" class="block text-sm font-medium text-gray-700">Content *</label>
|
|
<div id="editor" class="mt-1 bg-white h-64 border rounded-md"></div>
|
|
<input type="hidden" name="content" id="content-input" required />
|
|
if errors["content"] != "" {
|
|
<p class="mt-2 text-sm text-red-600">{ errors["content"] }</p>
|
|
}
|
|
</div>
|
|
|
|
<div>
|
|
<label for="keywords" class="block text-sm font-medium text-gray-700">Keywords</label>
|
|
<input type="text" name="keywords" id="keywords"
|
|
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm p-2 border" />
|
|
</div>
|
|
|
|
<div class="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label for="image_file" class="block text-sm font-medium text-gray-700">Upload Image</label>
|
|
<input type="file" name="image_file" id="image_file" accept="image/*"
|
|
class="mt-1 block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-semibold file:bg-indigo-50 file:text-indigo-700 hover:file:bg-indigo-100" />
|
|
</div>
|
|
<div>
|
|
<label for="image" class="block text-sm font-medium text-gray-700">Manual Image Path (optional)</label>
|
|
<input type="text" name="image" id="image"
|
|
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm p-2 border" />
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-2">Categories</label>
|
|
<div class="grid grid-cols-2 gap-2">
|
|
if len(categories) > 0 {
|
|
for _, cat := range categories {
|
|
<label class="flex items-center">
|
|
<input type="checkbox" name="category_ids" value={ fmt.Sprintf("%d", cat.ID) }
|
|
class="rounded border-gray-300 text-indigo-600 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" />
|
|
<span class="ml-2 text-sm text-gray-700">{ cat.Title }</span>
|
|
</label>
|
|
}
|
|
} else {
|
|
<p class="text-xs text-gray-500 italic col-span-2">No active categories found. <a href="/admin/blog/categories/new" class="text-indigo-600 underline">Add one first.</a></p>
|
|
}
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-2">Tags</label>
|
|
<div class="grid grid-cols-3 gap-2">
|
|
if len(tags) > 0 {
|
|
for _, tag := range tags {
|
|
<label class="flex items-center">
|
|
<input type="checkbox" name="tag_ids" value={ fmt.Sprintf("%d", tag.ID) }
|
|
class="rounded border-gray-300 text-indigo-600 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" />
|
|
<span class="ml-2 text-sm text-gray-700">{ tag.Tag }</span>
|
|
</label>
|
|
}
|
|
} else {
|
|
<p class="text-xs text-gray-500 italic col-span-3">No active tags found. <a href="/admin/blog/tags/new" class="text-indigo-600 underline">Add one first.</a></p>
|
|
}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex items-center space-x-4">
|
|
<label class="flex items-center">
|
|
<input type="checkbox" name="is_active" checked
|
|
class="rounded border-gray-300 text-indigo-600 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" />
|
|
<span class="ml-2 text-sm text-gray-700">Active</span>
|
|
</label>
|
|
|
|
<label class="flex items-center">
|
|
<input type="checkbox" name="is_front" checked
|
|
class="rounded border-gray-300 text-indigo-600 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" />
|
|
<span class="ml-2 text-sm text-gray-700">Show on Front Page</span>
|
|
</label>
|
|
</div>
|
|
|
|
<div class="flex justify-end gap-3">
|
|
<a href="/admin/blog" class="bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50">Cancel</a>
|
|
<button type="submit" class="bg-indigo-600 text-white px-4 py-2 rounded-md hover:bg-indigo-700">Create Post</button>
|
|
</div>
|
|
</form>
|
|
<script>
|
|
autoSlug('title', 'slug');
|
|
|
|
function initQuill() {
|
|
if (typeof Quill === 'undefined') {
|
|
setTimeout(initQuill, 100);
|
|
return;
|
|
}
|
|
|
|
const editorEl = document.querySelector('#editor');
|
|
if (!editorEl || editorEl.classList.contains('ql-container')) return;
|
|
|
|
const quill = new Quill('#editor', {
|
|
theme: 'snow',
|
|
modules: {
|
|
toolbar: [
|
|
[{ 'header': [1, 2, 3, false] }],
|
|
['bold', 'italic', 'underline', 'strike'],
|
|
['link', 'image', 'video'],
|
|
[{ 'list': 'ordered'}, { 'list': 'bullet' }],
|
|
['clean']
|
|
]
|
|
}
|
|
});
|
|
|
|
const form = editorEl.closest('form');
|
|
if (form) {
|
|
const syncContent = () => {
|
|
const contentInput = document.querySelector('#content-input');
|
|
if (contentInput) contentInput.value = quill.root.innerHTML;
|
|
};
|
|
|
|
form.addEventListener('submit', syncContent);
|
|
form.addEventListener('htmx:configRequest', (event) => {
|
|
syncContent();
|
|
if (event.detail.parameters) {
|
|
event.detail.parameters['content'] = quill.root.innerHTML;
|
|
}
|
|
});
|
|
}
|
|
}
|
|
initQuill();
|
|
</script>
|
|
</div>
|
|
</div>
|
|
}
|
|
}
|
|
|
|
templ Edit(post *models.Post, categories []models.Category, tags []models.Tag, errors map[string]string) {
|
|
@Layout("Edit Blog Post") {
|
|
<div class="px-4 py-6 sm:px-0">
|
|
<div class="max-w-4xl mx-auto">
|
|
<h1 class="text-2xl font-semibold text-gray-900 mb-6">Edit Post: { post.Title }</h1>
|
|
<form id="blog-form-edit" action={ templ.SafeURL(fmt.Sprintf("/admin/blog/%d", post.ID)) } method="POST" enctype="multipart/form-data" class="space-y-6 bg-white p-6 rounded-lg shadow">
|
|
<div>
|
|
<label for="title" class="block text-sm font-medium text-gray-700">Title *</label>
|
|
<input type="text" name="title" id="title" value={ post.Title } required
|
|
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm p-2 border" />
|
|
if errors["title"] != "" {
|
|
<p class="mt-2 text-sm text-red-600">{ errors["title"] }</p>
|
|
}
|
|
</div>
|
|
|
|
<div>
|
|
<label for="slug" class="block text-sm font-medium text-gray-700">Slug</label>
|
|
<input type="text" name="slug" id="slug" value={ post.Slug }
|
|
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm p-2 border" />
|
|
<p class="mt-1 text-xs text-gray-400 italic">Leave blank to keep existing or regenerate from title.</p>
|
|
</div>
|
|
|
|
<div>
|
|
<label for="content" class="block text-sm font-medium text-gray-700">Content *</label>
|
|
<div id="editor" class="mt-1 bg-white h-64 border rounded-md">@templ.Raw(post.Content)</div>
|
|
<input type="hidden" name="content" id="content-input" value={ post.Content } required />
|
|
if errors["content"] != "" {
|
|
<p class="mt-2 text-sm text-red-600">{ errors["content"] }</p>
|
|
}
|
|
</div>
|
|
|
|
<div>
|
|
<label for="keywords" class="block text-sm font-medium text-gray-700">Keywords</label>
|
|
<input type="text" name="keywords" id="keywords" value={ post.Keywords }
|
|
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm p-2 border" />
|
|
</div>
|
|
|
|
<div class="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label for="image_file" class="block text-sm font-medium text-gray-700">Update Image</label>
|
|
<input type="file" name="image_file" id="image_file" accept="image/*"
|
|
class="mt-1 block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-semibold file:bg-indigo-50 file:text-indigo-700 hover:file:bg-indigo-100" />
|
|
if post.Image != "" {
|
|
<div class="mt-2">
|
|
<p class="text-xs text-gray-500 mb-1">Current Preview:</p>
|
|
<img src={ post.Image } class="h-20 w-auto rounded border shadow-sm" />
|
|
</div>
|
|
}
|
|
</div>
|
|
<div>
|
|
<label for="image" class="block text-sm font-medium text-gray-700">Current Image Path</label>
|
|
<input type="text" name="image" id="image" value={ post.Image }
|
|
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm p-2 border" />
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-2">Categories</label>
|
|
<div class="grid grid-cols-2 gap-2">
|
|
for _, cat := range categories {
|
|
<label class="flex items-center">
|
|
<input type="checkbox" name="category_ids" value={ fmt.Sprintf("%d", cat.ID) }
|
|
if isCategorySelected(post.Categories, cat.ID) {
|
|
checked
|
|
}
|
|
class="rounded border-gray-300 text-indigo-600 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" />
|
|
<span class="ml-2 text-sm text-gray-700">{ cat.Title }</span>
|
|
</label>
|
|
}
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-2">Tags</label>
|
|
<div class="grid grid-cols-3 gap-2">
|
|
for _, tag := range tags {
|
|
<label class="flex items-center">
|
|
<input type="checkbox" name="tag_ids" value={ fmt.Sprintf("%d", tag.ID) }
|
|
if isTagSelected(post.Tags, tag.ID) {
|
|
checked
|
|
}
|
|
class="rounded border-gray-300 text-indigo-600 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" />
|
|
<span class="ml-2 text-sm text-gray-700">{ tag.Tag }</span>
|
|
</label>
|
|
}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex items-center space-x-4">
|
|
<label class="flex items-center">
|
|
<input type="checkbox" name="is_active"
|
|
if post.IsActive {
|
|
checked
|
|
}
|
|
class="rounded border-gray-300 text-indigo-600 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" />
|
|
<span class="ml-2 text-sm text-gray-700">Active</span>
|
|
</label>
|
|
|
|
<label class="flex items-center">
|
|
<input type="checkbox" name="is_front"
|
|
if post.IsFront {
|
|
checked
|
|
}
|
|
class="rounded border-gray-300 text-indigo-600 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" />
|
|
<span class="ml-2 text-sm text-gray-700">Show on Front Page</span>
|
|
</label>
|
|
</div>
|
|
|
|
<div class="flex justify-end gap-3">
|
|
<a href="/admin/blog" class="bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50">Cancel</a>
|
|
<button type="submit" class="bg-indigo-600 text-white px-4 py-2 rounded-md hover:bg-indigo-700">Save Changes</button>
|
|
</div>
|
|
</form>
|
|
<script>
|
|
autoSlug('title', 'slug');
|
|
|
|
function initQuillEdit() {
|
|
if (typeof Quill === 'undefined') {
|
|
setTimeout(initQuillEdit, 100);
|
|
return;
|
|
}
|
|
|
|
const editorEl = document.querySelector('#editor');
|
|
if (!editorEl || editorEl.classList.contains('ql-container')) return;
|
|
|
|
const quill = new Quill('#editor', {
|
|
theme: 'snow',
|
|
modules: {
|
|
toolbar: [
|
|
[{ 'header': [1, 2, 3, false] }],
|
|
['bold', 'italic', 'underline', 'strike'],
|
|
['link', 'image', 'video'],
|
|
[{ 'list': 'ordered'}, { 'list': 'bullet' }],
|
|
['clean']
|
|
]
|
|
}
|
|
});
|
|
|
|
const form = editorEl.closest('form');
|
|
if (form) {
|
|
const syncContent = () => {
|
|
const contentInput = document.querySelector('#content-input');
|
|
if (contentInput) contentInput.value = quill.root.innerHTML;
|
|
};
|
|
|
|
form.addEventListener('submit', syncContent);
|
|
form.addEventListener('htmx:configRequest', (event) => {
|
|
syncContent();
|
|
if (event.detail.parameters) {
|
|
event.detail.parameters['content'] = quill.root.innerHTML;
|
|
}
|
|
});
|
|
}
|
|
}
|
|
initQuillEdit();
|
|
</script>
|
|
</div>
|
|
</div>
|
|
}
|
|
}
|
|
|
|
func isCategorySelected(categories []*models.Category, id uint64) bool {
|
|
for _, cat := range categories {
|
|
if cat.ID == id {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func isTagSelected(tags []*models.Tag, id uint64) bool {
|
|
for _, tag := range tags {
|
|
if tag.ID == id {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|