feat: agregar búsqueda de canciones y configurar hook de Gitea Actions
All checks were successful
build-and-deploy / build (push) Successful in 25s
build-and-deploy / deploy (push) Successful in 4s

- Agregar componente SearchFilter.client.vue modular y expandible
- Integrar búsqueda en MainContainer con contador de resultados
- Implementar filtrado de canciones por nombre en pages/index.vue
- Configurar hook PostToolUse para monitorear Gitea Actions
- Copiar y adaptar script monitor-gitea-action.sh para repodructor
This commit is contained in:
2025-10-14 01:14:19 -06:00
parent 182bfa74c9
commit de3ffbe05f
4 changed files with 484 additions and 5 deletions

View File

@@ -0,0 +1,197 @@
#!/bin/bash
# Monitor Gitea Action after git push
# Este script se ejecuta después de un git push y espera a que termine la Gitea Action
set -euo pipefail
# Configuración
GITEA_URL="https://gitea.nucleoriofrio.com"
OWNER="nucleo000"
REPO="repodructor"
# Intentar cargar el token desde el entorno o desde ~/.bashrc
GITEA_TOKEN="${GITEA_TOKEN:-}"
if [ -z "$GITEA_TOKEN" ] && [ -f "$HOME/.bashrc" ]; then
# Intentar extraer el token de .bashrc
GITEA_TOKEN=$(grep -oP "export GITEA_TOKEN=['\"]?\K[^'\"]*" "$HOME/.bashrc" 2>/dev/null || echo "")
fi
MAX_WAIT_SECONDS=600 # 10 minutos
POLL_INTERVAL=10 # Consultar cada 10 segundos
# Leer el input JSON del hook
INPUT=$(cat)
# Verificar si el comando fue un git push
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // ""')
if [[ ! "$COMMAND" =~ git[[:space:]]+push ]]; then
# No fue un git push, salir sin hacer nada
exit 0
fi
# Verificar que existe el token
if [ -z "$GITEA_TOKEN" ]; then
cat <<EOF
{
"decision": "block",
"reason": "⚠️ Git push exitoso, pero no puedo monitorear la Gitea Action: falta GITEA_TOKEN.\n\nConfigura el token en ~/.bashrc o ~/.zshrc:\nexport GITEA_TOKEN='tu_token_aqui'"
}
EOF
exit 0
fi
# Función para verificar si las actions están habilitadas
check_actions_enabled() {
local repo_info=$(curl -s -H "Authorization: token $GITEA_TOKEN" \
"$GITEA_URL/api/v1/repos/$OWNER/$REPO")
echo "$repo_info" | jq -r '.has_actions // false'
}
# Función para consultar el estado de la última action
get_latest_action_status() {
local response=$(curl -s -H "Authorization: token $GITEA_TOKEN" \
"$GITEA_URL/api/v1/repos/$OWNER/$REPO/actions/tasks?limit=1")
# Verificar si hay un error de permisos
if echo "$response" | jq -e '.message' > /dev/null 2>&1; then
echo "ERROR: $(echo "$response" | jq -r '.message')" >&2
echo ""
return 1
fi
echo "$response" | jq -r '.workflow_runs[0] // .data[0] // empty'
}
# Función para formatear el resultado
format_result() {
local status="$1"
local task_data="$2"
local id=$(echo "$task_data" | jq -r '.id // "N/A"')
local workflow_name=$(echo "$task_data" | jq -r '.name // "N/A"')
local workflow_file=$(echo "$task_data" | jq -r '.workflow_id // "N/A"')
local run_number=$(echo "$task_data" | jq -r '.run_number // "N/A"')
local event=$(echo "$task_data" | jq -r '.event // "N/A"')
local branch=$(echo "$task_data" | jq -r '.head_branch // "N/A"')
local title=$(echo "$task_data" | jq -r '.display_title // "N/A"')
local created=$(echo "$task_data" | jq -r '.created_at // "N/A"')
local started=$(echo "$task_data" | jq -r '.run_started_at // .started_at // "N/A"')
local updated=$(echo "$task_data" | jq -r '.updated_at // .stopped_at // "N/A"')
local commit=$(echo "$task_data" | jq -r '.head_sha[0:8] // "N/A"')
local run_url=$(echo "$task_data" | jq -r '.url // ""')
# Calcular duración si es posible
local duration="N/A"
if [[ "$started" != "N/A" && "$updated" != "N/A" ]]; then
local start_ts=$(date -d "$started" +%s 2>/dev/null || echo "0")
local end_ts=$(date -d "$updated" +%s 2>/dev/null || echo "0")
if [[ $start_ts -gt 0 && $end_ts -gt 0 ]]; then
local diff=$((end_ts - start_ts))
if [[ $diff -lt 60 ]]; then
duration="${diff}s"
else
local mins=$((diff / 60))
local secs=$((diff % 60))
duration="${mins}m ${secs}s"
fi
fi
fi
case "$status" in
success)
local emoji="✅"
local msg="EXITOSO"
;;
failure)
local emoji="❌"
local msg="FALLÓ"
;;
cancelled)
local emoji="🚫"
local msg="CANCELADO"
;;
*)
local emoji="⚠️"
local msg="DESCONOCIDO ($status)"
;;
esac
# Construir URL de logs si no está disponible
if [[ -z "$run_url" || "$run_url" == "null" ]]; then
run_url="$GITEA_URL/$OWNER/$REPO/actions/runs/$id"
fi
cat <<EOF
{
"decision": "block",
"reason": "$emoji Gitea Action completada: $msg\n\n📋 Detalles:\n • Workflow: $workflow_name ($workflow_file)\n • Run #$run_number\n • Evento: $event\n • Branch: $branch\n • Commit: $commit\n • Título: $title\n • Duración: $duration\n • Iniciado: $started\n • Finalizado: $updated\n\n🔗 Ver logs completos:\n $run_url"
}
EOF
}
# Verificar si las actions están habilitadas
echo "🔍 Verificando si Actions están habilitadas..." >&2
ACTIONS_ENABLED=$(check_actions_enabled)
if [[ "$ACTIONS_ENABLED" != "true" ]]; then
cat <<EOF
{
"decision": "block",
"reason": "⚠️ Git push exitoso, pero las Gitea Actions NO están habilitadas en este repositorio.\n\n📝 Para habilitar Actions:\n1. Ve a: $GITEA_URL/$OWNER/$REPO/settings\n2. Busca la sección 'Actions' o 'Workflows'\n3. Activa las Actions\n\nLuego podrás ver tus workflows en:\n$GITEA_URL/$OWNER/$REPO/actions"
}
EOF
exit 0
fi
# Notificar que empezamos a monitorear
echo "🔄 Monitoreando Gitea Action (máximo ${MAX_WAIT_SECONDS}s)..." >&2
# Esperar un poco antes de la primera consulta (dar tiempo a que Gitea cree la action)
sleep 5
# Polling loop
elapsed=0
while [ $elapsed -lt $MAX_WAIT_SECONDS ]; do
# Consultar el estado
TASK_DATA=$(get_latest_action_status)
if [ -z "$TASK_DATA" ]; then
echo "⏳ Esperando que Gitea cree la action... (${elapsed}s)" >&2
sleep $POLL_INTERVAL
elapsed=$((elapsed + POLL_INTERVAL))
continue
fi
STATUS=$(echo "$TASK_DATA" | jq -r '.status // "unknown"')
echo "📊 Estado actual: $STATUS (${elapsed}s)" >&2
# Verificar si terminó
case "$STATUS" in
success|failure|cancelled)
# Action terminó!
format_result "$STATUS" "$TASK_DATA"
exit 0
;;
running|pending|waiting)
# Todavía corriendo
sleep $POLL_INTERVAL
elapsed=$((elapsed + POLL_INTERVAL))
;;
*)
# Estado desconocido
echo "⚠️ Estado desconocido: $STATUS" >&2
sleep $POLL_INTERVAL
elapsed=$((elapsed + POLL_INTERVAL))
;;
esac
done
# Timeout alcanzado
cat <<EOF
{
"decision": "block",
"reason": "⏱️ Timeout: La Gitea Action todavía está corriendo después de ${MAX_WAIT_SECONDS}s.\n\n🔗 Verifica el estado manualmente en:\n$GITEA_URL/$OWNER/$REPO/actions"
}
EOF

View File

@@ -13,6 +13,10 @@
<div class="header-controls"> <div class="header-controls">
<AuthIndicator /> <AuthIndicator />
<SearchFilter
:results-count="filteredCount"
@search="$emit('search', $event)"
/>
<PlaybackControls <PlaybackControls
@shuffle-changed="$emit('shuffle-changed', $event)" @shuffle-changed="$emit('shuffle-changed', $event)"
@repeat-changed="$emit('repeat-changed', $event)" @repeat-changed="$emit('repeat-changed', $event)"
@@ -38,6 +42,7 @@ import { Music } from 'lucide-vue-next'
import AuthIndicator from './AuthIndicator.client.vue' import AuthIndicator from './AuthIndicator.client.vue'
import ThemeToggle from './ThemeToggle.client.vue' import ThemeToggle from './ThemeToggle.client.vue'
import PlaybackControls from './PlaybackControls.client.vue' import PlaybackControls from './PlaybackControls.client.vue'
import SearchFilter from './SearchFilter.client.vue'
const props = defineProps({ const props = defineProps({
currentTrack: { currentTrack: {
@@ -47,10 +52,14 @@ const props = defineProps({
isPlaying: { isPlaying: {
type: Boolean, type: Boolean,
default: false default: false
},
filteredCount: {
type: Number,
default: null
} }
}) })
defineEmits(['shuffle-changed', 'repeat-changed']) defineEmits(['shuffle-changed', 'repeat-changed', 'search'])
// Computed property to determine if there's an active track // Computed property to determine if there's an active track
const hasActiveTrack = computed(() => !!props.currentTrack) const hasActiveTrack = computed(() => !!props.currentTrack)

View File

@@ -0,0 +1,242 @@
<template>
<div class="search-filter">
<div class="search-container" :class="{ expanded: isExpanded }">
<IconButton
:icon="Search"
title="Buscar canciones"
size="small"
@click="toggleSearch"
class="search-btn"
:active="isExpanded || searchQuery.length > 0"
/>
<transition name="search-input">
<div v-if="isExpanded" class="input-wrapper">
<input
ref="searchInput"
v-model="searchQuery"
type="text"
placeholder="Buscar por nombre..."
class="search-input"
@blur="handleBlur"
@keydown.escape="closeSearch"
/>
<button
v-if="searchQuery.length > 0"
@click="clearSearch"
class="clear-btn"
title="Limpiar búsqueda"
>
<X :size="16" />
</button>
</div>
</transition>
</div>
<transition name="results-badge">
<div v-if="searchQuery.length > 0 && resultsCount !== null" class="results-badge">
{{ resultsCount }} resultado{{ resultsCount !== 1 ? 's' : '' }}
</div>
</transition>
</div>
</template>
<script setup>
import { ref, watch, nextTick } from 'vue'
import { Search, X } from 'lucide-vue-next'
import IconButton from './IconButton.client.vue'
const props = defineProps({
resultsCount: {
type: Number,
default: null
}
})
const emit = defineEmits(['search'])
const isExpanded = ref(false)
const searchQuery = ref('')
const searchInput = ref(null)
const toggleSearch = async () => {
isExpanded.value = !isExpanded.value
if (isExpanded.value) {
await nextTick()
searchInput.value?.focus()
} else if (searchQuery.value.length === 0) {
// Solo colapsar si no hay búsqueda activa
emit('search', '')
}
}
const closeSearch = () => {
if (searchQuery.value.length === 0) {
isExpanded.value = false
}
}
const handleBlur = () => {
// Mantener expandido si hay una búsqueda activa
if (searchQuery.value.length === 0) {
setTimeout(() => {
isExpanded.value = false
}, 200)
}
}
const clearSearch = () => {
searchQuery.value = ''
searchInput.value?.focus()
}
// Emitir el término de búsqueda cuando cambie
watch(searchQuery, (newValue) => {
emit('search', newValue.toLowerCase().trim())
})
</script>
<style scoped>
.search-filter {
position: relative;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8px;
}
.search-container {
display: flex;
align-items: center;
gap: 8px;
position: relative;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.search-btn:hover {
animation: pulse-search 0.6s ease-in-out;
}
.input-wrapper {
position: relative;
display: flex;
align-items: center;
gap: 4px;
}
.search-input {
width: 200px;
padding: 8px 32px 8px 12px;
background: rgba(255, 255, 255, 0.08);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 12px;
color: var(--text-primary);
font-size: 0.9rem;
outline: none;
transition: all 0.3s ease;
}
.search-input::placeholder {
color: var(--text-secondary);
opacity: 0.7;
}
.search-input:focus {
background: rgba(255, 255, 255, 0.12);
border-color: var(--accent-primary);
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
}
.clear-btn {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 4px;
border-radius: 50%;
transition: all 0.2s ease;
}
.clear-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: var(--text-primary);
}
.results-badge {
font-size: 0.75rem;
color: var(--text-secondary);
background: var(--bg-glass);
padding: 4px 10px;
border-radius: 12px;
border: 1px solid var(--border-glass);
white-space: nowrap;
}
/* Animations */
.search-input-enter-active,
.search-input-leave-active {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.search-input-enter-from {
opacity: 0;
transform: translateX(-10px);
width: 0;
}
.search-input-leave-to {
opacity: 0;
transform: translateX(-10px);
width: 0;
}
.results-badge-enter-active,
.results-badge-leave-active {
transition: all 0.2s ease;
}
.results-badge-enter-from,
.results-badge-leave-to {
opacity: 0;
transform: translateY(-5px);
}
@keyframes pulse-search {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.1); }
}
/* Responsive design */
@media (max-width: 768px) {
.search-input {
width: 160px;
font-size: 0.85rem;
padding: 6px 28px 6px 10px;
}
.results-badge {
font-size: 0.7rem;
padding: 3px 8px;
}
}
@media (max-width: 520px) {
.search-filter {
align-items: center;
}
.search-input {
width: 140px;
}
}
</style>

View File

@@ -10,8 +10,10 @@
<MainContainer <MainContainer
:current-track="currentTrack" :current-track="currentTrack"
:is-playing="isPlaying" :is-playing="isPlaying"
:filtered-count="filteredCount"
@shuffle-changed="handleShuffleChanged" @shuffle-changed="handleShuffleChanged"
@repeat-changed="handleRepeatChanged" @repeat-changed="handleRepeatChanged"
@search="handleSearch"
> >
<!-- Track List --> <!-- Track List -->
<TrackList <TrackList
@@ -91,16 +93,41 @@ const isShuffled = useLocalStorage('shuffle', false)
const repeatMode = useLocalStorage('repeat', 'none') // 'none', 'all', 'one' const repeatMode = useLocalStorage('repeat', 'none') // 'none', 'all', 'one'
const shuffledIndices = ref([]) const shuffledIndices = ref([])
// Search state
const searchQuery = ref('')
// Refs // Refs
const audioPlayer = ref(null) const audioPlayer = ref(null)
// Computed - removed progressPercent as it's not used in this component // Computed - removed progressPercent as it's not used in this component
const displayTracks = computed(() => { const displayTracks = computed(() => {
if (isShuffled.value && shuffledIndices.value.length > 0) { let tracksToDisplay = tracks.value
return shuffledIndices.value.map(index => tracks.value[index])
// Aplicar filtro de búsqueda
if (searchQuery.value.length > 0) {
tracksToDisplay = tracksToDisplay.filter(track =>
track.name.toLowerCase().includes(searchQuery.value)
)
} }
return tracks.value
// Aplicar shuffle si está activado
if (isShuffled.value && shuffledIndices.value.length > 0) {
const shuffledTracks = shuffledIndices.value
.map(index => tracks.value[index])
.filter(track =>
searchQuery.value.length === 0 ||
track.name.toLowerCase().includes(searchQuery.value)
)
return shuffledTracks
}
return tracksToDisplay
})
const filteredCount = computed(() => {
if (searchQuery.value.length === 0) return null
return displayTracks.value.length
}) })
// Methods // Methods
@@ -293,6 +320,10 @@ const handleRepeatChanged = (mode) => {
repeatMode.value = mode repeatMode.value = mode
} }
const handleSearch = (query) => {
searchQuery.value = query
}
const handleTrackSelected = ({ track, index }) => { const handleTrackSelected = ({ track, index }) => {
playTrack(track, index) playTrack(track, index)
} }