From 8810585c80c7f5c5736a580d740230aeeb7222cb Mon Sep 17 00:00:00 2001 From: Navid Shad Date: Thu, 11 Dec 2025 13:40:52 +0200 Subject: [PATCH 1/5] chore: Clean up workspace configuration and update roadmap progress - Remove the chord_library path from the workspace configuration. - Mark several tasks as completed in the roadmap, including the Search Page, Filtering Logic, Results Grid, and Artists Index, reflecting progress on discovery and search engine features. --- docs/ROADMAP.md | 8 ++++---- goranee_v2.code-workspace | 4 ---- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index c4265a7..7f6796d 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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. --- diff --git a/goranee_v2.code-workspace b/goranee_v2.code-workspace index e1bd65a..a40f78c 100644 --- a/goranee_v2.code-workspace +++ b/goranee_v2.code-workspace @@ -11,10 +11,6 @@ }, { "path": "admin_panel" - }, - { - "name": "chord_library", - "path": "/Users/navid-shad/Projects/Personal/chord_library" } ] } From c933583690d6f2834c90e9e768bccbec18fe27f8 Mon Sep 17 00:00:00 2001 From: Navid Shad Date: Thu, 11 Dec 2025 16:40:25 +0200 Subject: [PATCH 2/5] refactor: #86evuzm61 Update LanguageSwitcher components to improve language selection UI - Replace button-based language selection with a SegmentedControl for better user experience. - Simplify language label retrieval using i18n for dynamic translations. - Remove deprecated code and streamline the component structure for clarity. --- .../components/widget/LanguageSwitcher.vue | 1 - .../widget/song/LanguageSwitcher.vue | 102 ++++-------------- 2 files changed, 23 insertions(+), 80 deletions(-) diff --git a/end_user/components/widget/LanguageSwitcher.vue b/end_user/components/widget/LanguageSwitcher.vue index eed4beb..cae0297 100644 --- a/end_user/components/widget/LanguageSwitcher.vue +++ b/end_user/components/widget/LanguageSwitcher.vue @@ -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 }[] = [ diff --git a/end_user/components/widget/song/LanguageSwitcher.vue b/end_user/components/widget/song/LanguageSwitcher.vue index 11b21b5..1895ec0 100644 --- a/end_user/components/widget/song/LanguageSwitcher.vue +++ b/end_user/components/widget/song/LanguageSwitcher.vue @@ -1,29 +1,17 @@ - From d08bb89f131efdc6040627d75a3917107fc71983 Mon Sep 17 00:00:00 2001 From: Navid Shad Date: Thu, 11 Dec 2025 22:43:32 +0200 Subject: [PATCH 3/5] feat: #86evuzm61 Implement dynamic base URL handling and enhance SEO metadata across pages - Introduced a new composable `useBaseUrl` to dynamically determine the base URL for both server-side and client-side rendering, improving API integration. - Updated `nuxt.config.ts` to utilize the new base URL logic. - Enhanced SEO metadata management in various pages (`discovery.vue`, `index.vue`, `artist/[id]/[[lang]].vue`, `tab/[id]/[[lang]].vue`) to improve search engine visibility and user engagement. - Added structured data support for better indexing by search engines. - Implemented middleware for setting Last-Modified headers to optimize caching and improve performance. --- end_user/composables/useBaseUrl.ts | 57 ++++++ end_user/composables/useImageUrl.ts | 53 ++--- end_user/i18n/locales/english.json | 6 + end_user/i18n/locales/farsi.json | 6 + end_user/i18n/locales/kmr.json | 6 + end_user/i18n/locales/sorani-farsi.json | 6 + end_user/i18n/locales/sorani-latin.json | 6 + end_user/nuxt.config.ts | 2 +- end_user/pages/artist/[id]/[[lang]].vue | 85 ++++++-- end_user/pages/discovery.vue | 29 +++ end_user/pages/index.vue | 16 ++ end_user/pages/tab/[id]/[[lang]].vue | 185 ++++++++++++++---- end_user/server/middleware/seo-headers.ts | 120 ++++++++++++ end_user/server/routes/sitemap.xml.ts | 20 +- server/src/modules/chords/db_events.ts | 15 +- server/src/modules/chords/db_song.ts | 75 ++++--- server/src/modules/chords/indexing_service.ts | 93 +++++++++ 17 files changed, 671 insertions(+), 109 deletions(-) create mode 100644 end_user/composables/useBaseUrl.ts create mode 100644 end_user/server/middleware/seo-headers.ts create mode 100644 server/src/modules/chords/indexing_service.ts diff --git a/end_user/composables/useBaseUrl.ts b/end_user/composables/useBaseUrl.ts new file mode 100644 index 0000000..e114290 --- /dev/null +++ b/end_user/composables/useBaseUrl.ts @@ -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' + }) +} diff --git a/end_user/composables/useImageUrl.ts b/end_user/composables/useImageUrl.ts index f7aa453..9726adc 100644 --- a/end_user/composables/useImageUrl.ts +++ b/end_user/composables/useImageUrl.ts @@ -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 @@ -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) } diff --git a/end_user/i18n/locales/english.json b/end_user/i18n/locales/english.json index 5820914..4d70e3d 100644 --- a/end_user/i18n/locales/english.json +++ b/end_user/i18n/locales/english.json @@ -241,6 +241,12 @@ "of": "of" }, "song": { + "language": "Language", + "languages": { + "ckb-IR": "سورانی (ئێران)", + "ckb-Latn": "Soranî (Latînî)", + "kmr": "Kurmancî" + }, "metadata": { "key": "Key", "rhythm": "Rhythm", diff --git a/end_user/i18n/locales/farsi.json b/end_user/i18n/locales/farsi.json index da093fd..4d040df 100644 --- a/end_user/i18n/locales/farsi.json +++ b/end_user/i18n/locales/farsi.json @@ -241,6 +241,12 @@ "of": "از" }, "song": { + "language": "زبان", + "languages": { + "ckb-IR": "سورانی (ئێران)", + "ckb-Latn": "Soranî (Latînî)", + "kmr": "Kurmancî" + }, "metadata": { "key": "کلید", "rhythm": "ریتم", diff --git a/end_user/i18n/locales/kmr.json b/end_user/i18n/locales/kmr.json index 62f689c..5d45398 100644 --- a/end_user/i18n/locales/kmr.json +++ b/end_user/i18n/locales/kmr.json @@ -241,6 +241,12 @@ "of": "ji" }, "song": { + "language": "Ziman", + "languages": { + "ckb-IR": "سورانی (ئێران)", + "ckb-Latn": "Soranî (Latînî)", + "kmr": "Kurmancî" + }, "metadata": { "key": "Kî", "rhythm": "Rîtm", diff --git a/end_user/i18n/locales/sorani-farsi.json b/end_user/i18n/locales/sorani-farsi.json index ca503ad..7179f11 100644 --- a/end_user/i18n/locales/sorani-farsi.json +++ b/end_user/i18n/locales/sorani-farsi.json @@ -241,6 +241,12 @@ "of": "لە" }, "song": { + "language": "زمان", + "languages": { + "ckb-IR": "سورانی (ئێران)", + "ckb-Latn": "Soranî (Latînî)", + "kmr": "Kurmancî" + }, "metadata": { "key": "پلی دەنگ", "rhythm": "ڕیتم", diff --git a/end_user/i18n/locales/sorani-latin.json b/end_user/i18n/locales/sorani-latin.json index 612bcb4..5c4a317 100644 --- a/end_user/i18n/locales/sorani-latin.json +++ b/end_user/i18n/locales/sorani-latin.json @@ -241,6 +241,12 @@ "of": "le" }, "song": { + "language": "Ziman", + "languages": { + "ckb-IR": "سورانی (ئێران)", + "ckb-Latn": "Soranî (Latînî)", + "kmr": "Kurmancî" + }, "metadata": { "key": "Pley Deng", "rhythm": "Rîtm", diff --git a/end_user/nuxt.config.ts b/end_user/nuxt.config.ts index 9d2c9b5..013a53e 100644 --- a/end_user/nuxt.config.ts +++ b/end_user/nuxt.config.ts @@ -23,7 +23,7 @@ export default defineNuxtConfig({ // @ts-ignore apiBaseUrl: process.env.NUXT_API_BASE_URL || process.env.VITE_API_BASE_URL || '/api/', // @ts-ignore - ssrApiBaseUrl: process.env.NUXT_SSR_API_BASE_URL || process.env.VITE_SSR_API_BASE_URL, + baseUrl: process.env.NUXT_PUBLIC_BASE_URL || process.env.BASE_URL || 'https://goranee.ir', }, }, diff --git a/end_user/pages/artist/[id]/[[lang]].vue b/end_user/pages/artist/[id]/[[lang]].vue index 4533514..0063793 100644 --- a/end_user/pages/artist/[id]/[[lang]].vue +++ b/end_user/pages/artist/[id]/[[lang]].vue @@ -154,13 +154,46 @@ const artistName = computed(() => { return langContent?.name || artist.value.content?.['ckb-IR']?.name || '' }); -// SEO -useHead({ +// SEO: Meta tags +const baseUrl = useBaseUrl() +const artistDescription = computed(() => { + if (!artist.value) return 'Goranee - Kurdish Chords Platform' + const langContent = artist.value.content?.[langCode.value] || artist.value.content?.['ckb-IR'] + const bio = langContent?.bio || '' + const songCount = songsCount.value + return bio + ? `${bio} | ${songCount} ${t('common.songs')} | Goranee` + : `${artistName.value} - ${songCount} ${t('common.songs')} | Goranee` +}) +const artistImageUrl = computed(() => { + if (!artistImage.value) return `${baseUrl.value}/favicon.ico` + return artistImage.value +}) +const canonicalUrl = computed(() => { + return langCode.value === 'ckb-IR' + ? `${baseUrl.value}/artist/${artistId.value}` + : `${baseUrl.value}/artist/${artistId.value}/${langCode.value}` +}) + +useSeoMeta({ title: computed(() => artistName.value ? `${artistName.value} - Goranee` : t('common.artist')), + description: artistDescription, + ogTitle: artistName, + ogDescription: artistDescription, + ogImage: artistImageUrl, + ogType: 'profile', + ogUrl: canonicalUrl, + twitterCard: 'summary_large_image', + twitterTitle: artistName, + twitterDescription: artistDescription, + twitterImage: artistImageUrl, +}) + +// SEO: Structured Data (JSON-LD) and hreflang/canonical +useHead({ link: computed(() => { if (!artist.value) return [] - const baseUrl = 'https://goranee.ir' const links: any[] = [] // Get available languages for artist @@ -172,8 +205,8 @@ useHead({ // Add hreflang for each available language availableLangs.forEach(lang => { const url = lang === 'ckb-IR' - ? `${baseUrl}/artist/${artistId.value}` - : `${baseUrl}/artist/${artistId.value}/${lang}` + ? `${baseUrl.value}/artist/${artistId.value}` + : `${baseUrl.value}/artist/${artistId.value}/${lang}` links.push({ rel: 'alternate', @@ -186,22 +219,52 @@ useHead({ links.push({ rel: 'alternate', hreflang: 'x-default', - href: `${baseUrl}/artist/${artistId.value}`, + href: `${baseUrl.value}/artist/${artistId.value}`, }) } // Add canonical URL - const canonicalUrl = langCode.value === 'ckb-IR' - ? `${baseUrl}/artist/${artistId.value}` - : `${baseUrl}/artist/${artistId.value}/${langCode.value}` - links.push({ rel: 'canonical', - href: canonicalUrl, + href: canonicalUrl.value, }) return links }), + script: computed(() => { + if (!artist.value) return [] + + const langContent = artist.value.content?.[langCode.value] || artist.value.content?.['ckb-IR'] + const bio = langContent?.bio || '' + + // Structured data with dateModified and dateCreated + const structuredData: any = { + '@context': 'https://schema.org', + '@type': 'Person', // Using Person for individual artists, could be MusicGroup for bands + name: artistName.value, + url: canonicalUrl.value, + ...(bio ? { description: bio } : {}), + ...(artist.value.image ? { + image: artistImageUrl.value, + } : {}), + } + + // Add timestamps if available + const artistWithTimestamps = artist.value as any + if (artistWithTimestamps.createdAt) { + structuredData.dateCreated = new Date(artistWithTimestamps.createdAt).toISOString() + } + if (artistWithTimestamps.updatedAt) { + structuredData.dateModified = new Date(artistWithTimestamps.updatedAt).toISOString() + } else if (artistWithTimestamps.createdAt) { + structuredData.dateModified = new Date(artistWithTimestamps.createdAt).toISOString() + } + + return [{ + type: 'application/ld+json', + children: JSON.stringify(structuredData), + }] + }), }); diff --git a/end_user/pages/discovery.vue b/end_user/pages/discovery.vue index bbfc7b4..f816e18 100644 --- a/end_user/pages/discovery.vue +++ b/end_user/pages/discovery.vue @@ -224,6 +224,35 @@ const navigateToSong = (id: string) => { router.push(ROUTES.TAB.DETAIL(id)) } +// SEO: Meta tags +const baseUrl = useBaseUrl() +const discoveryTitle = computed(() => { + if (hasQuery.value) { + return `${t('pages.discovery.searchResults')} "${searchQuery.value}" - Goranee` + } + return `${t('pages.discovery.title')} - Goranee` +}) +const discoveryDescription = computed(() => { + if (hasQuery.value) { + return `Search results for "${searchQuery.value}" on Goranee. Find Kurdish chord sheets and songs.` + } + return 'Discover and search Kurdish songs with chord sheets. Filter by genre, key, rhythm and find the perfect song to learn.' +}) + +useSeoMeta({ + title: discoveryTitle, + description: discoveryDescription, + ogTitle: discoveryTitle, + ogDescription: discoveryDescription, + ogImage: `${baseUrl.value}/favicon.ico`, + ogType: 'website', + ogUrl: computed(() => `${baseUrl.value}/discovery${route.query.q ? `?q=${route.query.q}` : ''}`), + twitterCard: 'summary_large_image', + twitterTitle: discoveryTitle, + twitterDescription: discoveryDescription, + twitterImage: `${baseUrl.value}/favicon.ico`, +}) +