Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ public function logout(Request $request)
$request->session()->invalidate();

$request->session()->regenerateToken();

return Inertia::location(config('modular.login-url'));
}
}
6 changes: 5 additions & 1 deletion stubs/resources/js/Components/DataTable/AppDataSearch.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
<AppInputText
id="search"
v-model="searchTerm"
:placeholder="__('Search')"
:placeholder="placeholder"
name="search"
class="w-full py-2 pl-9 md:w-1/2"
></AppInputText>
Expand Down Expand Up @@ -39,6 +39,10 @@ const props = defineProps({
additionalParams: {
type: Object,
default: () => {}
},
placeholder: {
type: String,
default: 'Search'
}
})

Expand Down
223 changes: 223 additions & 0 deletions stubs/resources/js/Components/Form/AppMultiCombobox.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
<template>
<div ref="wrapperRef" class="relative w-64">
<!-- Button to open dropdown -->
<AppButton
class="mt-1 flex w-full flex-wrap justify-between rounded-md border-0 bg-skin-neutral-1 px-3 py-2 text-left text-skin-neutral-12 shadow-sm ring-1 ring-inset ring-skin-neutral-7 focus:ring-2 focus:ring-inset focus:ring-skin-neutral-7 sm:text-sm sm:leading-6"
aria-haspopup="true"
:aria-expanded="isOpen"
@click="toggleState"
>
<!-- Selected tags -->
<div class="flex flex-wrap gap-1">
<span
v-for="(item, index) in modelValue"
:key="item.value"
class="flex items-center rounded bg-skin-neutral-3 px-2 py-1 text-xs text-skin-neutral-12"
>
{{ item.label }}
<i
class="ri-close-line ml-1 cursor-pointer hover:text-red-500"
@click.stop="remove(index)"
/>
</span>

<span v-if="!modelValue.length" class="text-skin-neutral-9">
{{ comboLabel }}
</span>
</div>

<span class="ml-auto">
<i class="ri-arrow-down-line hover:text-skin-neutral-9"></i>
</span>
</AppButton>

<!-- Dropdown -->
<transition name="slide-fade">
<div v-show="isOpen" class="absolute z-50 mt-1 w-full">
<!-- Search -->
<div v-show="useSearch" class="bg-white p-1 shadow">
<label :for="getElementId()" class="sr-only">Search</label>
<div class="relative">
<div
class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3"
>
<i class="ri-search-line" aria-hidden="true"></i>
</div>
<AppInputText
:id="getElementId()"
ref="searchInputRef"
v-model="searchOptionText"
role="searchbox"
aria-autocomplete="list"
type="text"
class="pl-10"
:placeholder="searchPlaceholder"
@keypress.enter="validateOptionHighlighted"
@keydown="handleArrowKeys"
@keydown.esc="toggleState"
/>
</div>
</div>

<!-- Options -->
<ul
class="max-h-60 overflow-y-auto bg-white p-1 shadow"
role="listbox"
>
<li
v-for="(option, index) in filteredOptions"
:key="option.value"
role="option"
class="flex items-center gap-2 px-4 py-2 text-sm hover:cursor-pointer hover:bg-skin-neutral-3 hover:text-skin-neutral-12"
:class="{
'bg-skin-neutral-3 text-skin-neutral-12':
index === highlightedIndex
}"
@click="toggleSelect(option)"
>
<input
type="checkbox"
class="rounded border-gray-300"
:checked="isSelected(option)"
@change="toggleSelect(option)"
/>
{{ option.label }}
</li>
</ul>
</div>
</transition>
</div>
</template>

<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import slug from '@resources/js/Utils/slug.js'
import useClickOutside from '@resources/js/Composables/useClickOutside'

const props = defineProps({
modelValue: {
type: Array,
required: true,
default: () => []
},
comboLabel: {
type: String,
default: 'Select options'
},
useSearch: {
type: Boolean,
default: true
},
searchPlaceholder: {
type: String,
default: 'Search'
},
options: {
type: Array,
default: () => []
}
})

const emit = defineEmits(['update:modelValue'])

const wrapperRef = ref(null)
const { isClickOutside } = useClickOutside(wrapperRef)

watch(isClickOutside, (val) => {
if (val) {
isOpen.value = false
}
})

onMounted(() => {
isOpen.value && (highlightedIndex.value = 0)
})

const getElementId = () => {
return slug(props.comboLabel)
}

const isOpen = ref(false)
const searchInputRef = ref(null)

const toggleState = () => {
isOpen.value = !isOpen.value
highlightedIndex.value = 0
window.setTimeout(() => {
if (isOpen.value) {
searchOptionText.value = ''
searchInputRef.value.focusInput()
}
}, 100)
}

const searchOptionText = ref('')

const filteredOptions = computed(() => {
if (searchOptionText.value) {
return props.options.filter((option) =>
option.label
.toLowerCase()
.includes(searchOptionText.value.toLowerCase())
)
} else {
return props.options
}
})

const highlightedIndex = ref(0)

const handleArrowKeys = (event) => {
switch (event.key) {
case 'ArrowUp':
if (highlightedIndex.value > 0) {
highlightedIndex.value--
}
break
case 'ArrowDown':
if (highlightedIndex.value < filteredOptions.value.length - 1) {
highlightedIndex.value++
}
break
}
}

const validateOptionHighlighted = () => {
if (filteredOptions.value[highlightedIndex.value]) {
toggleSelect(filteredOptions.value[highlightedIndex.value])
}
}

const isSelected = (option) => {
return props.modelValue.some((item) => item.value === option.value)
}

const toggleSelect = (option) => {
let newValue
if (isSelected(option)) {
newValue = props.modelValue.filter(
(item) => item.value !== option.value
)
} else {
newValue = [...props.modelValue, option]
}
emit('update:modelValue', newValue)
}

const remove = (index) => {
const newValue = [...props.modelValue]
newValue.splice(index, 1)
emit('update:modelValue', newValue)
}
</script>

<style scoped>
.slide-fade-enter-active,
.slide-fade-leave-active {
@apply transition-all duration-200 ease-in;
}
.slide-fade-enter-from,
.slide-fade-leave-to {
@apply -translate-y-2 opacity-0;
}
</style>
66 changes: 59 additions & 7 deletions stubs/resources/js/Components/Misc/AppTopBar.vue
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
<template>
<div
class="bg-neutral-2 text-neutral-11 sticky top-0 z-40 flex h-16 shrink-0 justify-between py-3 pr-9 pl-3 shadow-xs"
class="sticky top-0 z-40 flex h-16 flex-shrink-0 justify-between bg-skin-neutral-2 py-3 pl-3 pr-4 text-skin-neutral-11 shadow-sm md:pr-6"
>
<div class="flex items-center">
<AppButton
class="btn btn-icon hover:bg-neutral-4"
class="btn btn-icon hover:bg-skin-neutral-5"
@click="$emit('sidebar:toggle')"
>
<i class="ri-menu-line"></i>
Expand All @@ -13,17 +13,33 @@
<h1 class="flex items-center">{{ title }}</h1>
</div>

<div class="flex items-center">
<div class="flex items-center gap-1">
<!-- Fullscreen toggle with animation -->
<AppButton
class="btn btn-icon hover:bg-skin-neutral-5 transition-all duration-300"
@click="toggleFullscreen"
>
<i
:class="iconFullscreenClass"
class="transition-transform duration-500 ease-in-out"
:style="{ transform: isFullscreen ? 'rotate(180deg) scale(1.2)' : 'rotate(0deg) scale(1)' }"
></i>
</AppButton>


<!-- Theme toggle -->
<AppButton
href="#"
class="btn btn-icon hover:bg-neutral-4"
class="btn btn-icon hover:bg-skin-neutral-5 transition-all duration-300"
@click="toggleTheme"
>
<i :class="iconThemeClass"></i>
</AppButton>


<!-- Logout -->
<AppButton
class="btn btn-icon hover:bg-neutral-4"
class="btn btn-icon hover:bg-skin-neutral-5"
@click="$inertia.visit(route('adminAuth.logout'))"
>
<i class="ri-logout-circle-r-line"></i>
Expand All @@ -33,9 +49,9 @@
</template>

<script setup>
import { ref, onMounted } from 'vue'
import { ref, onMounted, onUnmounted } from 'vue'

defineProps({
const props = defineProps({
title: {
type: String,
default: ''
Expand All @@ -44,6 +60,7 @@ defineProps({

defineEmits(['sidebar:toggle'])

/* ========== THEME ========== */
const iconThemeClass = ref('ri-sun-line')

onMounted(() => {
Expand All @@ -67,4 +84,39 @@ const toggleTheme = () => {
iconThemeClass.value = 'ri-sun-line'
}
}

/* ========== FULLSCREEN ========== */
const isFullscreen = ref(false)
const iconFullscreenClass = ref('ri-fullscreen-line')

const toggleFullscreen = () => {
if (!isFullscreen.value) {
document.documentElement.requestFullscreen()
} else {
document.exitFullscreen()
}
}

const updateFullscreenState = () => {
isFullscreen.value = !!document.fullscreenElement
iconFullscreenClass.value = isFullscreen.value
? 'ri-fullscreen-exit-line'
: 'ri-fullscreen-line'

// Persist fullscreen state
localStorage.setItem('fullscreen', isFullscreen.value ? '1' : '0')
}

onMounted(() => {
document.addEventListener('fullscreenchange', updateFullscreenState)

// Restore fullscreen if previously enabled
if (localStorage.getItem('fullscreen') === '1') {
document.documentElement.requestFullscreen().catch(() => {})
}
})

onUnmounted(() => {
document.removeEventListener('fullscreenchange', updateFullscreenState)
})
</script>