Created
September 15, 2025 13:28
-
-
Save dedemenezes/5abecacf986db67d8cd19f37bb2a065a to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| diff --git a/app/controllers/noticias_controller.rb b/app/controllers/noticias_controller.rb | |
| index 0de505c..5bd0bb9 100644 | |
| --- a/app/controllers/noticias_controller.rb | |
| +++ b/app/controllers/noticias_controller.rb | |
| @@ -4,39 +4,55 @@ class NoticiasController < ApplicationController | |
| include InfiniteScrollable | |
| # TODO: Breakdown into smaller, | |
| # more readable methods | |
| + before_action :set_filter_options, only: :index | |
| + | |
| def index | |
| scope = Noticia.includes(:caderno).published | |
| + selected_filters = {} | |
| - if params[:search].present? | |
| - term = "%#{params[:search].downcase}%" | |
| - scope = scope.where( | |
| - "LOWER(titulo) LIKE ? OR LOWER(chamada) LIKE ?", term, term | |
| - ) | |
| + # if params[:search].present? | |
| + # term = "%#{params[:search].downcase}%" | |
| + # scope = scope.where( | |
| + # "LOWER(titulo) LIKE ? OR LOWER(chamada) LIKE ?", term, term | |
| + # ) | |
| + # end | |
| + | |
| + if params[:data] | |
| + selected_date = { filter_display: params[:data], filter_value: params[:data], filter_label: I18n.t("filter.date") } | |
| + # Noticia.where(data: (Date.parse("2025-06-19")..Date.today)) | |
| + date_range = (Date.parse(selected_date[:filter_value])..Date.today) | |
| + scope = scope.where(data: date_range) | |
| end | |
| - if params[:cadernos].present? | |
| - scope = scope.where(caderno: { permalink_pt: params[:cadernos] }) | |
| + if params[:caderno].present? | |
| + selected_caderno = @cadernos.find { |c| c["filter_value"] == params[:caderno] } | |
| + selected_filters[:caderno] = selected_caderno if selected_caderno | |
| + if I18n.locale == :pt | |
| + scope = scope.where(caderno: { permalink_pt: params[:caderno] }) | |
| + else | |
| + scope = scope.where(caderno: { permalink_en: params[:caderno] }) | |
| + end | |
| end | |
| + current_page = params[:page].to_i ||= 1 | |
| + | |
| ordered = scope.order(created: :desc) | |
| - @pagy, @noticias = pagy_infinite(ordered, params[:page]) | |
| + @pagy, @noticias = pagy_infinite(ordered, current_page) | |
| - cadernos = Caderno.for_filters | |
| # Only set selectedCadernos if the param exists | |
| - selected_filters = {} | |
| - if params[:cadernos].present? | |
| - selected_caderno = cadernos.find { |c| c["permalink_pt"] == params[:cadernos] } | |
| - selected_filters[:cadernos] = selected_caderno if selected_caderno | |
| - end | |
| + | |
| + # tabBaseUrl must be defined with path + selected filters | |
| render inertia: "Noticias/Index", props: { | |
| rootUrl: @root_url, | |
| - cadernos: cadernos, | |
| + tabBaseUrl: noticias_url, | |
| + dataLabel: I18n.t("filter.date"), | |
| + cadernos: @cadernos, | |
| breadcrumbs: breadcrumbs( | |
| [ "", @root_url ], | |
| [ "Notícias", "" ], | |
| ), | |
| - noticias: @noticias.as_json( | |
| + elements: @noticias.as_json( | |
| only: %i[id titulo permalink chamada imagem], | |
| methods: [ :caderno_nome, :display_date ] | |
| ), | |
| @@ -45,7 +61,10 @@ class NoticiasController < ApplicationController | |
| pages: @pagy.pages, | |
| last: @pagy.last | |
| }, | |
| - selectedFilters: selected_filters | |
| + current_filters: { | |
| + data: selected_date, | |
| + caderno: selected_caderno | |
| + } | |
| } | |
| end | |
| @@ -63,4 +82,15 @@ class NoticiasController < ApplicationController | |
| ) | |
| } | |
| end | |
| + | |
| + private | |
| + | |
| + def set_filter_options | |
| + # cadernos = Caderno.for_filters | |
| + if I18n.locale == :pt | |
| + @cadernos = Caderno.collection_without_edition_for(:permalink_pt, :nome_pt, :caderno) | |
| + else | |
| + @cadernos = Caderno.collection_without_edition_for(:permalink_en, :nome_en, :caderno) | |
| + end | |
| + end | |
| end | |
| diff --git a/app/frontend/components/features/filters/NoticiasFilterForm.vue b/app/frontend/components/features/filters/NoticiasFilterForm.vue | |
| index 6309089..b1ef3e4 100644 | |
| --- a/app/frontend/components/features/filters/NoticiasFilterForm.vue | |
| +++ b/app/frontend/components/features/filters/NoticiasFilterForm.vue | |
| @@ -1,39 +1,61 @@ | |
| <script setup> | |
| import { computed, defineAsyncComponent } from "vue"; | |
| import AccordionGroup from "@/components/AccordionGroup.vue"; | |
| +import DatePickerComponent from "@/components/ui/DatePickerComponent.vue"; | |
| const ComboboxComponent = defineAsyncComponent(() => import('@/components/ui/ComboboxComponent.vue')) | |
| const props = defineProps({ | |
| modelValue: { type: Object, required: true }, | |
| updateField: { type: Function, required: true }, | |
| + dataLabel: String, | |
| cadernos: { type: Array, default: () => [] }, // Article-specific prop | |
| }); | |
| // Transform cadernos prop for ComboboxComponent format | |
| const cadernosOptions = computed(() => { | |
| return props.cadernos.map(caderno => ({ | |
| - label: caderno.nome_pt, | |
| - value: caderno.permalink_pt, | |
| + label: caderno.filter_display, | |
| + value: caderno.filter_value, | |
| })); | |
| }); | |
| +const cadernoLabel = computed(() => props.cadernos[0].filter_label) | |
| -const getCadernoObjectFromPermalink = (permalink) => { | |
| - return props.cadernos.find(c => c.permalink_pt === permalink) || null; | |
| +const getCadernoObjectFromPermalink = (inputValue) => { | |
| + return props.cadernos.find(c => c.filter_value === inputValue) || null; | |
| }; | |
| + | |
| +const getDateFromInput = (value) => { | |
| + // debugger | |
| + return { "filter_display": value, "filter_value": value, filter_label: props.dataLabel } || null; | |
| +} | |
| </script> | |
| <template> | |
| + <AccordionGroup | |
| + :text="props.dataLabel" | |
| + :isOpen="!!props.modelValue.data?.filter_value" | |
| + > | |
| + <template v-slot:content> | |
| + <div class="w-full"> | |
| + <DatePickerComponent | |
| + :model-value="props.modelValue.data?.filter_value" | |
| + placeholder="Pick session date" | |
| + @update:model-value="updateField('data', getDateFromInput($event))" | |
| + /> | |
| + </div> | |
| + </template> | |
| + </AccordionGroup> | |
| <!-- Article-specific filter content --> | |
| <AccordionGroup | |
| - text="Cadernos" | |
| - :isOpen="!!props.modelValue.cadernos" | |
| + :text="cadernoLabel" | |
| + :isOpen="!!props.modelValue.caderno" | |
| > | |
| <template v-slot:content> | |
| <div class="pt-400 overflow-hidden w-full"> | |
| <ComboboxComponent | |
| :collection="cadernosOptions" | |
| - :modelValue="props.modelValue.cadernos?.permalink_pt || null" | |
| - @update:modelValue="(val) => props.updateField('cadernos', getCadernoObjectFromPermalink(val))" | |
| + :modelValue="props.modelValue.caderno?.filter_value || null" | |
| + @update:modelValue="(val) => props.updateField('caderno', getCadernoObjectFromPermalink(val))" | |
| /> | |
| </div> | |
| </template> | |
| diff --git a/app/frontend/components/features/filters/composables/usaSearchFilter.js b/app/frontend/components/features/filters/composables/usaSearchFilter.js | |
| new file mode 100644 | |
| index 0000000..c51cbeb | |
| --- /dev/null | |
| +++ b/app/frontend/components/features/filters/composables/usaSearchFilter.js | |
| @@ -0,0 +1,147 @@ | |
| +import { ref, watch } from "vue"; | |
| +import { router } from "@inertiajs/vue3" | |
| +import { extractFilterValues } from "@/lib/filterUtils"; | |
| + | |
| +/** | |
| + * FILTER STATE MANAGEMENT - SINGLE SOURCE OF TRUTH | |
| + */ | |
| +export function useSearchFilter(props, filtersFromController = {}) { | |
| + /** | |
| + * Initialize empty filter structure | |
| + */ | |
| + console.log(filtersFromController); | |
| + | |
| + const initializeFilters = () => (filtersFromController) | |
| + | |
| + /** | |
| + * Override empty filters with current filter values from controller | |
| + */ | |
| + const overrideFiltersValues = () => { | |
| + return { ...initializeFilters(), ...props.current_filters } | |
| + } | |
| + | |
| + // Props that will change when updating filters | |
| + const propsToUpdate = ['elements', 'pagy', 'current_filters', 'has_active_filters', 'menuTabs'] | |
| + | |
| + // Main filter state - this is passed to SearchFilter via ResponsiveFilterMenu | |
| + const filters = ref(overrideFiltersValues()) | |
| + | |
| + // Watch for prop changes from server (shouldn't happen often but good to have) | |
| + watch(() => props.current_filters, (newFilters) => { | |
| + console.log('ProgramPage: current_filters changed from server:', newFilters); | |
| + filters.value = overrideFiltersValues() | |
| + }, { immediate: true, deep: true }) | |
| + | |
| + // ============================================================================ | |
| + // FILTER OPERATIONS - CALLED BY SEARCHFILTER VIA EVENTS | |
| + // ============================================================================ | |
| + | |
| + /** | |
| + * Called when SearchFilter emits filtersApplied | |
| + * This makes the actual router call to update the page | |
| + */ | |
| + | |
| + const filterSearch = (filtersFromSearchFilter) => { | |
| + console.log('ProgramPage: Applying filters from SearchFilter:', filtersFromSearchFilter); | |
| + | |
| + // Build query params by rejecting any filter: null or "" | |
| + const cleanedFilters = extractFilterValues(filtersFromSearchFilter || filters.value) | |
| + debugger | |
| + // MAke search request and says which prop to update | |
| + router.get(props.tabBaseUrl, cleanedFilters, { | |
| + preserveScroll: true, | |
| + only: propsToUpdate | |
| + }) | |
| + }; | |
| + | |
| + /** | |
| + * Called when user clicks a filter tag to remove it | |
| + * This updates the filters state and makes a router call | |
| + */ | |
| + | |
| + const removeQuery = (filterToRemove) => { | |
| + debugger | |
| + // Clear the specific filter | |
| + // TODO: REFAC TIP ADD FILTER_KEY FROM CONTROLLER | |
| + // IT SHOULD MAKE AGNOSTIC | |
| + // Map filter labels to filter keys (could be improved with a filter_key from controller) | |
| + const filterKeyMap = { | |
| + 'Time': 'sessao', | |
| + 'Sessão': 'sessao', | |
| + 'Showcase': 'mostra', | |
| + 'Mostra': 'mostra', | |
| + 'Cinema': 'cinema', | |
| + 'Genre': 'genero', | |
| + 'Genero': 'genero', | |
| + 'Country': 'pais', | |
| + 'País': 'pais', | |
| + 'Director': 'direcao', | |
| + 'Direção': 'direcao', | |
| + 'Cast': 'elenco', | |
| + 'Elenco': 'elenco', | |
| + "Caderno": "caderno", | |
| + "Category": "caderno", | |
| + "Data de publicação": "data", | |
| + "Publication date": "data" | |
| + }; | |
| + const filterKey = filterKeyMap[filterToRemove.filter_label]; | |
| + if (filterKey) { | |
| + // Updated local filter state | |
| + filters.value[filterKey] = null; | |
| + | |
| + // Build new URL params from remaining filters | |
| + const newParams = new URLSearchParams(); | |
| + Object.entries(filters.value).forEach(([key, value]) => { | |
| + if (value !== null && value !== undefined && value !== "" && value?.filter_value) { | |
| + newParams.set(key, value.filter_value); | |
| + } | |
| + }); | |
| + | |
| + router.get(props.tabBaseUrl, newParams, { | |
| + preserveScroll: true, | |
| + only: propsToUpdate | |
| + }) | |
| + } else { | |
| + console.warn('ProgramPage: Unknown filter label:', filterToRemove.filter_label); | |
| + } | |
| + } | |
| + | |
| + /** | |
| + * Called when SearchFilter emits filtersCleared | |
| + * This clears all filters and optionally makes a router call | |
| + */ | |
| + const clearSearchQuery = () => { | |
| + console.log('ProgramPage: Clearing all filters'); | |
| + | |
| + const clearedFilters = initializeFilters() | |
| + filters.value = clearedFilters | |
| + // Only make router call if there were actually filters applied | |
| + const hasFiltersApplied = Object.entries(props.current_filters).some(([key, value]) => value != null) | |
| + if (hasFiltersApplied) { | |
| + router.get(props.tabBaseUrl, {}, { | |
| + preserveState: true, | |
| + preserveScroll: true, | |
| + only: propsToUpdate | |
| + }); | |
| + } | |
| + }; | |
| + | |
| + /** | |
| + * Called when user clears search bar directly (if needed) | |
| + */ | |
| + const handleClear = () => { | |
| + console.log('ProgramPage: Clearing search query'); | |
| + filters.value.query = null; | |
| + filterSearch(filters.value); | |
| + }; | |
| + | |
| + return { | |
| + initializeFilters, | |
| + overrideFiltersValues, | |
| + filters, | |
| + filterSearch, | |
| + removeQuery, | |
| + clearSearchQuery, | |
| + handleClear | |
| + } | |
| +} | |
| diff --git a/app/frontend/components/ui/DatePickerComponent.vue b/app/frontend/components/ui/DatePickerComponent.vue | |
| new file mode 100644 | |
| index 0000000..5bb32b3 | |
| --- /dev/null | |
| +++ b/app/frontend/components/ui/DatePickerComponent.vue | |
| @@ -0,0 +1,69 @@ | |
| +<script setup> | |
| +import { | |
| + DateFormatter, | |
| + getLocalTimeZone, | |
| + parseDate, | |
| +} from "@internationalized/date" | |
| +import { CalendarIcon } from "lucide-vue-next" | |
| + | |
| +import { ref, watch } from "vue" | |
| +import { cn } from "@/lib/utils" | |
| +import { Button } from "@/components/ui/button" | |
| +import { Calendar } from "@/components/ui/calendar" | |
| +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" | |
| + | |
| +const props = defineProps({ | |
| + modelValue: String, // ISO format date, e.g., "2025-10-09" | |
| +}) | |
| +const emit = defineEmits(["update:modelValue"]) | |
| + | |
| +// Create local DateValue from string | |
| +const value = ref(props.modelValue ? parseDate(props.modelValue) : null) | |
| + | |
| +// Sync from external modelValue -> internal DateValue | |
| +watch( | |
| + () => props.modelValue, | |
| + (newVal) => { | |
| + value.value = newVal ? parseDate(newVal) : null | |
| + } | |
| +) | |
| + | |
| +// Emit ISO string when user picks a date | |
| +watch(value, (newVal) => { | |
| + if (newVal) { | |
| + const iso = newVal.toDate(getLocalTimeZone()).toISOString().split("T")[0] | |
| + emit("update:modelValue", iso) | |
| + } else { | |
| + emit("update:modelValue", null) | |
| + } | |
| +}) | |
| + | |
| +// For display in the button | |
| +const df = new DateFormatter("en-US", { | |
| + dateStyle: "long", | |
| +}) | |
| +</script> | |
| + | |
| +<template> | |
| + <Popover> | |
| + <PopoverTrigger as-child> | |
| + <Button | |
| + variant="outline" | |
| + :class="cn( | |
| + 'w-full justify-start text-left font-normal', | |
| + !value && 'text-muted-foreground', | |
| + )" | |
| + > | |
| + <CalendarIcon class="mr-2 h-4 w-4" /> | |
| + {{ | |
| + value | |
| + ? df.format(value.toDate(getLocalTimeZone())) | |
| + : "Pick a date" | |
| + }} | |
| + </Button> | |
| + </PopoverTrigger> | |
| + <PopoverContent class="w-auto p-0"> | |
| + <Calendar v-model="value" initial-focus /> | |
| + </PopoverContent> | |
| + </Popover> | |
| +</template> | |
| diff --git a/app/frontend/components/ui/calendar/Calendar.vue b/app/frontend/components/ui/calendar/Calendar.vue | |
| new file mode 100644 | |
| index 0000000..9b92333 | |
| --- /dev/null | |
| +++ b/app/frontend/components/ui/calendar/Calendar.vue | |
| @@ -0,0 +1,98 @@ | |
| +<script setup> | |
| +import { reactiveOmit } from "@vueuse/core"; | |
| +import { CalendarRoot, useForwardPropsEmits } from "reka-ui"; | |
| +import { cn } from "@/lib/utils"; | |
| +import { | |
| + CalendarCell, | |
| + CalendarCellTrigger, | |
| + CalendarGrid, | |
| + CalendarGridBody, | |
| + CalendarGridHead, | |
| + CalendarGridRow, | |
| + CalendarHeadCell, | |
| + CalendarHeader, | |
| + CalendarHeading, | |
| + CalendarNextButton, | |
| + CalendarPrevButton, | |
| +} from "."; | |
| + | |
| +const props = defineProps({ | |
| + defaultValue: { type: null, required: false }, | |
| + defaultPlaceholder: { type: null, required: false }, | |
| + placeholder: { type: null, required: false }, | |
| + pagedNavigation: { type: Boolean, required: false }, | |
| + preventDeselect: { type: Boolean, required: false }, | |
| + weekStartsOn: { type: Number, required: false }, | |
| + weekdayFormat: { type: String, required: false }, | |
| + calendarLabel: { type: String, required: false }, | |
| + fixedWeeks: { type: Boolean, required: false }, | |
| + maxValue: { type: null, required: false }, | |
| + minValue: { type: null, required: false }, | |
| + locale: { type: String, required: false }, | |
| + numberOfMonths: { type: Number, required: false }, | |
| + disabled: { type: Boolean, required: false }, | |
| + readonly: { type: Boolean, required: false }, | |
| + initialFocus: { type: Boolean, required: false }, | |
| + isDateDisabled: { type: Function, required: false }, | |
| + isDateUnavailable: { type: Function, required: false }, | |
| + dir: { type: String, required: false }, | |
| + nextPage: { type: Function, required: false }, | |
| + prevPage: { type: Function, required: false }, | |
| + modelValue: { type: null, required: false }, | |
| + multiple: { type: Boolean, required: false }, | |
| + disableDaysOutsideCurrentView: { type: Boolean, required: false }, | |
| + asChild: { type: Boolean, required: false }, | |
| + as: { type: null, required: false }, | |
| + class: { type: null, required: false }, | |
| +}); | |
| +const emits = defineEmits(["update:modelValue", "update:placeholder"]); | |
| + | |
| +const delegatedProps = reactiveOmit(props, "class"); | |
| + | |
| +const forwarded = useForwardPropsEmits(delegatedProps, emits); | |
| +</script> | |
| + | |
| +<template> | |
| + <CalendarRoot | |
| + v-slot="{ grid, weekDays }" | |
| + data-slot="calendar" | |
| + :class="cn('p-3', props.class)" | |
| + v-bind="forwarded" | |
| + > | |
| + <CalendarHeader> | |
| + <CalendarHeading /> | |
| + | |
| + <div class="flex items-center gap-1"> | |
| + <CalendarPrevButton /> | |
| + <CalendarNextButton /> | |
| + </div> | |
| + </CalendarHeader> | |
| + | |
| + <div class="flex flex-col gap-y-4 mt-4 sm:flex-row sm:gap-x-4 sm:gap-y-0"> | |
| + <CalendarGrid v-for="month in grid" :key="month.value.toString()"> | |
| + <CalendarGridHead> | |
| + <CalendarGridRow> | |
| + <CalendarHeadCell v-for="day in weekDays" :key="day"> | |
| + {{ day }} | |
| + </CalendarHeadCell> | |
| + </CalendarGridRow> | |
| + </CalendarGridHead> | |
| + <CalendarGridBody> | |
| + <CalendarGridRow | |
| + v-for="(weekDates, index) in month.rows" | |
| + :key="`weekDate-${index}`" | |
| + class="mt-2 w-full" | |
| + > | |
| + <CalendarCell | |
| + v-for="weekDate in weekDates" | |
| + :key="weekDate.toString()" | |
| + :date="weekDate" | |
| + > | |
| + <CalendarCellTrigger :day="weekDate" :month="month.value" /> | |
| + </CalendarCell> | |
| + </CalendarGridRow> | |
| + </CalendarGridBody> | |
| + </CalendarGrid> | |
| + </div> | |
| + </CalendarRoot> | |
| +</template> | |
| diff --git a/app/frontend/components/ui/calendar/CalendarCell.vue b/app/frontend/components/ui/calendar/CalendarCell.vue | |
| new file mode 100644 | |
| index 0000000..7a720dd | |
| --- /dev/null | |
| +++ b/app/frontend/components/ui/calendar/CalendarCell.vue | |
| @@ -0,0 +1,31 @@ | |
| +<script setup> | |
| +import { reactiveOmit } from "@vueuse/core"; | |
| +import { CalendarCell, useForwardProps } from "reka-ui"; | |
| +import { cn } from "@/lib/utils"; | |
| + | |
| +const props = defineProps({ | |
| + date: { type: null, required: true }, | |
| + asChild: { type: Boolean, required: false }, | |
| + as: { type: null, required: false }, | |
| + class: { type: null, required: false }, | |
| +}); | |
| + | |
| +const delegatedProps = reactiveOmit(props, "class"); | |
| + | |
| +const forwardedProps = useForwardProps(delegatedProps); | |
| +</script> | |
| + | |
| +<template> | |
| + <CalendarCell | |
| + data-slot="calendar-cell" | |
| + :class=" | |
| + cn( | |
| + 'relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([data-selected])]:rounded-md [&:has([data-selected])]:bg-accent', | |
| + props.class, | |
| + ) | |
| + " | |
| + v-bind="forwardedProps" | |
| + > | |
| + <slot /> | |
| + </CalendarCell> | |
| +</template> | |
| diff --git a/app/frontend/components/ui/calendar/CalendarCellTrigger.vue b/app/frontend/components/ui/calendar/CalendarCellTrigger.vue | |
| new file mode 100644 | |
| index 0000000..b8f2462 | |
| --- /dev/null | |
| +++ b/app/frontend/components/ui/calendar/CalendarCellTrigger.vue | |
| @@ -0,0 +1,43 @@ | |
| +<script setup> | |
| +import { reactiveOmit } from "@vueuse/core"; | |
| +import { CalendarCellTrigger, useForwardProps } from "reka-ui"; | |
| +import { cn } from "@/lib/utils"; | |
| +import { buttonVariants } from '@/components/ui/button'; | |
| + | |
| +const props = defineProps({ | |
| + day: { type: null, required: true }, | |
| + month: { type: null, required: true }, | |
| + asChild: { type: Boolean, required: false }, | |
| + as: { type: null, required: false, default: "button" }, | |
| + class: { type: null, required: false }, | |
| +}); | |
| + | |
| +const delegatedProps = reactiveOmit(props, "class"); | |
| + | |
| +const forwardedProps = useForwardProps(delegatedProps); | |
| +</script> | |
| + | |
| +<template> | |
| + <CalendarCellTrigger | |
| + data-slot="calendar-cell-trigger" | |
| + :class=" | |
| + cn( | |
| + buttonVariants({ variant: 'ghost' }), | |
| + 'size-8 p-0 font-normal aria-selected:opacity-100 cursor-default', | |
| + '[&[data-today]:not([data-selected])]:bg-accent [&[data-today]:not([data-selected])]:text-accent-foreground', | |
| + // Selected | |
| + 'data-[selected]:bg-primary data-[selected]:text-primary-foreground data-[selected]:opacity-100 data-[selected]:hover:bg-primary data-[selected]:hover:text-primary-foreground data-[selected]:focus:bg-primary data-[selected]:focus:text-primary-foreground', | |
| + // Disabled | |
| + 'data-[disabled]:text-muted-foreground data-[disabled]:opacity-50', | |
| + // Unavailable | |
| + 'data-[unavailable]:text-destructive-foreground data-[unavailable]:line-through', | |
| + // Outside months | |
| + 'data-[outside-view]:text-muted-foreground', | |
| + props.class, | |
| + ) | |
| + " | |
| + v-bind="forwardedProps" | |
| + > | |
| + <slot /> | |
| + </CalendarCellTrigger> | |
| +</template> | |
| diff --git a/app/frontend/components/ui/calendar/CalendarGrid.vue b/app/frontend/components/ui/calendar/CalendarGrid.vue | |
| new file mode 100644 | |
| index 0000000..52bef72 | |
| --- /dev/null | |
| +++ b/app/frontend/components/ui/calendar/CalendarGrid.vue | |
| @@ -0,0 +1,25 @@ | |
| +<script setup> | |
| +import { reactiveOmit } from "@vueuse/core"; | |
| +import { CalendarGrid, useForwardProps } from "reka-ui"; | |
| +import { cn } from "@/lib/utils"; | |
| + | |
| +const props = defineProps({ | |
| + asChild: { type: Boolean, required: false }, | |
| + as: { type: null, required: false }, | |
| + class: { type: null, required: false }, | |
| +}); | |
| + | |
| +const delegatedProps = reactiveOmit(props, "class"); | |
| + | |
| +const forwardedProps = useForwardProps(delegatedProps); | |
| +</script> | |
| + | |
| +<template> | |
| + <CalendarGrid | |
| + data-slot="calendar-grid" | |
| + :class="cn('w-full border-collapse space-x-1', props.class)" | |
| + v-bind="forwardedProps" | |
| + > | |
| + <slot /> | |
| + </CalendarGrid> | |
| +</template> | |
| diff --git a/app/frontend/components/ui/calendar/CalendarGridBody.vue b/app/frontend/components/ui/calendar/CalendarGridBody.vue | |
| new file mode 100644 | |
| index 0000000..131bc5a | |
| --- /dev/null | |
| +++ b/app/frontend/components/ui/calendar/CalendarGridBody.vue | |
| @@ -0,0 +1,14 @@ | |
| +<script setup> | |
| +import { CalendarGridBody } from "reka-ui"; | |
| + | |
| +const props = defineProps({ | |
| + asChild: { type: Boolean, required: false }, | |
| + as: { type: null, required: false }, | |
| +}); | |
| +</script> | |
| + | |
| +<template> | |
| + <CalendarGridBody data-slot="calendar-grid-body" v-bind="props"> | |
| + <slot /> | |
| + </CalendarGridBody> | |
| +</template> | |
| diff --git a/app/frontend/components/ui/calendar/CalendarGridHead.vue b/app/frontend/components/ui/calendar/CalendarGridHead.vue | |
| new file mode 100644 | |
| index 0000000..1d8e684 | |
| --- /dev/null | |
| +++ b/app/frontend/components/ui/calendar/CalendarGridHead.vue | |
| @@ -0,0 +1,15 @@ | |
| +<script setup> | |
| +import { CalendarGridHead } from "reka-ui"; | |
| + | |
| +const props = defineProps({ | |
| + asChild: { type: Boolean, required: false }, | |
| + as: { type: null, required: false }, | |
| + class: { type: null, required: false }, | |
| +}); | |
| +</script> | |
| + | |
| +<template> | |
| + <CalendarGridHead data-slot="calendar-grid-head" v-bind="props"> | |
| + <slot /> | |
| + </CalendarGridHead> | |
| +</template> | |
| diff --git a/app/frontend/components/ui/calendar/CalendarGridRow.vue b/app/frontend/components/ui/calendar/CalendarGridRow.vue | |
| new file mode 100644 | |
| index 0000000..9ad8c5b | |
| --- /dev/null | |
| +++ b/app/frontend/components/ui/calendar/CalendarGridRow.vue | |
| @@ -0,0 +1,25 @@ | |
| +<script setup> | |
| +import { reactiveOmit } from "@vueuse/core"; | |
| +import { CalendarGridRow, useForwardProps } from "reka-ui"; | |
| +import { cn } from "@/lib/utils"; | |
| + | |
| +const props = defineProps({ | |
| + asChild: { type: Boolean, required: false }, | |
| + as: { type: null, required: false }, | |
| + class: { type: null, required: false }, | |
| +}); | |
| + | |
| +const delegatedProps = reactiveOmit(props, "class"); | |
| + | |
| +const forwardedProps = useForwardProps(delegatedProps); | |
| +</script> | |
| + | |
| +<template> | |
| + <CalendarGridRow | |
| + data-slot="calendar-grid-row" | |
| + :class="cn('flex', props.class)" | |
| + v-bind="forwardedProps" | |
| + > | |
| + <slot /> | |
| + </CalendarGridRow> | |
| +</template> | |
| diff --git a/app/frontend/components/ui/calendar/CalendarHeadCell.vue b/app/frontend/components/ui/calendar/CalendarHeadCell.vue | |
| new file mode 100644 | |
| index 0000000..451bfa2 | |
| --- /dev/null | |
| +++ b/app/frontend/components/ui/calendar/CalendarHeadCell.vue | |
| @@ -0,0 +1,30 @@ | |
| +<script setup> | |
| +import { reactiveOmit } from "@vueuse/core"; | |
| +import { CalendarHeadCell, useForwardProps } from "reka-ui"; | |
| +import { cn } from "@/lib/utils"; | |
| + | |
| +const props = defineProps({ | |
| + asChild: { type: Boolean, required: false }, | |
| + as: { type: null, required: false }, | |
| + class: { type: null, required: false }, | |
| +}); | |
| + | |
| +const delegatedProps = reactiveOmit(props, "class"); | |
| + | |
| +const forwardedProps = useForwardProps(delegatedProps); | |
| +</script> | |
| + | |
| +<template> | |
| + <CalendarHeadCell | |
| + data-slot="calendar-head-cell" | |
| + :class=" | |
| + cn( | |
| + 'text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]', | |
| + props.class, | |
| + ) | |
| + " | |
| + v-bind="forwardedProps" | |
| + > | |
| + <slot /> | |
| + </CalendarHeadCell> | |
| +</template> | |
| diff --git a/app/frontend/components/ui/calendar/CalendarHeader.vue b/app/frontend/components/ui/calendar/CalendarHeader.vue | |
| new file mode 100644 | |
| index 0000000..8f2fd1c | |
| --- /dev/null | |
| +++ b/app/frontend/components/ui/calendar/CalendarHeader.vue | |
| @@ -0,0 +1,27 @@ | |
| +<script setup> | |
| +import { reactiveOmit } from "@vueuse/core"; | |
| +import { CalendarHeader, useForwardProps } from "reka-ui"; | |
| +import { cn } from "@/lib/utils"; | |
| + | |
| +const props = defineProps({ | |
| + asChild: { type: Boolean, required: false }, | |
| + as: { type: null, required: false }, | |
| + class: { type: null, required: false }, | |
| +}); | |
| + | |
| +const delegatedProps = reactiveOmit(props, "class"); | |
| + | |
| +const forwardedProps = useForwardProps(delegatedProps); | |
| +</script> | |
| + | |
| +<template> | |
| + <CalendarHeader | |
| + data-slot="calendar-header" | |
| + :class=" | |
| + cn('flex justify-center pt-1 relative items-center w-full', props.class) | |
| + " | |
| + v-bind="forwardedProps" | |
| + > | |
| + <slot /> | |
| + </CalendarHeader> | |
| +</template> | |
| diff --git a/app/frontend/components/ui/calendar/CalendarHeading.vue b/app/frontend/components/ui/calendar/CalendarHeading.vue | |
| new file mode 100644 | |
| index 0000000..7e6ef9a | |
| --- /dev/null | |
| +++ b/app/frontend/components/ui/calendar/CalendarHeading.vue | |
| @@ -0,0 +1,30 @@ | |
| +<script setup> | |
| +import { reactiveOmit } from "@vueuse/core"; | |
| +import { CalendarHeading, useForwardProps } from "reka-ui"; | |
| +import { cn } from "@/lib/utils"; | |
| + | |
| +const props = defineProps({ | |
| + asChild: { type: Boolean, required: false }, | |
| + as: { type: null, required: false }, | |
| + class: { type: null, required: false }, | |
| +}); | |
| + | |
| +defineSlots(); | |
| + | |
| +const delegatedProps = reactiveOmit(props, "class"); | |
| + | |
| +const forwardedProps = useForwardProps(delegatedProps); | |
| +</script> | |
| + | |
| +<template> | |
| + <CalendarHeading | |
| + v-slot="{ headingValue }" | |
| + data-slot="calendar-heading" | |
| + :class="cn('text-sm font-medium', props.class)" | |
| + v-bind="forwardedProps" | |
| + > | |
| + <slot :heading-value> | |
| + {{ headingValue }} | |
| + </slot> | |
| + </CalendarHeading> | |
| +</template> | |
| diff --git a/app/frontend/components/ui/calendar/CalendarNextButton.vue b/app/frontend/components/ui/calendar/CalendarNextButton.vue | |
| new file mode 100644 | |
| index 0000000..fa6d04e | |
| --- /dev/null | |
| +++ b/app/frontend/components/ui/calendar/CalendarNextButton.vue | |
| @@ -0,0 +1,37 @@ | |
| +<script setup> | |
| +import { reactiveOmit } from "@vueuse/core"; | |
| +import { ChevronRight } from "lucide-vue-next"; | |
| +import { CalendarNext, useForwardProps } from "reka-ui"; | |
| +import { cn } from "@/lib/utils"; | |
| +import { buttonVariants } from '@/components/ui/button'; | |
| + | |
| +const props = defineProps({ | |
| + nextPage: { type: Function, required: false }, | |
| + asChild: { type: Boolean, required: false }, | |
| + as: { type: null, required: false }, | |
| + class: { type: null, required: false }, | |
| +}); | |
| + | |
| +const delegatedProps = reactiveOmit(props, "class"); | |
| + | |
| +const forwardedProps = useForwardProps(delegatedProps); | |
| +</script> | |
| + | |
| +<template> | |
| + <CalendarNext | |
| + data-slot="calendar-next-button" | |
| + :class=" | |
| + cn( | |
| + buttonVariants({ variant: 'outline' }), | |
| + 'absolute right-1', | |
| + 'size-7 bg-transparent p-0 opacity-50 hover:opacity-100', | |
| + props.class, | |
| + ) | |
| + " | |
| + v-bind="forwardedProps" | |
| + > | |
| + <slot> | |
| + <ChevronRight class="size-4" /> | |
| + </slot> | |
| + </CalendarNext> | |
| +</template> | |
| diff --git a/app/frontend/components/ui/calendar/CalendarPrevButton.vue b/app/frontend/components/ui/calendar/CalendarPrevButton.vue | |
| new file mode 100644 | |
| index 0000000..9c115fb | |
| --- /dev/null | |
| +++ b/app/frontend/components/ui/calendar/CalendarPrevButton.vue | |
| @@ -0,0 +1,37 @@ | |
| +<script setup> | |
| +import { reactiveOmit } from "@vueuse/core"; | |
| +import { ChevronLeft } from "lucide-vue-next"; | |
| +import { CalendarPrev, useForwardProps } from "reka-ui"; | |
| +import { cn } from "@/lib/utils"; | |
| +import { buttonVariants } from '@/components/ui/button'; | |
| + | |
| +const props = defineProps({ | |
| + prevPage: { type: Function, required: false }, | |
| + asChild: { type: Boolean, required: false }, | |
| + as: { type: null, required: false }, | |
| + class: { type: null, required: false }, | |
| +}); | |
| + | |
| +const delegatedProps = reactiveOmit(props, "class"); | |
| + | |
| +const forwardedProps = useForwardProps(delegatedProps); | |
| +</script> | |
| + | |
| +<template> | |
| + <CalendarPrev | |
| + data-slot="calendar-prev-button" | |
| + :class=" | |
| + cn( | |
| + buttonVariants({ variant: 'outline' }), | |
| + 'absolute left-1', | |
| + 'size-7 bg-transparent p-0 opacity-50 hover:opacity-100', | |
| + props.class, | |
| + ) | |
| + " | |
| + v-bind="forwardedProps" | |
| + > | |
| + <slot> | |
| + <ChevronLeft class="size-4" /> | |
| + </slot> | |
| + </CalendarPrev> | |
| +</template> | |
| diff --git a/app/frontend/components/ui/calendar/index.js b/app/frontend/components/ui/calendar/index.js | |
| new file mode 100644 | |
| index 0000000..f5e9b7e | |
| --- /dev/null | |
| +++ b/app/frontend/components/ui/calendar/index.js | |
| @@ -0,0 +1,12 @@ | |
| +export { default as Calendar } from "./Calendar.vue"; | |
| +export { default as CalendarCell } from "./CalendarCell.vue"; | |
| +export { default as CalendarCellTrigger } from "./CalendarCellTrigger.vue"; | |
| +export { default as CalendarGrid } from "./CalendarGrid.vue"; | |
| +export { default as CalendarGridBody } from "./CalendarGridBody.vue"; | |
| +export { default as CalendarGridHead } from "./CalendarGridHead.vue"; | |
| +export { default as CalendarGridRow } from "./CalendarGridRow.vue"; | |
| +export { default as CalendarHeadCell } from "./CalendarHeadCell.vue"; | |
| +export { default as CalendarHeader } from "./CalendarHeader.vue"; | |
| +export { default as CalendarHeading } from "./CalendarHeading.vue"; | |
| +export { default as CalendarNextButton } from "./CalendarNextButton.vue"; | |
| +export { default as CalendarPrevButton } from "./CalendarPrevButton.vue"; | |
| diff --git a/app/frontend/pages/Noticias/Index.vue b/app/frontend/pages/Noticias/Index.vue | |
| index 959e3a0..184abab 100644 | |
| --- a/app/frontend/pages/Noticias/Index.vue | |
| +++ b/app/frontend/pages/Noticias/Index.vue | |
| @@ -4,9 +4,8 @@ | |
| // 1. 📦 Node.js built-ins (if used) | |
| // 2. 🔌 External packages (npm, libraries) | |
| -import { Head, router } from "@inertiajs/vue3"; | |
| +import { Head } from "@inertiajs/vue3"; | |
| // 3. 🧠 Internal libs/helpers/utilities | |
| -import { applyFiltersToQuery } from "@/lib/applyFiltersToQuery"; | |
| // 4. 🧩 Global components / shared UI | |
| import TwContainer from "@/components/layout/TwContainer.vue"; | |
| @@ -15,156 +14,111 @@ import TagFilter from "@/components/common/tags/TagFilter.vue"; | |
| // 5. 🧱 Feature-specific components | |
| import MobileTrigger from "@/components/features/filters/MobileTrigger.vue"; | |
| -import MobileFilterMenu from "@/components/features/filters/MobileFilterMenu.vue"; | |
| +import ResponsiveFilterMenu from "@/components/features/filters/ResponsiveFilterMenu.vue"; | |
| import NoticiasFilterForm from "@/components/features/filters/NoticiasFilterForm.vue"; | |
| -import PagyPagination from "../PagyPagination.vue"; // relative path (usually for siblings only) | |
| +import InfiniteScrollLayout from "@/components/layout/InfiniteScrollLayout.vue"; | |
| +import IndexArticleCard from "@/components/common/cards/IndexArticleCard.vue"; | |
| import { useMobileTrigger } from "@/components/features/filters/composables/useMobileTrigger"; | |
| +import { useSearchFilter } from "@/components/features/filters/composables/usaSearchFilter"; | |
| const props = defineProps({ | |
| breadcrumbs: { type: Array, default: () => []}, | |
| - noticias: { type: Array, default: () => []}, | |
| + tabBaseUrl: { type: String, default: "MISSING" }, | |
| + dataLabel: { type: String, required: true }, | |
| + elements: { type: Array, default: () => []}, | |
| cadernos: { type: Array, required: true }, | |
| - selectedFilters: { type: Object, default: () => {} }, | |
| + current_filters: { type: Object, default: () => {} }, | |
| pagy: { type: Object, required: true } | |
| }) | |
| const { isFilterMenuOpen, openMenu, closeMenu } = useMobileTrigger(); | |
| +const { filters, filterSearch, removeQuery } = useSearchFilter(props) | |
| +console.log(filters); | |
| -const filterSearch = (filtersFromChild) => { | |
| - // console.log("Filters from MobileFilterMenu:", filtersFromChild); | |
| - | |
| - const params = applyFiltersToQuery(filtersFromChild) | |
| - const query = params.toString() | |
| - router.visit(`/noticias?${query}`, { | |
| - preserveScroll: true, | |
| - preserveState: true, | |
| - only: ["noticias", "selectedFilters"], | |
| - }); | |
| -}; | |
| - | |
| -const removeQuery = (item) => { | |
| - const newFilters = { ...props.selectedFilters }; | |
| - | |
| - for (const [key, value] of Object.entries(newFilters)) { | |
| - if (value?.permalink_pt === item) { | |
| - newFilters[key] = null; | |
| - } | |
| - } | |
| - | |
| - const params = applyFiltersToQuery(newFilters) | |
| - const query = params.toString() | |
| - | |
| - router.visit(`/noticias?${query}`, { | |
| - preserveScroll: true, | |
| - preserveState: false, // full reset | |
| - only: ["noticias", "selectedFilters"], | |
| - }); | |
| -} | |
| - | |
| -const clearSearchQuery = () => { | |
| - const currentUrl = new URL(window.location.href); | |
| - const hasUrlParams = currentUrl.searchParams.toString() !== ""; | |
| - | |
| - if (!hasUrlParams) { | |
| - // console.log("Already clean - no request needed"); | |
| - return; | |
| - } | |
| - | |
| - router.visit(`/noticias`, { | |
| - preserveScroll: true, | |
| - preserveState: true, | |
| - only: [ "noticias", "selectedFilters" ] | |
| - }) | |
| -} | |
| </script> | |
| <template> | |
| + <!-- DEBUGGER --> | |
| + <div v-if="false" class="bg-amarelo-200 p-2 mb-4 text-md text-neutrals-900"> | |
| + <p><strong>Noticias.Index current_filters:</strong> {{ current_filters }}</p> | |
| + </div> | |
| <Head> | |
| <title>Noticias - Festival do Rio</title> | |
| <!-- TODO: Add metatags into all pages! --> | |
| </Head> | |
| + <!-- <Breadcrumb :crumbs="props.breadcrumbs"/> --> | |
| <TwContainer> | |
| - <Breadcrumb :crumbs="props.breadcrumbs"/> | |
| - <!-- search & ordering --> | |
| - <div | |
| - class="filter flex items-center justify-between md:gap-800 lg:gap-1200 py-300" | |
| - > | |
| + <Breadcrumb :crumbs="props.breadcrumbs" /> | |
| + </TwContainer> | |
| + <TwContainer class="relative"> | |
| + <div class="filter flex lg:hidden items-center justify-end py-300 bg-white"> | |
| <MobileTrigger @open-menu="openMenu" /> | |
| - | |
| - <!-- Ordering --> | |
| - <div class="flex items-center gap-300"> | |
| - <span class="text-body-strong-sm uppercase text-secondary-gray" | |
| - >A - Z</span | |
| - > | |
| - <img | |
| - src="@assets/icons/divisor.svg" | |
| - alt="divisor" | |
| - height="16px" | |
| - width="1px" | |
| - /> | |
| - <span class="text-body-strong-sm uppercase text-neutrals-900"> | |
| - <!-- {{$t("filter_by.date")}} --> | |
| - por Data | |
| - </span> | |
| - </div> | |
| - <!-- Ordering --> | |
| </div> | |
| - <!-- search & ordering --> | |
| - | |
| - <!-- mobile filter content --> | |
| - <transition name="slide-left"> | |
| - <MobileFilterMenu | |
| - :is-open="isFilterMenuOpen" | |
| - :initialFilters="props.selectedFilters" | |
| - @filtersApplied="filterSearch" | |
| - @filtersCleared="clearSearchQuery" | |
| - @close-filter-menu="closeMenu" | |
| - > | |
| - <template #filters="{ modelValue, updateField }"> | |
| - <!-- class="py-600" --> | |
| - <NoticiasFilterForm | |
| - :model-value="modelValue" | |
| - :update-field="updateField" | |
| - :cadernos="props.cadernos" | |
| - /> | |
| - </template> | |
| - </MobileFilterMenu> | |
| - <!-- input --> | |
| - </transition> | |
| - <!-- mobile filter content --> | |
| - <!-- filtered tag --> | |
| + <!-- MOBILE TAG FILTER --> | |
| <div | |
| - class="flex gap-300 pt-200 pb-300" | |
| - v-show="Object.values(props.selectedFilters).some((item) => item !== null)" | |
| + class="flex lg:hidden gap-300 pt-200 pb-300 overflow-x-auto no-scroll-bar" | |
| + v-if="Object.values(props.current_filters).some((item) => item !== null)" | |
| > | |
| - <TagFilter | |
| - v-for="(value, key) in props.selectedFilters" | |
| - :key="key" | |
| - :filter="{label: value.nome_pt, value: value.permalink_pt }" | |
| - :text="value.nome_pt" | |
| - @remove-filter="removeQuery" | |
| - /> | |
| + <TagFilter | |
| + v-for="[key, value] in Object.entries(props.current_filters).filter(([k, v]) => v !== null)" | |
| + :key="key" | |
| + :filter="value" | |
| + :text="value.filter_display" | |
| + @remove-filter="removeQuery" | |
| + /> | |
| </div> | |
| - <!-- filtered tag --> | |
| - | |
| - <!-- list --> | |
| <div class="grid grid-cols-12"> | |
| - <div class="col-span-12"> | |
| - <PagyPagination | |
| - :noticias="props.noticias" | |
| + <div class="col-span-12 md:col-span-6"> | |
| + <div | |
| + class="hidden lg:flex gap-300 pt-200 pb-300 overflow-x-auto no-scroll-bar sticky top-15 z-10 bg-white" | |
| + v-if="Object.values(props.current_filters).some((item) => item !== null)" | |
| + > | |
| + <TagFilter | |
| + v-for="[key, value] in Object.entries(props.current_filters).filter(([k, v]) => v !== null)" | |
| + :key="value.filter_value" | |
| + :filter="value" | |
| + :text="value.filter_display" | |
| + @remove-filter="removeQuery" | |
| + /> | |
| + </div> | |
| + <InfiniteScrollLayout #content="{ allElements }" | |
| + :elements="props.elements" | |
| :pagy="props.pagy" | |
| - > | |
| - <!-- :filters="props.filters" --> | |
| - </PagyPagination> | |
| + > | |
| + <IndexArticleCard | |
| + v-for="article in allElements" | |
| + :key="article.id" | |
| + :title="article.titulo" | |
| + :permalink="article.permalink" | |
| + :chamada="article.chamada" | |
| + :imagem="article.imagem" | |
| + :category="article.caderno_nome" | |
| + :date="article.display_date" | |
| + /> | |
| + </InfiniteScrollLayout> | |
| </div> | |
| - <div class="hidden col-span-5 col-start-8"> | |
| - <p>Flamengo</p> | |
| + <div class="col-start-8 col-span-6 sticky top-0 z-50"> | |
| + <div ref="sentinel" class="h-1"></div> | |
| + <ResponsiveFilterMenu | |
| + v-model="filters" | |
| + :is-open="isFilterMenuOpen" | |
| + @filtersApplied="filterSearch" | |
| + @close-filter-menu="closeMenu" | |
| + > | |
| + <template #filters="{ modelValue, updateField }"> | |
| + <NoticiasFilterForm | |
| + :model-value="modelValue" | |
| + :update-field="updateField" | |
| + :data-label="props.dataLabel" | |
| + :cadernos="props.cadernos" | |
| + /> | |
| + </template> | |
| + </ResponsiveFilterMenu> | |
| </div> | |
| </div> | |
| - <!-- list --> | |
| </TwContainer> | |
| </template> | |
| diff --git a/app/frontend/pages/Peliculas/Show.vue b/app/frontend/pages/Peliculas/Show.vue | |
| index 0e869d5..0169874 100644 | |
| --- a/app/frontend/pages/Peliculas/Show.vue | |
| +++ b/app/frontend/pages/Peliculas/Show.vue | |
| @@ -83,7 +83,7 @@ const isDesktop = useUpdateWindowWidth(); | |
| aria-hidden="true" | |
| class="h-[16px] select-none" | |
| /> | |
| - <p class="text-overline">{{ props.pelicula.duracao_coord_int }}</p> | |
| + <p class="text-overline">{{ props.pelicula.duracao_coord_int }}'</p> | |
| <img | |
| src="@assets/icons/divisor_black.svg" | |
| alt="" | |
| diff --git a/app/frontend/pages/ProgramPage.vue b/app/frontend/pages/ProgramPage.vue | |
| index c79eeda..d9628a0 100644 | |
| --- a/app/frontend/pages/ProgramPage.vue | |
| +++ b/app/frontend/pages/ProgramPage.vue | |
| @@ -13,8 +13,6 @@ | |
| // but maybe this is not mandatory | |
| -import { ref, watch } from "vue"; | |
| -import { router } from "@inertiajs/vue3" | |
| import TwContainer from "@/components/layout/TwContainer.vue"; | |
| import InfiniteScrollLayout from "@/components/layout/InfiniteScrollLayout.vue"; | |
| @@ -30,8 +28,8 @@ import { useStickyMenuTabs } from "@/components/layout/navbar/composables/useSti | |
| import ResponsiveFilterMenu from "@/components/features/filters/ResponsiveFilterMenu.vue"; | |
| import Breadcrumb from "@/components/common/Breadcrumb.vue"; | |
| -import { extractFilterValues } from "@/lib/filterUtils"; | |
| -import { slugify } from "@/lib/utils"; | |
| +// import { slugify } from "@/lib/utils"; | |
| +import { useSearchFilter } from "@/components/features/filters/composables/usaSearchFilter"; | |
| const { isFilterMenuOpen, openMenu, closeMenu } = useMobileTrigger(); | |
| @@ -54,139 +52,26 @@ const props = defineProps({ | |
| ,crumbs: { type: Array, required: true } | |
| }) | |
| -// ============================================================================ | |
| -// FILTER STATE MANAGEMENT - SINGLE SOURCE OF TRUTH | |
| -// ============================================================================ | |
| - | |
| -/** | |
| - * Initialize empty filter structure | |
| - */ | |
| -const initializeFilters = () => ({ | |
| - query: null, | |
| - sessao: null, | |
| - mostra: null, | |
| - cinema: null, | |
| - genero: null, | |
| - pais: null, | |
| - direcao: null, | |
| - elenco: null, | |
| -}) | |
| - | |
| -/** | |
| - * Override empty filters with current filter values from controller | |
| - */ | |
| -const overrideFiltersValues = () => { | |
| - return { ...initializeFilters(), ...props.current_filters } | |
| -} | |
| - | |
| - | |
| -// Main filter state - this is passed to SearchFilter via ResponsiveFilterMenu | |
| -const filters = ref(overrideFiltersValues()) | |
| - | |
| -// Watch for prop changes from server (shouldn't happen often but good to have) | |
| -watch(() => props.current_filters, (newFilters) => { | |
| - console.log('ProgramPage: current_filters changed from server:', newFilters); | |
| - filters.value = overrideFiltersValues() | |
| -}, { immediate: true, deep: true }) | |
| - | |
| -// ============================================================================ | |
| -// FILTER OPERATIONS - CALLED BY SEARCHFILTER VIA EVENTS | |
| -// ============================================================================ | |
| - | |
| -/** | |
| - * Called when SearchFilter emits filtersApplied | |
| - * This makes the actual router call to update the page | |
| - */ | |
| - | |
| -const filterSearch = (filtersFromSearchFilter) => { | |
| - console.log('ProgramPage: Applying filters from SearchFilter:', filtersFromSearchFilter); | |
| - | |
| - // Build query params by rejecting any filter: null or "" | |
| - const cleanedFilters = extractFilterValues(filtersFromSearchFilter || filters.value) | |
| - | |
| - // MAke search request and says which prop to update | |
| - router.get(props.tabBaseUrl, cleanedFilters, { | |
| - preserveScroll: true, | |
| - only: ['elements', 'pagy', 'current_filters', 'has_active_filters', 'menuTabs'] | |
| - }) | |
| -}; | |
| - | |
| -/** | |
| - * Called when user clicks a filter tag to remove it | |
| - * This updates the filters state and makes a router call | |
| - */ | |
| - | |
| -const removeQuery = (filterToRemove) => { | |
| - const newParams = new URLSearchParams() | |
| - // Clear the specific filter | |
| - // TODO: REFAC TIP ADD FILTER_KEY FROM CONTROLLER | |
| - // IT SHOULD MAKE AGNOSTIC | |
| - // Map filter labels to filter keys (could be improved with a filter_key from controller) | |
| - const filterKeyMap = { | |
| - 'Time': 'sessao', | |
| - 'Sessão': 'sessao', | |
| - 'Showcase': 'mostra', | |
| - 'Mostra': 'mostra', | |
| - 'Cinema': 'cinema', | |
| - 'Genre': 'genero', | |
| - 'Genero': 'genero', | |
| - 'Country': 'pais', | |
| - 'Pais': 'pais', | |
| - 'Director': 'direcao', | |
| - 'Direção': 'direcao', | |
| - 'Cast': 'elenco', | |
| - 'Elenco': 'elenco' | |
| - }; | |
| - const filterKey = filterKeyMap[filterToRemove.filter_label]; | |
| - if (filterKey) { | |
| - // Updated local filter state | |
| - filters.value[filterKey] = null; | |
| - | |
| - // Build new URL params from remaining filters | |
| - const newParams = new URLSearchParams(); | |
| - Object.entries(filters.value).forEach(([key, value]) => { | |
| - if (value !== null && value !== undefined && value !== "" && value?.filter_value) { | |
| - newParams.set(key, value.filter_value); | |
| - } | |
| - }); | |
| - | |
| - router.get(props.tabBaseUrl, newParams, { | |
| - preserveScroll: true, | |
| - only: ['elements', 'pagy', 'current_filters', 'has_active_filters', 'menuTabs'] | |
| - }) | |
| - } else { | |
| - console.warn('ProgramPage: Unknown filter label:', filterToRemove.filter_label); | |
| - } | |
| +const controllerParamsKeys = { | |
| + // query: null, | |
| + // sessao: null, | |
| + // mostra: null, | |
| + // cinema: null, | |
| + // genero: null, | |
| + // pais: null, | |
| + // // direcao: null, | |
| + // elenco: null, | |
| } | |
| -/** | |
| - * Called when SearchFilter emits filtersCleared | |
| - * This clears all filters and optionally makes a router call | |
| - */ | |
| -const clearSearchQuery = () => { | |
| - console.log('ProgramPage: Clearing all filters'); | |
| - | |
| - const clearedFilters = initializeFilters() | |
| - filters.value = clearedFilters | |
| - // Only make router call if there were actually filters applied | |
| - const hasFiltersApplied = Object.entries(props.current_filters).some(([key, value]) => value != null) | |
| - if (hasFiltersApplied) { | |
| - router.get(props.tabBaseUrl, {}, { | |
| - preserveState: true, | |
| - preserveScroll: true, | |
| - only: ['elements', 'pagy', 'current_filters', 'has_active_filters', 'menuTabs'] | |
| - }); | |
| - } | |
| -}; | |
| - | |
| -/** | |
| - * Called when user clears search bar directly (if needed) | |
| - */ | |
| -const handleClear = () => { | |
| - console.log('ProgramPage: Clearing search query'); | |
| - filters.value.query = null; | |
| - filterSearch(filters.value); | |
| -}; | |
| +const { | |
| + initializeFilters, | |
| + overrideFiltersValues, | |
| + filters, | |
| + filterSearch, | |
| + removeQuery, | |
| + clearSearchQuery, | |
| + handleClear | |
| +} = useSearchFilter(props) | |
| // ============================================================================ | |
| // UI UTILITIES | |
| diff --git a/app/models/caderno.rb b/app/models/caderno.rb | |
| index 8736d11..e97c3ba 100644 | |
| --- a/app/models/caderno.rb | |
| +++ b/app/models/caderno.rb | |
| @@ -1,4 +1,5 @@ | |
| class Caderno < ApplicationRecord | |
| + include Filterable | |
| has_many :noticias | |
| def self.for_filters | |
| diff --git a/app/models/concerns/Filterable.rb b/app/models/concerns/Filterable.rb | |
| index dfb037a..35ee98b 100644 | |
| --- a/app/models/concerns/Filterable.rb | |
| +++ b/app/models/concerns/Filterable.rb | |
| @@ -2,7 +2,9 @@ module Filterable | |
| extend ActiveSupport::Concern | |
| class_methods do | |
| - def collection_for(field_name, filter_params_key) | |
| + def collection_for(field_name, field_display = nil, filter_params_key) | |
| + field_display ||= field_name | |
| + | |
| full_field_collection = filter_scope(field_name) | |
| return unless block_given? | |
| @@ -14,8 +16,16 @@ module Filterable | |
| end | |
| def build_filter_json(value, key) | |
| + logger.debug(value) | |
| + if value.is_a?(Array) | |
| + display = value.second | |
| + value = value.first | |
| + else | |
| + # binding.b | |
| + display = value | |
| + end | |
| { | |
| - "filter_display" => value, | |
| + "filter_display" => display, | |
| "filter_value" => value, | |
| "filter_label" => I18n.t("filter.#{key}") | |
| } | |
| @@ -26,5 +36,23 @@ module Filterable | |
| .where.not(field_name => [ nil, "" ]) | |
| .pluck(field_name) | |
| end | |
| + | |
| + # HORRIVEL MAS EH ISSO | |
| + def collection_without_edition_for(field_name, field_display = nil, filter_params_key) | |
| + field_display ||= field_name | |
| + full_field_collection = no_edition_filter_scope(field_name, field_display) | |
| + # return unless block_given? | |
| + | |
| + full_field_collection = yield(full_field_collection) if block_given? | |
| + full_field_collection.compact_blank | |
| + .uniq | |
| + .sort | |
| + .map { |item| build_filter_json(item, filter_params_key) } | |
| + end | |
| + | |
| + def no_edition_filter_scope(field_name, field_display) | |
| + where.not(field_name => [ nil, "" ]) | |
| + .pluck(field_name, field_display) | |
| + end | |
| end | |
| end | |
| diff --git a/config/locales/en.yml b/config/locales/en.yml | |
| index b23ad61..391dc7a 100644 | |
| --- a/config/locales/en.yml | |
| +++ b/config/locales/en.yml | |
| @@ -31,7 +31,7 @@ en: | |
| filter: | |
| title: "Filters" | |
| - date: "Date" | |
| + date: "Publication date" | |
| time: "Time" | |
| submostra: "Showcase" | |
| cinema: "Theater" | |
| @@ -43,7 +43,7 @@ en: | |
| festivais: "Festivals" | |
| premios: "Awards" | |
| palavras_chaves: "Keywords" | |
| - | |
| + caderno: "Category" | |
| filter_by: | |
| date: "by date" | |
| time: "by time" | |
| diff --git a/config/locales/pt.yml b/config/locales/pt.yml | |
| index 9b575fa..1f5ef66 100644 | |
| --- a/config/locales/pt.yml | |
| +++ b/config/locales/pt.yml | |
| @@ -31,7 +31,7 @@ pt: | |
| filter: | |
| title: "Filtros" | |
| - date: "Data" | |
| + date: "Data de publicação" | |
| time: "Sessão" | |
| submostra: "Mostra" | |
| cinema: "Cinema" | |
| @@ -43,6 +43,7 @@ pt: | |
| festivais: "Festivais" | |
| premios: "Prêmios" | |
| palavras_chaves: "Palavras chaves" | |
| + caderno: "Caderno" | |
| filtro: "Filtro | Filtros" | |
| diff --git a/package-lock.json b/package-lock.json | |
| index 4f81d37..6dcd11d 100644 | |
| --- a/package-lock.json | |
| +++ b/package-lock.json | |
| @@ -6,6 +6,7 @@ | |
| "": { | |
| "dependencies": { | |
| "@inertiajs/vue3": "^2.1.3", | |
| + "@internationalized/date": "^3.9.0", | |
| "@tailwindcss/forms": "^0.5.10", | |
| "@tailwindcss/typography": "^0.5.16", | |
| "@tailwindcss/vite": "^4.1.12", | |
| @@ -1647,9 +1648,9 @@ | |
| "license": "MIT" | |
| }, | |
| "node_modules/axios": { | |
| - "version": "1.11.0", | |
| - "resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz", | |
| - "integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==", | |
| + "version": "1.12.2", | |
| + "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz", | |
| + "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==", | |
| "license": "MIT", | |
| "dependencies": { | |
| "follow-redirects": "^1.15.6", | |
| diff --git a/package.json b/package.json | |
| index 140639c..44d7f8a 100644 | |
| --- a/package.json | |
| +++ b/package.json | |
| @@ -11,6 +11,7 @@ | |
| }, | |
| "dependencies": { | |
| "@inertiajs/vue3": "^2.1.3", | |
| + "@internationalized/date": "^3.9.0", | |
| "@tailwindcss/forms": "^0.5.10", | |
| "@tailwindcss/typography": "^0.5.16", | |
| "@tailwindcss/vite": "^4.1.12", | |
| diff --git a/test/controllers/noticias_controller_test.rb b/test/controllers/noticias_controller_test.rb | |
| index 7f62ab9..4bc043e 100644 | |
| --- a/test/controllers/noticias_controller_test.rb | |
| +++ b/test/controllers/noticias_controller_test.rb | |
| @@ -1,7 +1,30 @@ | |
| require "test_helper" | |
| class NoticiasControllerTest < ActionDispatch::IntegrationTest | |
| - # test "the truth" do | |
| - # assert true | |
| - # end | |
| + test "filters by caderno in PT - Premio Felix" do | |
| + get noticias_url, params: { caderno: "premio-felix" } | |
| + | |
| + assert_response :success | |
| + props = inertia_props | |
| + | |
| + elements = props["elements"] | |
| + assert_equal 1, elements.length | |
| + | |
| + elements.each do |element| | |
| + assert_includes [ "TEST FELIX TWO titulo" ], element["titulo"] | |
| + end | |
| + end | |
| + test "filters by caderno in EN - Felix Award" do | |
| + get noticias_url(locale: :en), params: { caderno: "felix-award" } | |
| + | |
| + assert_response :success | |
| + props = inertia_props | |
| + | |
| + elements = props["elements"] | |
| + assert_equal 1, elements.length | |
| + | |
| + elements.each do |element| | |
| + assert_includes [ "TEST FELIX TWO titulo" ], element["titulo"] | |
| + end | |
| + end | |
| end | |
| diff --git a/test/fixtures/cadernos.yml b/test/fixtures/cadernos.yml | |
| new file mode 100644 | |
| index 0000000..90eed8b | |
| --- /dev/null | |
| +++ b/test/fixtures/cadernos.yml | |
| @@ -0,0 +1,13 @@ | |
| +talents: | |
| + nome_en: "Talents Rio" | |
| + nome_pt: "Talents Rio" | |
| + permalink_en: "talents-rio" | |
| + permalink_pt: "talents-rio" | |
| + created: "2016-10-03 11:17:54.000000000 +0000" | |
| + | |
| +felix: | |
| + nome_en: "Felix Award" | |
| + permalink_en: "felix-award" | |
| + nome_pt: "Prêmio Felix" | |
| + permalink_pt: "premio-felix" | |
| + created: "2015-10-10 12:43:00.000000000 +0000" | |
| diff --git a/test/fixtures/idiomas.yml b/test/fixtures/idiomas.yml | |
| new file mode 100644 | |
| index 0000000..6468f1f | |
| --- /dev/null | |
| +++ b/test/fixtures/idiomas.yml | |
| @@ -0,0 +1,7 @@ | |
| +port: | |
| + nome: "Português" | |
| + locale: "pt-br" | |
| + | |
| +ing: | |
| + nome: "Inglês" | |
| + locale: "en" | |
| diff --git a/test/fixtures/noticias.yml b/test/fixtures/noticias.yml | |
| new file mode 100644 | |
| index 0000000..0c344f5 | |
| --- /dev/null | |
| +++ b/test/fixtures/noticias.yml | |
| @@ -0,0 +1,27 @@ | |
| +one_talents: | |
| + idioma: port | |
| + caderno: talents | |
| + data: "2025-08-10" | |
| + hora: "2000-01-01 10:00:00.000000000 +0000" | |
| + titulo: "TEST TALENTS ONE titulo" | |
| + permalink: "test-talents-one-titulo" | |
| + imagem: "3281cf9b6dd1fc05aa42e9d0c47ba471.jpg" | |
| + chamada: "test TALENTS ONE chamada" | |
| + conteudo: "<p>test TALENTS ONE conteudo</p>" | |
| + ativo: 1 | |
| + created: "2025-08-08 18:54:10.000000000 +0000" | |
| + updated: "2025-08-11 19:36:02.000000000 +0000" | |
| + | |
| +one_felix: | |
| + idioma: port | |
| + caderno: felix | |
| + data: "2025-08-12" | |
| + hora: "2000-01-01 12:00:00.000000000 +0000" | |
| + titulo: "TEST FELIX TWO titulo" | |
| + permalink: "test-felix-two-titulo" | |
| + imagem: "3281cf9b6dd1fc05aa42e9d0c47ba471.jpg" | |
| + chamada: "test FELIX TWO chamada" | |
| + conteudo: "<p>test FELIX TWO conteudo</p>" | |
| + ativo: 1 | |
| + created: "2025-08-08 18:54:10.000000000 +0000" | |
| + updated: "2025-08-12 19:36:02.000000000 +0000" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment