feat: agregar búsqueda de canciones y configurar hook de Gitea Actions
- 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:
197
.claude/hooks/monitor-gitea-action.sh
Executable file
197
.claude/hooks/monitor-gitea-action.sh
Executable 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
|
||||
@@ -13,6 +13,10 @@
|
||||
|
||||
<div class="header-controls">
|
||||
<AuthIndicator />
|
||||
<SearchFilter
|
||||
:results-count="filteredCount"
|
||||
@search="$emit('search', $event)"
|
||||
/>
|
||||
<PlaybackControls
|
||||
@shuffle-changed="$emit('shuffle-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 ThemeToggle from './ThemeToggle.client.vue'
|
||||
import PlaybackControls from './PlaybackControls.client.vue'
|
||||
import SearchFilter from './SearchFilter.client.vue'
|
||||
|
||||
const props = defineProps({
|
||||
currentTrack: {
|
||||
@@ -47,10 +52,14 @@ const props = defineProps({
|
||||
isPlaying: {
|
||||
type: Boolean,
|
||||
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
|
||||
const hasActiveTrack = computed(() => !!props.currentTrack)
|
||||
|
||||
242
components/SearchFilter.client.vue
Normal file
242
components/SearchFilter.client.vue
Normal 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>
|
||||
@@ -10,8 +10,10 @@
|
||||
<MainContainer
|
||||
:current-track="currentTrack"
|
||||
:is-playing="isPlaying"
|
||||
:filtered-count="filteredCount"
|
||||
@shuffle-changed="handleShuffleChanged"
|
||||
@repeat-changed="handleRepeatChanged"
|
||||
@search="handleSearch"
|
||||
>
|
||||
<!-- Track List -->
|
||||
<TrackList
|
||||
@@ -91,16 +93,41 @@ const isShuffled = useLocalStorage('shuffle', false)
|
||||
const repeatMode = useLocalStorage('repeat', 'none') // 'none', 'all', 'one'
|
||||
const shuffledIndices = ref([])
|
||||
|
||||
// Search state
|
||||
const searchQuery = ref('')
|
||||
|
||||
// Refs
|
||||
const audioPlayer = ref(null)
|
||||
|
||||
// Computed - removed progressPercent as it's not used in this component
|
||||
|
||||
const displayTracks = computed(() => {
|
||||
if (isShuffled.value && shuffledIndices.value.length > 0) {
|
||||
return shuffledIndices.value.map(index => tracks.value[index])
|
||||
let tracksToDisplay = tracks.value
|
||||
|
||||
// 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
|
||||
@@ -293,6 +320,10 @@ const handleRepeatChanged = (mode) => {
|
||||
repeatMode.value = mode
|
||||
}
|
||||
|
||||
const handleSearch = (query) => {
|
||||
searchQuery.value = query
|
||||
}
|
||||
|
||||
const handleTrackSelected = ({ track, index }) => {
|
||||
playTrack(track, index)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user