Created
September 10, 2025 02:56
-
-
Save dedemenezes/cdcf914158246266679d1018d80c58f8 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
| <script setup> | |
| console.log('ProgramPage.vue mounted/rendered'); | |
| // TODO: CHANGE TEXT WHEN NO RESULT FOR FILTERING | |
| // TODO: FIX LIMPAR FILTRO | |
| // TODO: Click cleansearchbar should close mobile filter menu? | |
| // TODO: Click cleansearchbar should make new request to remove query param | |
| import { ref, watch } from "vue"; | |
| import { router } from "@inertiajs/vue3" | |
| import { IconProgram, IconClock, IconChange, IconNewUser } from "@components/common/icons"; | |
| import TwContainer from "@/components/layout/TwContainer.vue"; | |
| import InfiniteScrollLayout from "@/components/layout/InfiniteScrollLayout.vue"; | |
| import MenuContext from "@/components/layout/navbar/MenuContext.vue"; | |
| import MenuTabs from "@/components/layout/navbar/MenuTabs.vue"; | |
| import MobileTrigger from "@/components/features/filters/MobileTrigger.vue"; | |
| import ProgramsFilterForm from "@/components/features/filters/ProgramsFilterForm.vue"; | |
| import SessionCard from "@/components/common/cards/SessionCard.vue"; | |
| import { useMobileTrigger } from "@/components/features/filters/composables/useMobileTrigger"; | |
| import { useStickyMenuTabs } from "@/components/layout/navbar/composables/useStickyMenuTabs"; | |
| import ResponsiveFilterMenu from "@/components/features/filters/ResponsiveFilterMenu.vue"; | |
| import Breadcrumb from "@/components/common/Breadcrumb.vue"; | |
| const { isFilterMenuOpen, openMenu, closeMenu } = useMobileTrigger(); | |
| const props = defineProps({ | |
| tabBaseUrl: { type: String, required: true }, | |
| items: { type: Array, required: true } | |
| ,elements: { type: Object, required: true } | |
| ,pagy: { type: Object, required: true } | |
| ,mostrasFilter: { type: Array, default: () => [] } | |
| ,cinemasFilter: { type: Array, default: () => [] } | |
| ,paisesFilter: { type: Array, default: () => [] } | |
| // NEW LIFE | |
| ,menuTabs: { type: Array, required: true } | |
| ,current_filters: { type: Object, default: () => ({}) } | |
| ,has_active_filters: { type: Boolean, default: false } | |
| ,crumbs: { type: Array, required: true } | |
| }) | |
| const localFilters = ref({ ...props.current_filters }) | |
| const iconMapping = { | |
| "program": IconProgram, | |
| "user": IconNewUser, | |
| "change": IconChange, | |
| "clock": IconClock | |
| }; | |
| // Called when user clears search bar | |
| const handleClear = () => { | |
| filters.value.query = null; | |
| submit(props.tabBaseUrl); | |
| }; | |
| // Called when filters applied in MobileFilterMenu | |
| const filterSearch = (filtersFromChild) => { | |
| const cleanedFilters = {} | |
| for (const [key, value] of Object.entries(filtersFromChild)) { | |
| if (key === 'mostrasFilter' && typeof value === 'object' && value?.permalink_pt) { | |
| cleanedFilters[key] = value.permalink_pt // Extract just the string | |
| } else { | |
| cleanedFilters[key] = value | |
| } | |
| } | |
| router.get(props.tabBaseUrl, cleanedFilters, { | |
| preserveState: true, | |
| preserveScroll: true, | |
| only: ['elements', 'pagy', 'current_filters', 'has_active_filters', 'menuTabs'] | |
| }) | |
| }; | |
| // Called when filters cleared from MobileFilterMenu | |
| const clearSearchQuery = () => { | |
| clearAll(); | |
| submit(props.tabBaseUrl); | |
| }; | |
| // sticket menutabs | |
| const { sentinel, isSticky } = useStickyMenuTabs() | |
| watch(() => props.current_filters, (newFilters, oldFilters) => { | |
| console.log('current_filters CHANGED!'); | |
| console.log('OLD:', oldFilters); | |
| console.log('NEW:', newFilters); | |
| }, { deep: true }); | |
| </script> | |
| <template> | |
| <TwContainer> | |
| <Breadcrumb :crumbs="props.crumbs" /> | |
| </TwContainer> | |
| <MenuContext | |
| :items="props.items" | |
| :icon-mapping="iconMapping" | |
| /> | |
| <hr class="text-neutrals-300"> | |
| <!-- TODO: review border maybe just desktop --> | |
| <TwContainer class="relative"> | |
| <div | |
| class="filter flex lg:hidden items-center justify-end py-300 bg-white" | |
| > | |
| <MobileTrigger @open-menu="openMenu" /> | |
| </div> | |
| <div class="grid grid-cols-12"> | |
| <div class="col-span-12 md:col-span-6"> | |
| <!-- Added for sticky menutabs --> | |
| <div ref="sentinel" class="h-1"></div> | |
| <MenuTabs | |
| :is-sticky="isSticky" | |
| :tabs="menuTabs" | |
| class="h-15" | |
| /> | |
| <InfiniteScrollLayout #content="{ allElements }" | |
| :elements="props.elements" | |
| :pagy="props.pagy" | |
| > | |
| <SessionCard v-for="session in allElements" | |
| :session="session" | |
| /> | |
| </InfiniteScrollLayout> | |
| </div> | |
| <div class="col-start-8 col-span-6 sticky top-0 z-50"> | |
| <div ref="sentinel" class="h-1"></div> | |
| <ResponsiveFilterMenu | |
| :is-open="isFilterMenuOpen" | |
| :initialFilters="localFilters" | |
| @filtersApplied="filterSearch" | |
| @filtersCleared="clearSearchQuery" | |
| @close-filter-menu="closeMenu" | |
| > | |
| <template #filters="{ modelValue, updateField }"> | |
| <ProgramsFilterForm | |
| :model-value="modelValue" | |
| :update-field="updateField" | |
| :mostrasFilter="props.mostrasFilter" | |
| :cinemasFilter="props.cinemasFilter" | |
| :paisesFilter="props.paisesFilter" | |
| /> | |
| </template> | |
| </ResponsiveFilterMenu> | |
| </div> | |
| </div> | |
| </TwContainer> | |
| </template> | |
| <style scoped></style> |
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
| class ProgramsController < ApplicationController | |
| EDICAO_ATUAL = 12 | |
| # TODO: MAKE THIS ENV VARIABLE? | |
| include BreadcrumbsHelper | |
| DATES_PER_PAGE = 1 | |
| include Pagy::Backend | |
| def index | |
| items = [ | |
| # Links tem que vir do controller para incluir localizacao. Textos também | |
| { name: I18n.t("navigation.programming"), route: program_url, icon: "program" }, | |
| { name: I18n.t("navigation.sessoes_com_convidados"), route: "/", icon: "user" }, | |
| { name: I18n.t("navigation.mudancas_na_programacao"), route: "/", icon: "change" }, | |
| { name: I18n.t("navigation.sessoes_ao_ar_livre"), route: "/", icon: "clock" } | |
| ] | |
| selected_filters = {} | |
| last_import_id = Programacao.maximum(:importacoesprog_id) | |
| base_scope = Programacao | |
| .joins(pelicula: :mostra) | |
| .includes(:cinema, pelicula: :mostra) | |
| .where(importacoesprog_id: last_import_id, edicao_id: EDICAO_ATUAL) # TODO: remover importacoes? | |
| # Gathering filter options | |
| paises_filter = base_scope.includes(pelicula: :paises) | |
| .map { _1.pelicula.paises } | |
| .flatten | |
| .uniq | |
| .sort_by { |it| it.nome_pais } | |
| .as_json(only: %i[id nome_pais]) | |
| mostras_filter = Mostra.where(edicao_id: EDICAO_ATUAL) | |
| .to_a | |
| .uniq { |m| m.id } | |
| .sort_by { |it| it.permalink_pt } | |
| .as_json( | |
| only: %i[id permalink_pt nome_abreviado], | |
| methods: [ :tag_class, :display_name ] | |
| ) | |
| cinemas_filter = Cinema.where(edicao_id: EDICAO_ATUAL).to_a.uniq { |m| m.id }.sort_by { |it| it.nome }.as_json( | |
| only: %i[id nome endereco edicao_id] | |
| ) | |
| # Filtering SEARCH INPUT | |
| if params[:query].present? | |
| term = "%#{params[:query].downcase}%" | |
| base_scope = base_scope.where( | |
| "LOWER(peliculas.titulo_ingles_coord_int) LIKE :query OR | |
| LOWER(peliculas.titulo_original_coord_int) LIKE :query OR | |
| LOWER(peliculas.titulo_portugues_coord_int) LIKE :query OR | |
| LOWER(peliculas.titulo_ingles_semartigo) LIKE :query OR | |
| LOWER(peliculas.titulo_portugues_semartigo) LIKE :query", | |
| query: term | |
| ) | |
| selected_filters[:query] = params[:quer] | |
| end | |
| if params[:mostrasFilter].present? | |
| selected_mostra = mostras_filter.find { |c| c["permalink_pt"] == params[:mostrasFilter] } | |
| selected_filters[:mostrasFilter] = selected_mostra if selected_mostra | |
| if selected_mostra | |
| base_scope = base_scope.where(mostras: { permalink_pt: selected_filters[:mostrasFilter]["permalink_pt"] }) | |
| end | |
| end | |
| if params[:cinemasFilter] | |
| selected_cinema = cinemas_filter.find do |cinema_filter| | |
| (cinema_filter["id"].to_s === params[:cinemasFilter]) && (cinema_filter["edicao_id"] == EDICAO_ATUAL) | |
| end | |
| if selected_cinema | |
| selected_filters[:cinemasFilter] = selected_cinema | |
| base_scope = base_scope.where(cinema_id: selected_cinema["id"]) | |
| end | |
| end | |
| if params[:paisesFilter] | |
| selected_pais = paises_filter.find do |pais_filter| | |
| (pais_filter["id"].to_s === params[:paisesFilter]) | |
| end | |
| if selected_pais | |
| selected_filters[:paisesFilter] = selected_pais | |
| # raise | |
| # base_scope = base_scope.where(paises_id: selected_pais["id"]) | |
| base_scope = base_scope.joins(pelicula: :paises).where(pelicula: { paises: { id: selected_pais["id"] } }) | |
| end | |
| end | |
| # Get Scope Available dates | |
| available_dates = base_scope.distinct.pluck(:data).sort | |
| selected_date = available_dates.first | |
| # Filter by date | |
| if params[:date].present? | |
| parsed_date = Date.parse(params[:date]) rescue nil | |
| if parsed_date&.in?(available_dates) | |
| selected_date = parsed_date | |
| end | |
| end | |
| selected_filters[:date] = selected_date | |
| programacoes_for_date = base_scope.where(data: selected_date).order(:sessao) | |
| current_page = params[:page].to_i ||= 1 | |
| @pagy, @programacoes = pagy_infinite(programacoes_for_date, current_page) | |
| @programacoes = @programacoes.map do |programacao| | |
| { | |
| id: programacao.id, | |
| data: programacao.data, | |
| sessao: [ programacao.display_sessao ], | |
| cinema: programacao.cinema&.nome, | |
| titulo: programacao.pelicula&.titulo_portugues_coord_int, | |
| duracao: programacao.pelicula&.duracao_coord_int, | |
| imagem: programacao.pelicula&.imagem, | |
| genero: programacao.pelicula&.genre, | |
| paises: programacao.pelicula&.display_paises, | |
| mostra: programacao.pelicula&.mostra&.display_name, | |
| mostra_tag_class: programacao.pelicula&.mostra&.tag_class | |
| } | |
| end | |
| # display_selected_date = I18n.l(selected_date, format: "%a, %e %b", locale: :pt) if selected_date | |
| @menu_tabs = available_dates.map do |date| | |
| { | |
| date: date, | |
| url: build_tab_url(date, selected_filters), | |
| active: date.to_s == params[:date] || (date == selected_date && !params[:date]) | |
| } | |
| end | |
| # Build breadcrumbs | |
| # raise | |
| render inertia: "ProgramPage", props: { | |
| rootUrl: @root_url, | |
| tabBaseUrl: program_url, | |
| items:, | |
| elements: @programacoes, | |
| pagy: @pagy, | |
| mostrasFilter: mostras_filter, | |
| cinemasFilter: cinemas_filter, | |
| paisesFilter: paises_filter, | |
| menuTabs: @menu_tabs, | |
| current_filters: { | |
| query: params[:query], | |
| mostrasFilter: selected_mostra, | |
| cinemasFilter: selected_cinema, | |
| paisesFilter: selected_pais | |
| }, | |
| has_active_filters: params.permit(:query, :mostrasFilter).to_h.values.any?(&:present?), | |
| crumbs: breadcrumbs( | |
| [ "", @root_url ], | |
| [ "Programação", "" ], | |
| [ "Programação Completa", "" ], | |
| ) | |
| } | |
| end | |
| private | |
| # TODO: Relocate this piece into a concern or helper | |
| def pagy_infinite(collection, page_param) | |
| current_page = (page_param || params[:page] || 1).to_i | |
| limit = Pagy::DEFAULT[:limit] || 5 | |
| if current_page <= 1 | |
| # First page - normal pagination | |
| pagy_result = pagy(collection, limit: limit) | |
| pagy_result | |
| else | |
| # Infinite scroll - load all items from page 1 to current page | |
| total_items_needed = current_page * limit | |
| # Get the actual items | |
| items = collection.limit(total_items_needed) | |
| # Create proper pagy object with | |
| # all the metadata need in the frontend | |
| total_count = collection.count | |
| pagy_obj = Pagy.new( | |
| count: total_count, | |
| limit: limit, | |
| page: current_page, | |
| # overflow: :last_page | |
| ) | |
| [ pagy_obj, items ] | |
| end | |
| end | |
| def build_tab_url(date, filters) | |
| query_params = {} | |
| query_params[:mostrasFilter]= filters[:mostrasFilter]["permalink_pt"] if filters[:mostrasFilter].present? | |
| query_params[:cinemasFilter]= filters[:cinemasFilter]["id"] if filters[:cinemasFilter].present? | |
| query_params[:paisesFilter]= filters[:paisesFilter]["id"] if filters[:paisesFilter].present? | |
| query_params[:date] = date | |
| url_for(params: query_params, only_path: true) | |
| end | |
| end |
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
| <script setup> | |
| import { computed } from "vue"; | |
| import AccordionGroup from "@/components/AccordionGroup.vue"; | |
| import ComboboxComponent from "@/components/ui/ComboboxComponent.vue"; | |
| import SearchBar from "@/components/features/filters/SearchBar.vue"; | |
| const props = defineProps({ | |
| modelValue: { type: Object, required: true }, | |
| updateField: { type: Function, required: true }, | |
| mostrasFilter: { type: Array, default: () => [] }, // Program-specific prop | |
| cinemasFilter: { type: Array, default: () => [] }, // Program-specific prop | |
| paisesFilter: { type: Array, default: () => [] } | |
| }); | |
| // Transform cadernos prop for ComboboxComponent format | |
| const mostrasFilterOptions = computed(() => { | |
| return props.mostrasFilter.map(caderno => ({ | |
| label: caderno.nome_abreviado, | |
| value: caderno.permalink_pt, | |
| })); | |
| }); | |
| // Transform cinema prop for ComboboxComponent format | |
| const cinemasFilterOptions = computed(() => { | |
| return props.cinemasFilter.map(cinema => ({ | |
| label: cinema.nome, | |
| value: cinema.id, | |
| })); | |
| }); | |
| // Transform cinema prop for ComboboxComponent format | |
| const paisesFilterOptions = computed(() => { | |
| return props.paisesFilter.map(pais => ({ | |
| label: pais.nome_pais, | |
| value: pais.id, | |
| })); | |
| }); | |
| const getMostraObjectFromTagClas = (permalink_pt) => { | |
| return props.mostrasFilter.find(c => c.permalink_pt === permalink_pt) || null; | |
| }; | |
| const getCinemaObjectFromTagClas = (id) => { | |
| return props.cinemasFilter.find(c => c.id === id) || null; | |
| }; | |
| console.log('Pais modelValue being passed:', props.modelValue.paisesFilter?.id); | |
| console.log('Pais full object:', props.modelValue.paisesFilter); | |
| console.log('Pais collection:', paisesFilterOptions); | |
| </script> | |
| <template> | |
| <!-- Article-specific filter content --> | |
| <div class="pt-400"> | |
| <SearchBar | |
| :modelValue="props.modelValue.query" | |
| @update:modelValue="(val) => props.updateField('query', val)" | |
| /> | |
| </div> | |
| <!-- MOSTRAS --> | |
| <AccordionGroup | |
| text="Mostra" | |
| :isOpen="!!props.modelValue.mostrasFilter" | |
| > | |
| <template v-slot:content> | |
| <div class="overflow-hidden w-full"> | |
| <ComboboxComponent | |
| :collection="mostrasFilterOptions" | |
| :modelValue="props.modelValue.mostrasFilter?.permalink_pt || null" | |
| @update:modelValue="(val) => props.updateField('mostrasFilter', getMostraObjectFromTagClas(val))" | |
| /> | |
| </div> | |
| </template> | |
| </AccordionGroup> | |
| <!-- CINEMAS --> | |
| <AccordionGroup | |
| text="Cinema" | |
| :isOpen="!!props.modelValue.cinemasFilter" | |
| > | |
| <template v-slot:content> | |
| <div class="overflow-hidden w-full"> | |
| <ComboboxComponent | |
| :collection="cinemasFilterOptions" | |
| :modelValue="props.modelValue.cinemasFilter || null" | |
| @update:modelValue="(val) => props.updateField('cinemasFilter', getCinemaObjectFromTagClas(val))" | |
| /> | |
| </div> | |
| </template> | |
| </AccordionGroup> | |
| <!-- PAISES --> | |
| <AccordionGroup | |
| text="Pais" | |
| :isOpen="!!props.modelValue.paisesFilter" | |
| > | |
| <template v-slot:content> | |
| <div class="overflow-hidden w-full"> | |
| <ComboboxComponent | |
| :collection="paisesFilterOptions" | |
| :modelValue="props.modelValue.paisesFilter?.id || null" | |
| @update:modelValue="(val) => props.updateField('paisesFilter', val)" | |
| /> | |
| </div> | |
| </template> | |
| </AccordionGroup> | |
| --> | |
| </template> |
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
| <script setup> | |
| import { computed } from "vue"; | |
| import { toRaw } from "vue"; | |
| import { cleanObject, toHHMM } from "@/lib/utils"; | |
| import FilterActions from "@/components/features/filters/FilterActions.vue"; | |
| // import FilterForm from "./FilterForm.vue"; | |
| const closeMenu = () => { | |
| emit("close-filter-menu", false); | |
| }; | |
| const props = defineProps({ | |
| modelValue: { type: Object, required: true }, | |
| }); | |
| const emit = defineEmits([ | |
| "filtersApplied", | |
| "filtersCleared", | |
| "close-filter-menu", | |
| "update:modelValue", | |
| ]); | |
| const applyFilters = () => { | |
| const rawFilters = toRaw(props.modelValue); | |
| const cleanedFilters = cleanObject(rawFilters); | |
| // TODO: THINK. time processing maybe helper | |
| if (cleanedFilters.startTime) { | |
| cleanedFilters.startTime = toHHMM(cleanedFilters.startTime); | |
| } | |
| if (cleanedFilters.endTime) { | |
| cleanedFilters.endTime = toHHMM(cleanedFilters.endTime); | |
| } | |
| emit("filtersApplied", cleanedFilters); | |
| closeMenu(); | |
| }; | |
| const clearAllFilters = () => { | |
| const cleared = Object.fromEntries( | |
| Object.keys(props.modelValue).map((key) => [key, null]), | |
| ); | |
| emit("update:modelValue", cleared); | |
| emit("filtersCleared", cleared); | |
| }; | |
| const hasActiveFilters = computed(() => { | |
| const keysToIgnore = ['page', 'date']; // Add keys you want to ignore | |
| return Object.entries(props.modelValue) | |
| .filter(([key, value]) => !keysToIgnore.includes(key)) | |
| .filter(([key, value]) => value != "") | |
| .some(([key, value]) => value !== null); | |
| }); | |
| // return Object.values(props.modelValue).some((value) => value !== null); | |
| const updateField = (key, value) => { | |
| emit("update:modelValue", { ...props.modelValue, [key]: value }); | |
| }; | |
| </script> | |
| <template> | |
| <div class="flex-grow flex flex-col space-y-600 overflow-y-auto"> | |
| <slot | |
| name="filters" | |
| :modelValue="props.modelValue" | |
| :updateField="updateField" | |
| > | |
| <!-- Default content --> | |
| <p class="text-gradient text-header-lg text-center">No content passed</p> | |
| </slot> | |
| </div> | |
| <div | |
| class="shrink-0 py-400 actions justify-self-end sticky bottom-0 bg-white-transp-1000 z-10" | |
| > | |
| <slot | |
| name="actions" | |
| :hasActiveFilters="hasActiveFilters" | |
| :applyFilters="applyFilters" | |
| :clearAllFilters="clearAllFilters" | |
| > | |
| <!-- DFAULT --> | |
| <FilterActions | |
| @clear="clearAllFilters" | |
| @apply="applyFilters" | |
| :hasActiveFilters="hasActiveFilters" | |
| /> | |
| </slot> | |
| </div> | |
| </template> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment