wip: implement admin crud

This commit is contained in:
Björn Fromme committed 2026-07-16 09:03:42 +02:00
1 parent 306cd6bec4
commit e902a5e298
78 files changed
+2555 -101

No files matched your search

+8
View File
@@ -0,0 +1,8 @@
import htmx from 'htmx.org'
htmx.config.includeIndicatorStyles = false
htmx.config.historyCacheSize = 0
htmx.config.allowScriptTags = false
import './bootstrap.js'
import './styles/admin.css'
@@ -0,0 +1,84 @@
import { Controller } from '@hotwired/stimulus'
import { useFetch } from '../mixins/use_fetch'
import Autocomplete from '@trevoreyre/autocomplete-js'
export default class extends Controller {
static targets = [ 'input', 'field', 'resetButton', 'searchIcon' ]
static values = { url: String, choices: Array, selected: Boolean }
initialize() {
useFetch(this)
}
connect() {
const searchHandler = input => {
return new Promise(resolve => {
// Check input minimum length
if (input.length < 2) {
return resolve([])
}
// Search choices if provided
if (this.choicesValue.length > 0) {
return resolve(this.choicesValue.filter(choice => {
return -1 !== choice.text.toLowerCase().search(input.toLowerCase())
}))
}
// Use API otherwise
let data = { search: input }
fetch(this.urlValue, { method: 'POST', body: JSON.stringify(data), credentials: 'include' })
.then(this.checkStatus)
.then(this.parseJSON)
.then(data => {
resolve(data)
})
})
}
const submitHandler = result => {
this.fieldTarget.value = result.value
this.selectedValue = true
this.element.dispatchEvent(new CustomEvent('select', {
detail: { result }
}))
}
this.autocomplete = new Autocomplete(this.element, {
search: searchHandler,
getResultValue: result => result.text,
submitOnEnter: true,
onSubmit: submitHandler,
debounceTime: 250,
})
if (this.fieldTarget.value) {
this.selectedValue = true
}
// prevent form submission when selecting entry in autocomplete list with enter key
this.element.addEventListener('keypress', e => {
if (e.key === 'Enter') {
e.preventDefault()
}
})
}
reset() {
this.inputTarget.value = null
this.fieldTarget.value = null
this.selectedValue = false
this.element.dispatchEvent(new CustomEvent('reset'))
}
selectedValueChanged() {
this.resetButtonTarget.classList.toggle('hidden', false === this.selectedValue)
this.searchIconTarget.classList.toggle('hidden', this.selectedValue)
}
disconnect() {
this.autocomplete.destroy()
}
}
@@ -0,0 +1,56 @@
import { Controller } from '@hotwired/stimulus'
import flatpickr from 'flatpickr';
import { German } from 'flatpickr/dist/l10n/de';
/* stimulusFetch: 'lazy' */
export default class extends Controller {
static targets = [ 'field' ]
static values = {
minDate: String,
maxDate: String,
disableWeekends: Boolean,
}
initialize() {
flatpickr.localize(German)
}
connect() {
const minDate = this.minDateValue
const maxDate = this.maxDateValue
const options = {
dateFormat: 'Y-m-d',
altFormat: 'd.m.Y',
minDate,
maxDate,
altInput: true,
allowInput: true,
onClose(dates, currentdatestring, instance) {
instance.setDate(instance.altInput.value, true, instance.config.altFormat)
this.element.dispatchEvent(new CustomEvent('datepicker:closed'))
},
onChange(dates, currentdatestring, instance) {
this.element.dispatchEvent(new CustomEvent('datepicker:picked', {
detail: {
date: instance.altInput.value
}
}))
},
}
if (true === this.disableWeekendsValue) {
options['disable'] = [
function(date) {
return (date.getDay() === 0 || date.getDay() === 6);
}
]
}
this.picker = flatpickr(this.fieldTarget, options)
}
disconnect() {
this.picker.destroy()
}
}
@@ -0,0 +1,36 @@
import { Controller } from '@hotwired/stimulus'
import { useClickOutside } from 'stimulus-use'
export default class extends Controller {
static targets = [ 'label', 'dropdown', 'choice' ]
static values = { placeholder: String }
connect() {
this.update()
useClickOutside(this)
}
toggle() {
this.dropdownTarget.classList.toggle('hidden')
}
update() {
let labels = []
this.choiceTargets.forEach((choice) => {
if (choice.checked) {
labels.push(choice.dataset.label)
}
this.labelTarget.innerText = labels.join(', ')
})
if (0 === labels.length) {
this.labelTarget.innerText = this.placeholderValue
}
}
clickOutside(e) {
this.dropdownTarget.classList.toggle('hidden', true)
}
}
+42
View File
@@ -0,0 +1,42 @@
import { Controller } from '@hotwired/stimulus'
import Sortable from 'sortablejs'
import { useFetch } from '../mixins/use_fetch'
/* stimulusFetch: 'lazy' */
export default class extends Controller {
static targets = [ 'item', 'handle', 'positionField', 'positionLabel' ]
static values = { url: String }
connect() {
useFetch(this)
new Sortable(this.element, {
item: '[data-sortable-target="item"]',
handle: '[data-sortable-target="handle"]',
sort: true,
onEnd: () => {
this.updateOrdering()
if (this.urlValue) {
this.saveOrdering()
}
}
})
}
updateOrdering() {
this.positionFieldTargets.forEach((field, index) => {
field.value = index + 1
})
this.positionLabelTargets.forEach((label, index) => {
label.innerText = index + 1
})
}
saveOrdering() {
const body = new FormData()
this.positionFieldTargets.forEach(field => {
body.append(`sorting[${field.dataset.id}]`, field.value)
})
fetch(this.urlValue, this.getInitObject('post', body))
}
}
+1
View File
@@ -181,6 +181,7 @@
<symbol id="icon-no-symbol" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M18.364 18.364A9 9 0 0 0 5.636 5.636m12.728 12.728A9 9 0 0 1 5.636 5.636m12.728 12.728L5.636 5.636" />
</symbol>
<symbol id="icon-draggable" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M8.5 7C9.32843 7 10 6.32843 10 5.5C10 4.67157 9.32843 4 8.5 4C7.67157 4 7 4.67157 7 5.5C7 6.32843 7.67157 7 8.5 7ZM8.5 13.5C9.32843 13.5 10 12.8284 10 12C10 11.1716 9.32843 10.5 8.5 10.5C7.67157 10.5 7 11.1716 7 12C7 12.8284 7.67157 13.5 8.5 13.5ZM10 18.5C10 19.3284 9.32843 20 8.5 20C7.67157 20 7 19.3284 7 18.5C7 17.6716 7.67157 17 8.5 17C9.32843 17 10 17.6716 10 18.5ZM15.5 7C16.3284 7 17 6.32843 17 5.5C17 4.67157 16.3284 4 15.5 4C14.6716 4 14 4.67157 14 5.5C14 6.32843 14.6716 7 15.5 7ZM17 12C17 12.8284 16.3284 13.5 15.5 13.5C14.6716 13.5 14 12.8284 14 12C14 11.1716 14.6716 10.5 15.5 10.5C16.3284 10.5 17 11.1716 17 12ZM15.5 20C16.3284 20 17 19.3284 17 18.5C17 17.6716 16.3284 17 15.5 17C14.6716 17 14 17.6716 14 18.5C14 19.3284 14.6716 20 15.5 20Z"/></symbol>
<symbol id="icon-bus" xmlns="http://www.w3.org/2000/svg" stroke-width="1.5" viewBox="0 0 24 24" stroke="currentColor" fill="none">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M6 17m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 38 KiB

+29
View File
@@ -0,0 +1,29 @@
export const useFetch = controller => {
Object.assign(controller, {
checkStatus(response) {
if (response.status >= 200 && response.status < 300) {
return response
} else {
const error = new Error(response.statusText)
error.response = response
throw error
}
},
parseJSON(response) {
return response.json()
},
parseHTML(response) {
return response.text()
},
getInitObject(method = 'get', body = null) {
return {
method,
body,
credentials: 'include',
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
}
}
})
}
-1
View File
@@ -1,5 +1,4 @@
@import "components/button.css";
@import "components/datatable.css";
@import "components/forms.css";
@import "components/pagination.css";
@import "components/toast.css";
+10
View File
@@ -0,0 +1,10 @@
@import "components/autocomplete.css";
@import "components/badge.css";
@import "components/button.css";
@import "components/datatable.css";
@import "components/datepicker.css";
@import "components/forms.css";
@import "components/pagination.css";
@import "components/toast.css";
@import "components/tooltip.css";
@import "components/typography.css";
+7
View File
@@ -0,0 +1,7 @@
@import "tailwindcss/base";
@import "_base.css";
@import "tailwindcss/components";
@import "_components_admin.css";
@import "tailwindcss/utilities";
+33
View File
@@ -0,0 +1,33 @@
.autocomplete {
@apply relative w-full;
}
.autocomplete[data-loading="true"]:after {
@apply block absolute top-0 right-0;
@apply absolute w-4 h-4 rounded-full right-0 mr-4;
content: "";
border: 3px solid rgba(0, 0, 0, 0.12);
border-right: 3px solid rgba(0, 0, 0, 0.48);
top: 50%;
transform: translateY(-50%);
animation: rotate 1s infinite linear;
}
.autocomplete-icon {
@apply absolute top-1/2 right-0 transform -translate-y-1/2 mr-4;
}
.autocomplete[data-loading="true"] .autocomplete-icon {
@apply hidden;
}
.autocomplete-result-list {
@apply bg-white shadow-md max-h-64 overflow-y-auto;
}
.autocomplete-result {
@apply p-2;
}
.autocomplete-result:hover,
.autocomplete-result[aria-selected="true"] {
@apply bg-gray-200;
}
+3
View File
@@ -0,0 +1,3 @@
.badge {
@apply inline-flex items-center rounded-md px-1.5 py-0.5 text-xs font-medium whitespace-nowrap;
}
+13
View File
@@ -0,0 +1,13 @@
@import "flatpickr/dist/flatpickr.css";
.flatpickr-day.selected,
.flatpickr-day.selected:hover,
.flatpickr-day.selected:focus {
background-color: theme('colors.secondary');
border-color: theme('colors.secondary');
}
.flatpickr-monthDropdown-months,
.flatpickr-current-month .flatpickr-monthDropdown-months {
@apply form-select pt-0 pb-0 focus:outline-none focus:shadow-none;
}