Skip to content

Instantly share code, notes, and snippets.

@dedemenezes
Created September 10, 2025 02:56
Show Gist options
  • Select an option

  • Save dedemenezes/cdcf914158246266679d1018d80c58f8 to your computer and use it in GitHub Desktop.

Select an option

Save dedemenezes/cdcf914158246266679d1018d80c58f8 to your computer and use it in GitHub Desktop.
<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>
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
<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>
<script setup>
import { ref, watch } from "vue";
import { IconClose } from "@/components/common/icons";
import SearchFilter from "@/components/features/filters/SearchFilter.vue";
import TwContainer from "@/components/layout/TwContainer.vue";
const props = defineProps({
isOpen: { type: Boolean, required: true },
initialFilters: { type: Object, required: true },
});
const internalFilters = ref({ ...props.initialFilters });
// Watch for changes in initialFilters (after successful submission)
watch(() => props.initialFilters, (newFilters) => {
internalFilters.value = { ...newFilters };
}, { deep: true });
const emit = defineEmits([
"update:modelValue",
"filtersApplied",
"filtersCleared",
"close-filter-menu",
]);
console.log('initialFilters prop:', props.initialFilters);
console.log('internalFilters ref:', internalFilters.value);
watch(() => props.initialFilters, (newFilters) => {
console.log('initialFilters changed:', newFilters);
// Is internalFilters being updated here?
});
</script>
<template>
<!-- Desktop Layout: Sticky sidebar (always visible) -->
<div
style="margin-top: 0"
class="desktop-style bg-white hidden md:flex md:flex-col sticky top-0"
>
<TwContainer class="h-full">
<div class="flex flex-col h-full">
<!-- Desktop Filter header -->
<div
class="shrink-0 flex justify-between items-center py-400 sticky top-0 bg-white-transp-1000"
>
<p class="text-header-sm text-neutrals-900 uppercase">
FILTROS
</p>
<button @click="emit('close-filter-menu')" class="text-neutrals-900 cursor-pointer absolute -right-[.425rem]">
<IconClose height="32px" width="32px" />
</button>
</div>
<!-- Desktop Filter Content -->
<SearchFilter
v-model="internalFilters"
@update:modelValue="(val) => emit('update:modelValue', val)"
@filtersApplied="emit('filtersApplied', internalFilters)"
@filtersCleared="emit('filtersCleared')"
@close-filter-menu="emit('close-filter-menu')"
>
<template #filters="slotProps">
<slot
name="filters"
:modelValue="internalFilters"
:updateField="(field, value) => {
internalFilters[field] = value
}"
/>
</template>
</SearchFilter>
</div>
</TwContainer>
</div>
<!-- Mobile Layout: Fullscreen modal (only when open) -->
<div
v-if="props.isOpen"
style="margin-top: 0"
class="fixed inset-0 z-50 bg-white flex md:hidden flex-col w-full max-w-full h-[100vh] right-0 shadow-lg overflow-y-auto"
>
<TwContainer class="h-full">
<div class="flex flex-col h-full">
<!-- Mobile Filter header -->
<div
class="shrink-0 flex justify-between items-center py-400 sticky top-0 bg-white-transp-1000 z-50"
>
<p class="text-header-sm text-neutrals-900 uppercase">
FILTROS
</p>
<button @click="emit('close-filter-menu')" class="text-neutrals-900 cursor-pointer absolute -right-[.425rem]">
<IconClose height="32px" width="32px" />
</button>
</div>
<!-- Mobile Filter Content -->
<SearchFilter
v-model="internalFilters"
@update:modelValue="(val) => emit('update:modelValue', val)"
@filtersApplied="emit('filtersApplied', internalFilters)"
@filtersCleared="emit('filtersCleared')"
@close-filter-menu="emit('close-filter-menu')"
>
<template #filters="slotProps">
<slot
name="filters"
:modelValue="internalFilters"
:updateField="(field, value) => {
internalFilters[field] = value
}"
/>
</template>
</SearchFilter>
</div>
</TwContainer>
</div>
</template>
<style scoped></style>
<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