Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,17 +60,17 @@ The goal is to have a browsable, usable site where users can find and play songs

### 4. Discovery & Search Engine
*Helping users find specific content or explore new music.*
- [ ] **Search Page (`pages/discovery.vue` or `/search`)**:
- [x] **Search Page (`pages/discovery.vue` or `/search`)**:
- Accept query parameters (`?q=`, `?genre=`, `?key=`).
- Implement a layout with Filters Sidebar (Desktop) / Drawer (Mobile) and Results Grid.
- [ ] **Filtering Logic**:
- [x] **Filtering Logic**:
- Filter by Genre (Pop, Folklore, etc.), Key (Cm, Am...), and Rhythm.
- Sort options (Newest, Most Viewed, A-Z).
- [ ] **Results Grid**:
- [x] **Results Grid**:
- Efficient pagination or Infinite Scroll for large result sets.
- Re-use `SongCard` with optimized props for list views.
- "No Results" empty state with a "Request Song" CTA.
- [ ] **Artists Index (`pages/artists/index.vue`)**:
- [x] **Artists Index (`pages/artists/index.vue`)**:
- A simple, paginated grid of all available artists, sortable by name or popularity.

---
Expand Down
1 change: 0 additions & 1 deletion end_user/components/widget/LanguageSwitcher.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { Languages } from 'lucide-vue-next'
import type { UILanguageOption } from '~/stores/contentLanguage'
import { useAppConfigStore } from '~/stores/appConfig'

const { locale } = useI18n()
const appConfig = useAppConfigStore()

const languages: { code: UILanguageOption; label: string; nativeLabel: string }[] = [
Expand Down
102 changes: 23 additions & 79 deletions end_user/components/widget/song/LanguageSwitcher.vue
Original file line number Diff line number Diff line change
@@ -1,113 +1,57 @@
<template>
<div class="language-switcher" v-if="availableLangs.length > 1">
<div class="switcher-label">{{ t('song.language') || 'Language' }}:</div>
<div class="switcher-buttons">
<button
v-for="lang in availableLangs"
:key="lang"
:class="{
active: currentLang === lang,
unavailable: !isLangAvailable(lang)
}"
@click="switchToLang(lang)"
:title="getLangLabel(lang)"
>
{{ getLangCode(lang) }}
</button>
</div>
<div v-if="availableLangs.length > 1" class="flex justify-center mb-4 language-switcher-wrapper" dir="rtl">
<SegmentedControl :model-value="currentLang" :options="languageOptions" size="md"
@update:model-value="switchToLang" />
</div>
</template>

<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import type { LanguageCode } from '~/constants/routes'
import { ROUTES } from '~/constants/routes'
import SegmentedControl from '~/components/base/SegmentedControl.vue'

const props = defineProps<{
availableLangs: LanguageCode[]
currentLang: LanguageCode
songId: string
}>()

const route = useRoute()
const router = useRouter()
const { t } = useI18n()

const languageOptions = computed(() => {
return props.availableLangs.map((lang) => ({
value: lang,
label: getLangLabel(lang),
}))
})

const switchToLang = (lang: LanguageCode) => {
if (!props.availableLangs.includes(lang)) return
const newPath = lang === 'ckb-IR'

const newPath = lang === 'ckb-IR'
? ROUTES.TAB.DETAIL(props.songId) // Default: no lang in URL
: ROUTES.TAB.DETAIL(props.songId, lang)

router.push(newPath)
}

const getLangLabel = (lang: LanguageCode) => {
const labels = {
'ckb-IR': 'Ψ³ΩˆΨ±Ψ§Ω†ΫŒ (Ψ§ΫŒΨ±Ψ§Ω†)',
'ckb-Latn': 'Ψ³ΩˆΨ±Ψ§Ω†ΫŒ (Ω„Ψ§ΨͺΫŒΩ†)',
'kmr': 'Ϊ©Ψ±Ω…Ψ§Ω†Ψ¬ΫŒ',
}
return labels[lang] || lang
}

const getLangCode = (lang: LanguageCode) => {
// Show short code for UI
return lang.split('-')[0].toUpperCase()
router.push(newPath)
}

const isLangAvailable = (lang: LanguageCode) => {
return props.availableLangs.includes(lang)
const getLangLabel = (lang: LanguageCode): string => {
const translation = t(`song.languages.${lang}`)
return (translation as string) || lang
}
</script>

<style scoped>
.language-switcher {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem;
background: var(--surface-card, #f9fafb);
border-radius: 0.5rem;
margin-bottom: 1rem;
}

.switcher-label {
font-weight: 500;
color: var(--text-secondary, #6b7280);
.language-switcher-wrapper :deep(button) {
white-space: nowrap;
min-width: fit-content;
}

.switcher-buttons {
display: flex;
gap: 0.25rem;
}

.switcher-buttons button {
padding: 0.5rem 1rem;
border: 1px solid var(--border, #e5e7eb);
background: var(--surface-base, #ffffff);
border-radius: 0.25rem;
cursor: pointer;
transition: all 0.2s;
font-weight: 500;
}

.switcher-buttons button:hover {
background: var(--surface-hover, #f3f4f6);
}

.switcher-buttons button.active {
background: var(--brand-primary, #3b82f6);
color: white;
border-color: var(--brand-primary, #3b82f6);
}

.switcher-buttons button.unavailable {
opacity: 0.5;
cursor: not-allowed;
.language-switcher-wrapper :deep(span) {
white-space: nowrap;
}
</style>

57 changes: 57 additions & 0 deletions end_user/composables/useBaseUrl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/**
* Composable to get the base URL dynamically based on the request
* Works for both server-side (SSR) and client-side rendering
* Handles Nginx proxy headers (x-forwarded-*) and direct requests
* Returns a computed ref that updates reactively
*/
export const useBaseUrl = () => {
// Lazy initialization - only access Nuxt composables when computed is accessed
return computed(() => {
// Client-side: Use window.location
if (import.meta.client && typeof window !== 'undefined') {
return window.location.origin
}

// Server-side: Try to get from headers
if (import.meta.server) {
try {
// useRequestHeaders() is safer and handles context checking
const headers = useRequestHeaders(['host', 'x-forwarded-host', 'x-forwarded-proto'])

// Check for Nginx proxy headers first
const protocol = headers['x-forwarded-proto'] || 'https'
const host = headers['x-forwarded-host'] ||
headers['host'] ||
'goranee.ir'

// Remove port if it's standard (80 for http, 443 for https)
const hostWithoutPort = host.split(':')[0]
const port = host.includes(':') ? host.split(':')[1] : null

// Only include port if it's non-standard
let baseUrl = `${protocol}://${hostWithoutPort}`
if (port && port !== '80' && port !== '443') {
baseUrl = `${protocol}://${hostWithoutPort}:${port}`
}

return baseUrl
} catch (e) {
// Headers not available in this context, will use fallback
}
}

// Fallback: Try runtime config
try {
const config = useRuntimeConfig()
const envBaseUrl = config.public.baseUrl as string | undefined
if (envBaseUrl) {
return envBaseUrl
}
} catch (e) {
// Runtime config not available, use default
}

// Final fallback
return 'https://goranee.ir'
})
}
53 changes: 31 additions & 22 deletions end_user/composables/useImageUrl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,35 @@ import { fileProvider } from '@modular-rest/client'
* (not internal Docker URLs like http://server:8081)
*/
export const useImageUrl = () => {
// Get runtime config at composable initialization (valid Nuxt context)
let ssrBaseUrl: string | undefined = undefined

try {
if (!process.client) {
// Server-side (SSR): Use ssrApiBaseUrl from runtime config
// This is set at build time via NUXT_SSR_API_BASE_URL build arg in Dockerfile
const config = useRuntimeConfig()
const configBaseUrl = config.public.ssrApiBaseUrl

if (configBaseUrl && typeof configBaseUrl === 'string' && configBaseUrl.trim()) {
ssrBaseUrl = configBaseUrl.trim().replace(/\/$/, '')
}
}
} catch (e) {
// Runtime config not available, will use fallback
}

// Fallback: Try reading from process.env (for development or if runtime config isn't set)
if (!ssrBaseUrl && !process.client) {
const envBaseUrl = process.env.NUXT_SSR_API_BASE_URL || process.env.VITE_SSR_API_BASE_URL
if (envBaseUrl && typeof envBaseUrl === 'string' && envBaseUrl.trim()) {
ssrBaseUrl = envBaseUrl.trim().replace(/\/$/, '')
console.warn('[useImageUrl] Using SSR base URL from process.env (fallback):', ssrBaseUrl)
} else {
console.error('[useImageUrl] NUXT_SSR_API_BASE_URL is not configured. Image URLs will use internal Docker URL which is not accessible to clients.')
}
}

/**
* Get a public-facing image URL for client-side use
* During SSR, uses NUXT_SSR_API_BASE_URL to generate URLs accessible to browsers
Expand All @@ -14,30 +43,10 @@ export const useImageUrl = () => {
const getImageUrl = (file: any): string | undefined => {
if (!file) return undefined

let baseUrl: string | undefined = undefined

if (!process.client) {
// Server-side (SSR): Use ssrApiBaseUrl from runtime config
// This is set at build time via NUXT_SSR_API_BASE_URL build arg in Dockerfile
// The value is baked into the build, so it's available via runtime config
const config = useRuntimeConfig()
const ssrBaseUrl = config.public.ssrApiBaseUrl

if (ssrBaseUrl && typeof ssrBaseUrl === 'string' && ssrBaseUrl.trim()) {
baseUrl = ssrBaseUrl.trim().replace(/\/$/, '')
} else {
// Fallback: Try reading from process.env (for development or if runtime config isn't set)
const envBaseUrl = process.env.NUXT_SSR_API_BASE_URL || process.env.VITE_SSR_API_BASE_URL
if (envBaseUrl && typeof envBaseUrl === 'string' && envBaseUrl.trim()) {
baseUrl = envBaseUrl.trim().replace(/\/$/, '')
console.warn('[useImageUrl] Using SSR base URL from process.env (fallback):', baseUrl)
} else {
console.error('[useImageUrl] NUXT_SSR_API_BASE_URL is not configured. Image URLs will use internal Docker URL which is not accessible to clients.')
}
}
}
// Server-side: Use pre-fetched ssrBaseUrl
// Client-side: baseUrl is undefined, so fileProvider will use GlobalOptions.host
// which is set to window.location.origin + /api/ in the plugin
const baseUrl = !process.client ? ssrBaseUrl : undefined

return fileProvider.getFileLink(file, baseUrl)
}
Expand Down
Loading