├── .env-example ├── .github └── workflows │ └── playwright.yml ├── .gitignore ├── LICENSE ├── README.md ├── components.json ├── eslint.config.js ├── index.html ├── package-lock.json ├── package.json ├── playwright.config.ts ├── public ├── apple-touch-icon.png ├── assets │ ├── chromecast │ │ ├── bg.jpg │ │ ├── default.css │ │ └── logo.png │ ├── icons │ │ ├── icon-192x192.png │ │ ├── icon-512x512.png │ │ └── icon.svg │ ├── screenshots │ │ ├── screenshot-01-home-wide.jpg │ │ ├── screenshot-01-home-wide.png │ │ ├── screenshot-01-home.jpg │ │ ├── screenshot-01-home.png │ │ ├── screenshot-02-radio-wide.jpg │ │ ├── screenshot-02-radio-wide.png │ │ ├── screenshot-02-radio.jpg │ │ ├── screenshot-02-radio.png │ │ ├── screenshot-03-radio-page-wide.jpg │ │ ├── screenshot-03-radio-page-wide.png │ │ ├── screenshot-03-radio-page.jpg │ │ ├── screenshot-03-radio-page.png │ │ ├── screenshot-04-podcast-wide.jpg │ │ ├── screenshot-04-podcast-wide.png │ │ ├── screenshot-04-podcast.jpg │ │ └── screenshot-04-podcast.png │ ├── social-share.jpg │ └── social-share.png ├── favicon-16x16.png ├── favicon-32x32.png ├── favicon.ico ├── header-bg.jpg ├── icon-192x192-maskable.png ├── icon-192x192.png ├── icon-512x512-maskable.png ├── icon-512x512.png ├── icon.svg ├── logo-text-white.svg ├── logo-text.svg ├── manifest.json ├── mstile-150x150.png ├── safari-pinned-tab.svg ├── shortcut-playlists-192x192.png ├── shortcut-podcasts-192x192.png ├── shortcut-settings-192x192.png └── shortcut-stations-192x192.png ├── scripts ├── fetchPodcasts.js └── processRadioPodcasts.js ├── src ├── app.css ├── app.tsx ├── assets │ ├── data │ │ ├── featured │ │ │ └── podcasts.json │ │ ├── genres.json │ │ ├── languages.json │ │ ├── podcasts.json │ │ ├── stations.json │ │ └── stations │ │ │ └── podcasts.json │ └── icons │ │ └── tiktok.svg ├── components │ ├── appShell │ │ ├── appShell.tsx │ │ ├── toast │ │ │ ├── toast.tsx │ │ │ ├── toaster.tsx │ │ │ └── types.ts │ │ └── useAppShell.ts │ ├── content-section.tsx │ ├── day-timeline.tsx │ ├── image-background.tsx │ ├── loader.tsx │ ├── player │ │ ├── player.tsx │ │ └── usePlayer.ts │ ├── podcast-card.tsx │ ├── radio-station-card.tsx │ ├── share-button.tsx │ └── ui │ │ ├── badge.tsx │ │ ├── button.tsx │ │ ├── card.tsx │ │ ├── dropdown-list.tsx │ │ ├── input.tsx │ │ ├── radio-button-list.tsx │ │ ├── radio-button.tsx │ │ ├── switch.tsx │ │ └── tag-select.tsx ├── global.d.ts ├── hooks │ ├── useCastApi.ts │ ├── useHead.ts │ ├── useNoise.ts │ ├── usePodcastData.ts │ └── useRadioBrowser.ts ├── lib │ ├── convertTime.ts │ ├── opmlUtil.ts │ ├── playlistUtil.ts │ ├── reconnectUtil.ts │ ├── styleClass.ts │ ├── utils.ts │ ├── validationUtil.ts │ └── version.ts ├── main.tsx ├── pages │ ├── about │ │ └── index.tsx │ ├── homepage │ │ └── index.tsx │ ├── not-found │ │ └── index.tsx │ ├── playlist │ │ ├── index.tsx │ │ ├── playlist.module.css │ │ └── usePlaylist.ts │ ├── playlists │ │ ├── index.tsx │ │ ├── types.ts │ │ └── usePlaylists.ts │ ├── podcast │ │ ├── index.tsx │ │ └── usePodcast.ts │ ├── podcasts │ │ ├── index.tsx │ │ └── usePodcasts.ts │ ├── radio-station │ │ ├── index.tsx │ │ └── useRadioStation.ts │ ├── radio-stations │ │ ├── index.tsx │ │ └── useRadioStations.ts │ └── settings │ │ ├── index.tsx │ │ ├── types.ts │ │ └── useSettings.ts ├── store │ ├── db │ │ ├── db.ts │ │ └── migration.ts │ ├── signals │ │ ├── log.ts │ │ ├── player.ts │ │ ├── playlist.ts │ │ ├── podcast.ts │ │ ├── radio.ts │ │ ├── settings.ts │ │ └── ui.ts │ └── types.ts └── vite-env.d.ts ├── tests ├── homepage.spec.ts └── radio.spec.ts ├── tsconfig.app.json ├── tsconfig.app.tsbuildinfo ├── tsconfig.json ├── tsconfig.node.json ├── tsconfig.node.tsbuildinfo └── vite.config.ts /.env-example: -------------------------------------------------------------------------------- 1 | FIREBASE_LOGIN_URL="https://example.com" 2 | FIREBASE_USER_EMAIL="info@example.com" 3 | FIREBASE_USER_PASSWORD="password" 4 | FIREBASE_URL="https://example.com" 5 | VITE_BASE_URL="https://1tuner.com" 6 | RADIO_BROWSER_WORKER_URL="https://example.com" -------------------------------------------------------------------------------- /.github/workflows/playwright.yml: -------------------------------------------------------------------------------- 1 | name: Playwright Tests 2 | on: 3 | push: 4 | branches: [ main, master ] 5 | pull_request: 6 | branches: [ main, master ] 7 | jobs: 8 | test: 9 | timeout-minutes: 60 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: actions/setup-node@v4 14 | with: 15 | node-version: lts/* 16 | - name: Install dependencies 17 | run: npm ci 18 | - name: Install Playwright Browsers 19 | run: npx playwright install --with-deps 20 | - name: Run Playwright tests 21 | run: npx playwright test 22 | - uses: actions/upload-artifact@v4 23 | if: ${{ !cancelled() }} 24 | with: 25 | name: playwright-report 26 | path: playwright-report/ 27 | retention-days: 30 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | .well-known 15 | 16 | # Editor directories and files 17 | .vscode/* 18 | !.vscode/extensions.json 19 | .idea 20 | .DS_Store 21 | *.suo 22 | *.ntvs* 23 | *.njsproj 24 | *.sln 25 | *.sw? 26 | .aider* 27 | .gemini 28 | .playwright-mcp 29 | .env 30 | .firebase 31 | .firebaserc 32 | /firebase.json 33 | 34 | # Playwright 35 | /test-results/ 36 | /playwright-report/ 37 | /blob-report/ 38 | /playwright/.cache/ 39 | /playwright/.auth/ 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Robin Bakker (https://robinbakker.nl) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 1tuner 2 | 3 | ![Logo](https://1tuner.com/assets/icons/icon-192x192.png) 4 | 5 | A web app to listen to online radio. Also, create your own daily schedule (planning) and listen to different radio stations consecutively - the player will switch between radio streams automatically. 6 | 7 | 📻 Listen now at [1tuner.com](https://1tuner.com) 8 | 9 | 📖 See also [Creating a web app as side project](https://robinbakker.nl/en/blog/creating-a-web-app-as-side-project/) and [Updating my web app side project](https://robinbakker.nl/en/blog/updating-my-web-app-side-project/) 10 | 11 | 🚀 Built with [Preact](https://github.com/preactjs/preact) 12 | 13 | ## License 14 | 15 | MIT © [Robin Bakker](https://robinbakker.nl) 16 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "src/app.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "~/components", 15 | "utils": "~/lib/utils", 16 | "ui": "~/components/ui", 17 | "lib": "~/lib", 18 | "hooks": "~/hooks" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import { FlatCompat } from '@eslint/eslintrc'; 2 | import pluginJs from '@eslint/js'; 3 | import pluginReact from 'eslint-plugin-react'; 4 | import globals from 'globals'; 5 | import tseslint from 'typescript-eslint'; 6 | 7 | const compat = new FlatCompat(); 8 | 9 | /** @type {import('eslint').Linter.Config[]} */ 10 | export default [ 11 | { files: ['**/*.{js,mjs,cjs,ts,jsx,tsx}'] }, 12 | { 13 | ignores: ['dist/**/*', 'node_modules/**/*', 'public/**/*'], 14 | }, 15 | { languageOptions: { globals: globals.browser } }, 16 | pluginJs.configs.recommended, 17 | ...tseslint.configs.recommended, 18 | pluginReact.configs.flat['jsx-runtime'], 19 | ...compat.extends('plugin:react-hooks/recommended'), 20 | ]; 21 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 37 | 1tuner.com 38 | 39 | 40 |
41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "1tuner", 3 | "private": true, 4 | "version": "3.1.16", 5 | "description": "1 web app to listen to radio and podcasts", 6 | "author": "Robin Bakker", 7 | "license": "MIT", 8 | "type": "module", 9 | "scripts": { 10 | "start": "vite", 11 | "dev": "vite", 12 | "prefetch": "node ./scripts/fetchPodcasts.js", 13 | "process-radio-podcasts": "node ./scripts/processRadioPodcasts.js", 14 | "prebuild": "npm run prefetch && npm run process-radio-podcasts", 15 | "build": "tsc -b && vite build", 16 | "preview": "vite preview" 17 | }, 18 | "prettier": { 19 | "semi": true, 20 | "trailingComma": "all", 21 | "singleQuote": true, 22 | "printWidth": 120, 23 | "plugins": [ 24 | "prettier-plugin-organize-imports" 25 | ] 26 | }, 27 | "dependencies": { 28 | "@preact/signals": "^2.3.2", 29 | "@tailwindcss/vite": "^4.1.14", 30 | "class-variance-authority": "^0.7.1", 31 | "fast-xml-parser": "^5.3.0", 32 | "idb": "^8.0.3", 33 | "lucide-preact": "^0.544.0", 34 | "preact": "^10.27.2", 35 | "preact-iso": "^2.11.0", 36 | "tailwindcss-animate": "^1.0.7" 37 | }, 38 | "devDependencies": { 39 | "@eslint/js": "^9.37.0", 40 | "@playwright/test": "^1.55.1", 41 | "@preact/preset-vite": "^2.10.2", 42 | "@tailwindcss/container-queries": "^0.1.1", 43 | "@types/chromecast-caf-sender": "^1.0.11", 44 | "@types/node": "^24.6.2", 45 | "clsx": "^2.1.1", 46 | "country-flag-emoji-polyfill": "^0.1.8", 47 | "dotenv": "^17.2.3", 48 | "eslint": "^9.37.0", 49 | "eslint-plugin-react": "^7.37.5", 50 | "eslint-plugin-react-hooks": "^6.1.1", 51 | "globals": "^16.4.0", 52 | "postcss": "^8.5.6", 53 | "prettier-plugin-organize-imports": "^4.3.0", 54 | "tailwind-merge": "^3.3.1", 55 | "tailwindcss": "^4.1.14", 56 | "typescript": "^5.9.3", 57 | "typescript-eslint": "^8.45.0", 58 | "vite": "^7.1.9", 59 | "vite-plugin-pwa": "^1.0.3" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, devices } from '@playwright/test'; 2 | 3 | /** 4 | * See https://playwright.dev/docs/test-configuration. 5 | */ 6 | export default defineConfig({ 7 | testDir: './tests', 8 | /* Run tests in files in parallel */ 9 | fullyParallel: true, 10 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 11 | forbidOnly: !!process.env.CI, 12 | /* Retry on CI only */ 13 | retries: process.env.CI ? 2 : 0, 14 | /* Opt out of parallel tests on CI. */ 15 | workers: process.env.CI ? 1 : undefined, 16 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 17 | reporter: 'html', 18 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 19 | use: { 20 | /* Base URL to use in actions like `await page.goto('/')`. */ 21 | baseURL: 'http://localhost:5173', 22 | 23 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 24 | trace: 'on-first-retry', 25 | }, 26 | 27 | /* Configure projects for major browsers */ 28 | projects: [ 29 | { 30 | name: 'chromium', 31 | use: { ...devices['Desktop Chrome'] }, 32 | }, 33 | 34 | // { 35 | // name: 'firefox', 36 | // use: { ...devices['Desktop Firefox'] }, 37 | // }, 38 | 39 | // { 40 | // name: 'webkit', 41 | // use: { ...devices['Desktop Safari'] }, 42 | // }, 43 | 44 | /* Test against mobile viewports. */ 45 | // { 46 | // name: 'Mobile Chrome', 47 | // use: { ...devices['Pixel 5'] }, 48 | // }, 49 | // { 50 | // name: 'Mobile Safari', 51 | // use: { ...devices['iPhone 12'] }, 52 | // }, 53 | 54 | /* Test against branded browsers. */ 55 | // { 56 | // name: 'Microsoft Edge', 57 | // use: { ...devices['Desktop Edge'], channel: 'msedge' }, 58 | // }, 59 | // { 60 | // name: 'Google Chrome', 61 | // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, 62 | // }, 63 | ], 64 | 65 | /* Run your local dev server before starting the tests */ 66 | webServer: { 67 | command: 'npm run dev', 68 | url: 'http://localhost:5173', 69 | reuseExistingServer: !process.env.CI, 70 | }, 71 | }); 72 | -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robinbakker/1tuner/8c9c4ca90cfcb38f0b11541f4833de4741b6aa1d/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/assets/chromecast/bg.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robinbakker/1tuner/8c9c4ca90cfcb38f0b11541f4833de4741b6aa1d/public/assets/chromecast/bg.jpg -------------------------------------------------------------------------------- /public/assets/chromecast/default.css: -------------------------------------------------------------------------------- 1 | .background { 2 | background: center no-repeat url(bg.jpg); 3 | background-size: cover; 4 | } 5 | 6 | .logo { 7 | background-image: url(logo.png); 8 | } 9 | 10 | .progressBar { 11 | background-color: #ff6000; 12 | } 13 | -------------------------------------------------------------------------------- /public/assets/chromecast/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robinbakker/1tuner/8c9c4ca90cfcb38f0b11541f4833de4741b6aa1d/public/assets/chromecast/logo.png -------------------------------------------------------------------------------- /public/assets/icons/icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robinbakker/1tuner/8c9c4ca90cfcb38f0b11541f4833de4741b6aa1d/public/assets/icons/icon-192x192.png -------------------------------------------------------------------------------- /public/assets/icons/icon-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robinbakker/1tuner/8c9c4ca90cfcb38f0b11541f4833de4741b6aa1d/public/assets/icons/icon-512x512.png -------------------------------------------------------------------------------- /public/assets/icons/icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/assets/screenshots/screenshot-01-home-wide.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robinbakker/1tuner/8c9c4ca90cfcb38f0b11541f4833de4741b6aa1d/public/assets/screenshots/screenshot-01-home-wide.jpg -------------------------------------------------------------------------------- /public/assets/screenshots/screenshot-01-home-wide.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robinbakker/1tuner/8c9c4ca90cfcb38f0b11541f4833de4741b6aa1d/public/assets/screenshots/screenshot-01-home-wide.png -------------------------------------------------------------------------------- /public/assets/screenshots/screenshot-01-home.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robinbakker/1tuner/8c9c4ca90cfcb38f0b11541f4833de4741b6aa1d/public/assets/screenshots/screenshot-01-home.jpg -------------------------------------------------------------------------------- /public/assets/screenshots/screenshot-01-home.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robinbakker/1tuner/8c9c4ca90cfcb38f0b11541f4833de4741b6aa1d/public/assets/screenshots/screenshot-01-home.png -------------------------------------------------------------------------------- /public/assets/screenshots/screenshot-02-radio-wide.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robinbakker/1tuner/8c9c4ca90cfcb38f0b11541f4833de4741b6aa1d/public/assets/screenshots/screenshot-02-radio-wide.jpg -------------------------------------------------------------------------------- /public/assets/screenshots/screenshot-02-radio-wide.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robinbakker/1tuner/8c9c4ca90cfcb38f0b11541f4833de4741b6aa1d/public/assets/screenshots/screenshot-02-radio-wide.png -------------------------------------------------------------------------------- /public/assets/screenshots/screenshot-02-radio.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robinbakker/1tuner/8c9c4ca90cfcb38f0b11541f4833de4741b6aa1d/public/assets/screenshots/screenshot-02-radio.jpg -------------------------------------------------------------------------------- /public/assets/screenshots/screenshot-02-radio.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robinbakker/1tuner/8c9c4ca90cfcb38f0b11541f4833de4741b6aa1d/public/assets/screenshots/screenshot-02-radio.png -------------------------------------------------------------------------------- /public/assets/screenshots/screenshot-03-radio-page-wide.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robinbakker/1tuner/8c9c4ca90cfcb38f0b11541f4833de4741b6aa1d/public/assets/screenshots/screenshot-03-radio-page-wide.jpg -------------------------------------------------------------------------------- /public/assets/screenshots/screenshot-03-radio-page-wide.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robinbakker/1tuner/8c9c4ca90cfcb38f0b11541f4833de4741b6aa1d/public/assets/screenshots/screenshot-03-radio-page-wide.png -------------------------------------------------------------------------------- /public/assets/screenshots/screenshot-03-radio-page.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robinbakker/1tuner/8c9c4ca90cfcb38f0b11541f4833de4741b6aa1d/public/assets/screenshots/screenshot-03-radio-page.jpg -------------------------------------------------------------------------------- /public/assets/screenshots/screenshot-03-radio-page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robinbakker/1tuner/8c9c4ca90cfcb38f0b11541f4833de4741b6aa1d/public/assets/screenshots/screenshot-03-radio-page.png -------------------------------------------------------------------------------- /public/assets/screenshots/screenshot-04-podcast-wide.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robinbakker/1tuner/8c9c4ca90cfcb38f0b11541f4833de4741b6aa1d/public/assets/screenshots/screenshot-04-podcast-wide.jpg -------------------------------------------------------------------------------- /public/assets/screenshots/screenshot-04-podcast-wide.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robinbakker/1tuner/8c9c4ca90cfcb38f0b11541f4833de4741b6aa1d/public/assets/screenshots/screenshot-04-podcast-wide.png -------------------------------------------------------------------------------- /public/assets/screenshots/screenshot-04-podcast.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robinbakker/1tuner/8c9c4ca90cfcb38f0b11541f4833de4741b6aa1d/public/assets/screenshots/screenshot-04-podcast.jpg -------------------------------------------------------------------------------- /public/assets/screenshots/screenshot-04-podcast.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robinbakker/1tuner/8c9c4ca90cfcb38f0b11541f4833de4741b6aa1d/public/assets/screenshots/screenshot-04-podcast.png -------------------------------------------------------------------------------- /public/assets/social-share.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robinbakker/1tuner/8c9c4ca90cfcb38f0b11541f4833de4741b6aa1d/public/assets/social-share.jpg -------------------------------------------------------------------------------- /public/assets/social-share.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robinbakker/1tuner/8c9c4ca90cfcb38f0b11541f4833de4741b6aa1d/public/assets/social-share.png -------------------------------------------------------------------------------- /public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robinbakker/1tuner/8c9c4ca90cfcb38f0b11541f4833de4741b6aa1d/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robinbakker/1tuner/8c9c4ca90cfcb38f0b11541f4833de4741b6aa1d/public/favicon-32x32.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robinbakker/1tuner/8c9c4ca90cfcb38f0b11541f4833de4741b6aa1d/public/favicon.ico -------------------------------------------------------------------------------- /public/header-bg.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robinbakker/1tuner/8c9c4ca90cfcb38f0b11541f4833de4741b6aa1d/public/header-bg.jpg -------------------------------------------------------------------------------- /public/icon-192x192-maskable.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robinbakker/1tuner/8c9c4ca90cfcb38f0b11541f4833de4741b6aa1d/public/icon-192x192-maskable.png -------------------------------------------------------------------------------- /public/icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robinbakker/1tuner/8c9c4ca90cfcb38f0b11541f4833de4741b6aa1d/public/icon-192x192.png -------------------------------------------------------------------------------- /public/icon-512x512-maskable.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robinbakker/1tuner/8c9c4ca90cfcb38f0b11541f4833de4741b6aa1d/public/icon-512x512-maskable.png -------------------------------------------------------------------------------- /public/icon-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robinbakker/1tuner/8c9c4ca90cfcb38f0b11541f4833de4741b6aa1d/public/icon-512x512.png -------------------------------------------------------------------------------- /public/icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/logo-text-white.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/logo-text.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "1tuner.com", 3 | "short_name": "1tuner", 4 | "description": "1 web app to listen to online radio", 5 | "start_url": "/?source=pwa", 6 | "id": "/?homescreen=2", 7 | "display": "standalone", 8 | "orientation": "portrait-primary", 9 | "background_color": "#ebebeb", 10 | "theme_color": "#111111", 11 | "launch_handler": { 12 | "client_mode": ["focus-existing", "auto"] 13 | }, 14 | "icons": [ 15 | { 16 | "src": "/icon-192x192.png", 17 | "type": "image/png", 18 | "sizes": "192x192" 19 | }, 20 | { 21 | "purpose": "any", 22 | "src": "/icon-512x512.png", 23 | "type": "image/png", 24 | "sizes": "512x512" 25 | }, 26 | { 27 | "purpose": "maskable", 28 | "src": "/icon-192x192-maskable.png", 29 | "type": "image/png", 30 | "sizes": "192x192" 31 | }, 32 | { 33 | "purpose": "maskable", 34 | "src": "/icon-512x512-maskable.png", 35 | "type": "image/png", 36 | "sizes": "512x512" 37 | } 38 | ], 39 | "shortcuts": [ 40 | { 41 | "name": "Stations", 42 | "short_name": "Stations", 43 | "description": "Listen to the radio", 44 | "url": "/radio-stations?source=pwa", 45 | "icons": [{ "src": "/shortcut-stations-192x192.png", "sizes": "192x192" }] 46 | }, 47 | { 48 | "name": "Podcasts", 49 | "short_name": "Podcasts", 50 | "description": "Find and listen to podcasts", 51 | "url": "/podcasts?source=pwa", 52 | "icons": [{ "src": "/shortcut-podcasts-192x192.png", "sizes": "192x192" }] 53 | }, 54 | { 55 | "name": "Playlists", 56 | "short_name": "Playlists", 57 | "description": "Your own radio playlists", 58 | "url": "/playlists?source=pwa", 59 | "icons": [{ "src": "/shortcut-playlists-192x192.png", "sizes": "192x192" }] 60 | }, 61 | { 62 | "name": "Settings", 63 | "short_name": "Settings", 64 | "description": "Change the app settings", 65 | "url": "/settings?source=pwa", 66 | "icons": [{ "src": "/shortcut-settings-192x192.png", "sizes": "192x192" }] 67 | } 68 | ], 69 | "screenshots": [ 70 | { 71 | "src": "/assets/screenshots/screenshot-01-home.jpg", 72 | "type": "image/jpeg", 73 | "sizes": "1170x2532", 74 | "form_factor": "narrow" 75 | }, 76 | { 77 | "src": "/assets/screenshots/screenshot-02-radio.jpg", 78 | "type": "image/jpeg", 79 | "sizes": "1170x2532", 80 | "form_factor": "narrow" 81 | }, 82 | { 83 | "src": "/assets/screenshots/screenshot-04-podcast.jpg", 84 | "type": "image/jpeg", 85 | "sizes": "1170x2532", 86 | "form_factor": "narrow" 87 | }, 88 | { 89 | "src": "/assets/screenshots/screenshot-03-radio-page.jpg", 90 | "type": "image/jpeg", 91 | "sizes": "1170x2532", 92 | "form_factor": "narrow" 93 | }, 94 | { 95 | "src": "/assets/screenshots/screenshot-01-home-wide.jpg", 96 | "type": "image/jpeg", 97 | "sizes": "2732x2048", 98 | "form_factor": "wide" 99 | }, 100 | { 101 | "src": "/assets/screenshots/screenshot-02-radio-wide.jpg", 102 | "type": "image/jpeg", 103 | "sizes": "2732x2048", 104 | "form_factor": "wide" 105 | }, 106 | { 107 | "src": "/assets/screenshots/screenshot-04-podcast-wide.jpg", 108 | "type": "image/jpeg", 109 | "sizes": "2732x2048", 110 | "form_factor": "wide" 111 | }, 112 | { 113 | "src": "/assets/screenshots/screenshot-03-radio-page-wide.jpg", 114 | "type": "image/jpeg", 115 | "sizes": "2732x2048", 116 | "form_factor": "wide" 117 | } 118 | ], 119 | "categories": ["entertainment", "music", "news"] 120 | } 121 | -------------------------------------------------------------------------------- /public/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robinbakker/1tuner/8c9c4ca90cfcb38f0b11541f4833de4741b6aa1d/public/mstile-150x150.png -------------------------------------------------------------------------------- /public/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | Created by potrace 1.11, written by Peter Selinger 2001-2013 9 | 10 | 12 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /public/shortcut-playlists-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robinbakker/1tuner/8c9c4ca90cfcb38f0b11541f4833de4741b6aa1d/public/shortcut-playlists-192x192.png -------------------------------------------------------------------------------- /public/shortcut-podcasts-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robinbakker/1tuner/8c9c4ca90cfcb38f0b11541f4833de4741b6aa1d/public/shortcut-podcasts-192x192.png -------------------------------------------------------------------------------- /public/shortcut-settings-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robinbakker/1tuner/8c9c4ca90cfcb38f0b11541f4833de4741b6aa1d/public/shortcut-settings-192x192.png -------------------------------------------------------------------------------- /public/shortcut-stations-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robinbakker/1tuner/8c9c4ca90cfcb38f0b11541f4833de4741b6aa1d/public/shortcut-stations-192x192.png -------------------------------------------------------------------------------- /scripts/fetchPodcasts.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | import * as dotenv from 'dotenv'; 3 | import * as fs from 'fs/promises'; 4 | import * as path from 'path'; 5 | 6 | dotenv.config(); 7 | 8 | async function fetchPodcasts() { 9 | try { 10 | const FIREBASE_LOGIN_URL = process.env.FIREBASE_LOGIN_URL; 11 | const FIREBASE_USER_EMAIL = process.env.FIREBASE_USER_EMAIL; 12 | const FIREBASE_USER_PASSWORD = process.env.FIREBASE_USER_PASSWORD; 13 | const FIREBASE_URL = process.env.FIREBASE_URL; 14 | 15 | const firebaseAuth = await fetch(FIREBASE_LOGIN_URL, { 16 | body: JSON.stringify({ 17 | email: FIREBASE_USER_EMAIL, 18 | password: FIREBASE_USER_PASSWORD, 19 | returnSecureToken: true, 20 | }), 21 | method: 'POST', 22 | }); 23 | 24 | const firebaseAuthResult = await firebaseAuth.json(); 25 | const firebaseAuthToken = firebaseAuthResult.idToken; 26 | const podcastDataUrl = `${FIREBASE_URL}/podcasts.json?auth=${firebaseAuthToken}`; 27 | 28 | const response = await fetch(podcastDataUrl); 29 | const data = await response.json(); 30 | 31 | const keyArray = Object.keys(data).reverse(); 32 | const podcasts = keyArray 33 | .slice(0, 500) 34 | .map((key) => data[key]) 35 | .filter((pc) => pc && pc.feedUrl && pc.title) 36 | .map((pc) => ({ 37 | title: pc.title, 38 | description: pc.description, 39 | imageUrl: pc.logo600 || pc.logo, 40 | url: pc.feedUrl, 41 | categories: pc.genres ? pc.genres.map((g) => g.name).filter((g) => !!g) : [], 42 | addedDate: Date.now(), 43 | lastFetched: Date.now(), 44 | episodes: [], 45 | })); 46 | 47 | await fs.mkdir(path.join(process.cwd(), 'src/assets/data'), { recursive: true }); 48 | await fs.writeFile(path.join(process.cwd(), 'src/assets/data/podcasts.json'), JSON.stringify(podcasts, null, 2)); 49 | 50 | console.log('Podcasts data fetched and saved successfully!'); 51 | } catch (error) { 52 | console.error('Error fetching podcasts:', error); 53 | process.exit(1); 54 | } 55 | } 56 | 57 | fetchPodcasts(); 58 | -------------------------------------------------------------------------------- /scripts/processRadioPodcasts.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | import * as fs from 'fs/promises'; 3 | import * as path from 'path'; 4 | 5 | async function processPodcastsForStations() { 6 | try { 7 | // Read the podcast and stations data 8 | const podcastsData = JSON.parse( 9 | await fs.readFile(path.join(process.cwd(), 'src/assets/data/podcasts.json'), 'utf-8'), 10 | ); 11 | const stationsData = JSON.parse( 12 | await fs.readFile(path.join(process.cwd(), 'src/assets/data/stations.json'), 'utf-8'), 13 | ); 14 | 15 | // Create a map of podcast URLs to their full data for quick lookup 16 | const podcastMap = new Map(); 17 | podcastsData.forEach((podcast) => { 18 | podcastMap.set(podcast.url.toLowerCase(), podcast); 19 | }); 20 | 21 | // Process each station 22 | const stationPodcasts = {}; 23 | 24 | stationsData.stations.forEach((station) => { 25 | if (station.podcasts && station.podcasts.length > 0) { 26 | const matchedPodcasts = station.podcasts 27 | .map((podcastUrl) => podcastMap.get(podcastUrl.toLowerCase())) 28 | .filter(Boolean); // Remove any undefined entries 29 | 30 | if (matchedPodcasts.length > 0) { 31 | stationPodcasts[station.id] = matchedPodcasts; 32 | } 33 | } 34 | }); 35 | 36 | // Create the data directory if it doesn't exist 37 | await fs.mkdir(path.join(process.cwd(), 'src/assets/data/stations'), { recursive: true }); 38 | 39 | // Save the results 40 | await fs.writeFile( 41 | path.join(process.cwd(), 'src/assets/data/stations/podcasts.json'), 42 | JSON.stringify(stationPodcasts, null, 2), 43 | ); 44 | 45 | console.log('Station podcasts processed and saved successfully!'); 46 | } catch (error) { 47 | console.error('Error processing station podcasts:', error); 48 | process.exit(1); 49 | } 50 | } 51 | 52 | processPodcastsForStations(); 53 | -------------------------------------------------------------------------------- /src/app.css: -------------------------------------------------------------------------------- 1 | @import 'tailwindcss'; 2 | 3 | @plugin 'tailwindcss-animate'; 4 | @plugin '@tailwindcss/container-queries'; 5 | 6 | @custom-variant dark (&:where(.dark, .dark *)); 7 | 8 | @theme { 9 | --radius-lg: var(--radius); 10 | --radius-md: calc(var(--radius) - 2px); 11 | --radius-sm: calc(var(--radius) - 4px); 12 | 13 | --color-background: hsl(var(--background)); 14 | --color-foreground: hsl(var(--foreground)); 15 | 16 | --color-card: hsl(var(--card)); 17 | --color-card-foreground: hsl(var(--card-foreground)); 18 | 19 | --color-popover: hsl(var(--popover)); 20 | --color-popover-foreground: hsl(var(--popover-foreground)); 21 | 22 | --color-primary: hsl(var(--primary)); 23 | --color-primary-foreground: hsl(var(--primary-foreground)); 24 | 25 | --color-secondary: hsl(var(--secondary)); 26 | --color-secondary-foreground: hsl(var(--secondary-foreground)); 27 | 28 | --color-muted: hsl(var(--muted)); 29 | --color-muted-foreground: hsl(var(--muted-foreground)); 30 | 31 | --color-accent: hsl(var(--accent)); 32 | --color-accent-foreground: hsl(var(--accent-foreground)); 33 | 34 | --color-destructive: hsl(var(--destructive)); 35 | --color-destructive-foreground: hsl(var(--destructive-foreground)); 36 | 37 | --color-border: hsl(var(--border)); 38 | --color-input: hsl(var(--input)); 39 | --color-ring: hsl(var(--ring)); 40 | 41 | --color-chart-1: hsl(var(--chart-1)); 42 | --color-chart-2: hsl(var(--chart-2)); 43 | --color-chart-3: hsl(var(--chart-3)); 44 | --color-chart-4: hsl(var(--chart-4)); 45 | --color-chart-5: hsl(var(--chart-5)); 46 | } 47 | 48 | /* 49 | The default border color has changed to `currentColor` in Tailwind CSS v4, 50 | so we've added these compatibility styles to make sure everything still 51 | looks the same as it did with Tailwind CSS v3. 52 | 53 | If we ever want to remove these styles, we need to add an explicit border 54 | color utility to any element that depends on these defaults. 55 | */ 56 | @layer base { 57 | *, 58 | ::after, 59 | ::before, 60 | ::backdrop, 61 | ::file-selector-button { 62 | border-color: var(--color-gray-200, currentColor); 63 | } 64 | } 65 | 66 | @utility maximize-enter { 67 | transform: translateY(100%); 68 | 69 | @media (width >= theme(--breakpoint-md)) { 70 | transform: translateX(100%); 71 | } 72 | } 73 | 74 | @utility maximize-enter-active { 75 | transform: translateY(0); 76 | transition: transform 300ms ease-in-out; 77 | 78 | @media (width >= theme(--breakpoint-md)) { 79 | transform: translateX(0); 80 | } 81 | } 82 | 83 | @utility maximize-exit { 84 | transform: translateY(0); 85 | } 86 | 87 | @utility maximize-exit-active { 88 | transform: translateY(100%); 89 | transition: transform 300ms ease-in-out; 90 | 91 | @media (width >= theme(--breakpoint-md)) { 92 | transform: translateX(100%); 93 | } 94 | } 95 | 96 | @layer utilities { 97 | html, 98 | :host { 99 | font-family: 100 | 'Twemoji Country Flags', ui-sans-serif, system-ui, sans-serif, 101 | 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 102 | 'Noto Color Emoji'; 103 | } 104 | } 105 | 106 | @layer base { 107 | :root { 108 | --background: 60 9.1% 97.8%; /* stone-50 */ 109 | --foreground: 12 6.5% 33%; 110 | --card: 0 0% 100%; /* white */ 111 | --card-foreground: 20 14.3% 4.9%; /* stone-900 */ 112 | --popover: 0 0% 100%; /* white */ 113 | --popover-foreground: 20 14.3% 4.9%; /* stone-900 */ 114 | --primary: 22.59deg 100% 50%; 115 | --primary-foreground: 0 0% 98%; 116 | --secondary: 0 0% 96.1%; 117 | --secondary-foreground: 0 0% 9%; 118 | --muted: 60 4.8% 95.9%; /* stone-100 */ 119 | --muted-foreground: 25 5.3% 44.7%; /* stone-500 */ 120 | --accent: 60 4.8% 95.9%; /* stone-100 */ 121 | --accent-foreground: 24 9.8% 10%; /* stone-900 */ 122 | --destructive: 0 84.2% 60.2%; /* red-500 */ 123 | --destructive-foreground: 60 9.1% 97.8%; /* stone-50 */ 124 | --border: 20 5.9% 90%; /* stone-200 */ 125 | --input: 20 5.9% 90%; /* stone-200 */ 126 | --ring: 20 14.3% 4.9%; /* stone-900 */ 127 | --chart-1: 12 76% 61%; 128 | --chart-2: 173 58% 39%; 129 | --chart-3: 197 37% 24%; 130 | --chart-4: 43 74% 66%; 131 | --chart-5: 27 87% 67%; 132 | --radius: 0.5rem; 133 | } 134 | 135 | .dark { 136 | --background: 24 9.8% 10%; /* stone-900 */ 137 | --foreground: 60 9.1% 75%; 138 | --card: 12 6.5% 15.1%; /* stone-800 */ 139 | --card-foreground: 60 9.1% 97.8%; /* stone-50 */ 140 | --popover: 24 9.8% 10%; /* stone-900 */ 141 | --popover-foreground: 60 9.1% 97.8%; /* stone-50 */ 142 | --primary: 22.59deg 100% 50%; 143 | --primary-foreground: 0 0% 98%; 144 | --secondary: 0 0% 96.1%; 145 | --secondary-foreground: 0 0% 9%; 146 | --muted: 12 6.5% 15.1%; /* stone-800 */ 147 | --muted-foreground: 24 5.4% 63.9%; /* stone-400 */ 148 | --accent: 12 6.5% 15.1%; /* stone-800 */ 149 | --accent-foreground: 60 9.1% 97.8%; /* stone-50 */ 150 | --destructive: 0 62.8% 30.6%; /* red-700 */ 151 | --destructive-foreground: 60 9.1% 97.8%; /* stone-50 */ 152 | --border: 12 6.5% 15.1%; /* stone-800 */ 153 | --input: 12 6.5% 15.1%; /* stone-800 */ 154 | --ring: 24 5.7% 82.9%; /* stone-200 */ 155 | --chart-1: 220 70% 50%; 156 | --chart-2: 160 60% 45%; 157 | --chart-3: 30 80% 55%; 158 | --chart-4: 280 65% 60%; 159 | --chart-5: 340 75% 55%; 160 | } 161 | 162 | section ::-webkit-scrollbar { 163 | width: 10px; 164 | height: 10px; 165 | } 166 | 167 | section ::-webkit-scrollbar-track { 168 | background: transparent; 169 | } 170 | 171 | section ::-webkit-scrollbar-thumb { 172 | background: rgb(156 163 175 / 0.5); /* gray-400 with 50% opacity */ 173 | border-radius: 5px; 174 | } 175 | 176 | section ::-webkit-scrollbar-thumb:hover { 177 | background: rgb(156 163 175 / 0.7); 178 | } 179 | } 180 | 181 | @layer utilities { 182 | .is-dragging { 183 | user-select: none; 184 | -webkit-user-select: none; 185 | } 186 | 187 | .is-dragging * { 188 | cursor: ns-resize !important; 189 | } 190 | 191 | select.custom-select-arrow { 192 | appearance: none; 193 | background-image: 194 | linear-gradient(45deg, transparent 50%, hsl(var(--muted-foreground)) 50%), 195 | linear-gradient(135deg, hsl(var(--muted-foreground)) 50%, transparent 50%); 196 | background-position: 197 | calc(100% - 1.35rem) 1.25rem, 198 | calc(100% - 1rem) 1.25rem; 199 | background-size: 200 | 0.35rem 0.35rem, 201 | 0.35rem 0.35rem; 202 | background-repeat: no-repeat; 203 | } 204 | 205 | select.custom-select-arrow--small { 206 | background-position: 207 | calc(100% - 0.6rem) 0.75rem, 208 | calc(100% - 0.35rem) 0.75rem; 209 | background-size: 210 | 0.25rem 0.25rem, 211 | 0.25rem 0.25rem; 212 | } 213 | } 214 | 215 | @layer base { 216 | * { 217 | @apply border-border; 218 | } 219 | body { 220 | @apply bg-background text-foreground; 221 | } 222 | 223 | .app-shell-header { 224 | background: linear-gradient(to bottom, hsl(var(--card)), transparent 50%); 225 | } 226 | 227 | .player-minimized-backdrop { 228 | background: linear-gradient(to top, hsl(var(--card)), transparent 50%); 229 | } 230 | } 231 | -------------------------------------------------------------------------------- /src/app.tsx: -------------------------------------------------------------------------------- 1 | import { ErrorBoundary, LocationProvider, Route, Router } from 'preact-iso'; 2 | import { useEffect } from 'preact/hooks'; 3 | import './app.css'; 4 | import { AppShell } from './components/appShell/appShell'; 5 | import { AboutPage } from './pages/about'; 6 | import { Homepage } from './pages/homepage'; 7 | import { NotFound } from './pages/not-found'; 8 | import { PlaylistPage } from './pages/playlist'; 9 | import { PlaylistsPage } from './pages/playlists'; 10 | import { PodcastPage } from './pages/podcast'; 11 | import { PodcastsPage } from './pages/podcasts'; 12 | import { RadioStationPage } from './pages/radio-station'; 13 | import { RadioStationsPage } from './pages/radio-stations'; 14 | import { SettingsPage } from './pages/settings'; 15 | import { loadStateFromDB, saveStateToDB } from './store/db/db'; 16 | import { migrateOldData } from './store/db/migration'; 17 | import { isPlayerMaximized } from './store/signals/player'; 18 | 19 | export function App() { 20 | useEffect(() => { 21 | async function initializeApp() { 22 | await migrateOldData(); 23 | console.log('Loading state from DB...'); 24 | await loadStateFromDB(); 25 | } 26 | 27 | initializeApp(); 28 | 29 | const handleBeforeUnload = () => { 30 | console.log('Saving state to DB...'); 31 | saveStateToDB(); 32 | }; 33 | 34 | const handleVisibilityChange = () => { 35 | if (document.visibilityState === 'hidden') { 36 | console.log('App hidden, saving state...'); 37 | saveStateToDB(); 38 | } 39 | }; 40 | 41 | window.addEventListener('beforeunload', handleBeforeUnload); 42 | document.addEventListener('visibilitychange', handleVisibilityChange); 43 | window.addEventListener('pagehide', handleBeforeUnload); // Add pagehide event for iOS 44 | 45 | return () => { 46 | window.removeEventListener('beforeunload', handleBeforeUnload); 47 | document.removeEventListener('visibilitychange', handleVisibilityChange); 48 | window.removeEventListener('pagehide', handleBeforeUnload); 49 | saveStateToDB(); 50 | }; 51 | }, []); 52 | 53 | useEffect(() => { 54 | if (isPlayerMaximized.value) { 55 | // Prevent scrolling on mobile only 56 | if (window.innerWidth < 768) { 57 | document.body.style.overflow = 'hidden'; 58 | } 59 | } else { 60 | document.body.style.overflow = ''; 61 | } 62 | 63 | return () => { 64 | document.body.style.overflow = ''; 65 | }; 66 | }, []); 67 | 68 | return ( 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | ); 88 | } 89 | -------------------------------------------------------------------------------- /src/assets/data/featured/podcasts.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "ZmVlZHMuc2ltcGxlY2FzdC5jb20vNTRuQUdjSWw=", 4 | "title": "The Daily", 5 | "description": "This is what the news should sound like. The biggest stories of our time, told by the best journalists in the world. Twenty minutes a day, five days a week, ready by 6 a.m.", 6 | "imageUrl": "https://is2-ssl.mzstatic.com/image/thumb/Podcasts124/v4/92/61/e6/9261e6a3-0d4a-8d4f-bd30-6c72562868ac/mza_15829936928695092211.jpeg/600x600bb.jpg", 7 | "url": "https://feeds.simplecast.com/54nAGcIl", 8 | "feedUrl": "https://feeds.simplecast.com/54nAGcIl", 9 | "addedDate": 1743541655611, 10 | "lastFetched": 1743541655611 11 | }, 12 | { 13 | "id": "cnNzLmFydDE5LmNvbS92YW5kYWFn", 14 | "title": "NRC Vandaag", 15 | "description": "Vandaag is de dagelijkse podcast van NRC. Elke werkdag hoor je hét verhaal van de dag, verteld door onze beste journalisten.", 16 | "imageUrl": "https://content.production.cdn.art19.com/images/2e/d0/18/ab/2ed018ab-e157-4212-9f27-910ee353f0e1/91502b4bd0fde49ef44f4d5a3d8bc772ebc052dc6a2a3fea3f1473035e78fa243366aaf45d3376d959c28075262335c58da6cf79c1cf6890be2ee46354818675.jpeg", 17 | "url": "https://rss.art19.com/vandaag", 18 | "feedUrl": "https://rss.art19.com/vandaag", 19 | "addedDate": 1743541655610, 20 | "lastFetched": 1743541655610 21 | }, 22 | { 23 | "id": "cG9kY2FzdC5ucG8ubmwvZmVlZC9kZS1kYWcueG1s", 24 | "title": "De Dag", 25 | "description": "Elke werkdag biedt 'De Dag' je twintig minuten verdieping bij één onderwerp uit het nieuws.", 26 | "imageUrl": "https://is5-ssl.mzstatic.com/image/thumb/Podcasts123/v4/95/55/fa/9555fabb-343e-3859-4c05-8f421f5a4885/mza_1355691311593021135.jpg/600x600bb.jpg", 27 | "url": "https://podcast.npo.nl/feed/de-dag.xml", 28 | "feedUrl": "https://podcast.npo.nl/feed/de-dag.xml", 29 | "addedDate": 1743541655611, 30 | "lastFetched": 1743541655611 31 | }, 32 | { 33 | "id": "cYW5jaG9yLmZtL3MvMjFjNzM0YzQvcG9kY2FzdC9yc3M=", 34 | "title": "Maarten van Rossem en Tom Jessen", 35 | "description": "Maarten van Rossem en Tom Jessen geven hun kijk op het nieuws en de actualiteit. Ook wisselende onderwerpen uit de geschiedenis komen voorbij.", 36 | "imageUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded_nologo/5567033/5567033-1682276210669-9c8c45b803bd.jpg", 37 | "url": "https://anchor.fm/s/21c734c4/podcast/rss", 38 | "feedUrl": "https://anchor.fm/s/21c734c4/podcast/rss", 39 | "addedDate": 1743541655611, 40 | "lastFetched": 1743541655611 41 | }, 42 | { 43 | "id": "cG9kY2FzdC5kYXJrbmV0ZGlhcmllcy5jb20=", 44 | "title": "Darknet Diaries", 45 | "description": "Explore true stories of the dark side of the Internet with host Jack Rhysider as he takes you on a journey through the chilling world of hacking, data breaches, and cyber crime.", 46 | "imageUrl": "https://f.prxu.org/7057/images/18744d5d-8091-4bb8-b41a-1ed06efcd508/uploads_2F1562951997273-pdd2keiryql-99f75240ab90a579e25720d85d3057b2_2Fdarknet-diaries-rss.jpg", 47 | "url": "https://podcast.darknetdiaries.com/", 48 | "feedUrl": "https://podcast.darknetdiaries.com/", 49 | "addedDate": 1743541655611, 50 | "lastFetched": 1743541655611 51 | }, 52 | { 53 | "id": "ZmVlZHMuc2ltcGxlY2FzdC5jb20vbDJpOVluVGQ=", 54 | "title": "Hard Fork", 55 | "description": "“Hard Fork” is a show about the future that’s already here. Each week, journalists Kevin Roose and Casey Newton explore and make sense of the latest in the rapidly changing world of tech", 56 | "imageUrl": "https://image.simplecastcdn.com/images/4105a47a-42e5-4ccc-887a-832af7989986/23965394-f5e4-4fdb-b150-639f4910353e/3000x3000/nyt-hf-album-art-3000-2.jpg?aid=rss_feed", 57 | "url": "https://feeds.simplecast.com/l2i9YnTd", 58 | "feedUrl": "https://feeds.simplecast.com/l2i9YnTd", 59 | "addedDate": 1743541655611, 60 | "lastFetched": 1743541655611 61 | } 62 | ] 63 | -------------------------------------------------------------------------------- /src/assets/data/genres.json: -------------------------------------------------------------------------------- 1 | { 2 | "genres": [ 3 | { 4 | "id": "alternative", 5 | "name": "Alternative" 6 | }, 7 | { 8 | "id": "blues", 9 | "name": "Blues" 10 | }, 11 | { 12 | "id": "classical", 13 | "name": "Classical" 14 | }, 15 | { 16 | "id": "country", 17 | "name": "Country" 18 | }, 19 | { 20 | "id": "dance", 21 | "name": "Dance" 22 | }, 23 | { 24 | "id": "disco", 25 | "name": "Disco" 26 | }, 27 | { 28 | "id": "electronic", 29 | "name": "Electronic" 30 | }, 31 | { 32 | "id": "folk", 33 | "name": "Folk" 34 | }, 35 | { 36 | "id": "funk", 37 | "name": "Funk" 38 | }, 39 | { 40 | "id": "hiphop", 41 | "name": "Hip hop" 42 | }, 43 | { 44 | "id": "jazz", 45 | "name": "Jazz" 46 | }, 47 | { 48 | "id": "latin", 49 | "name": "Latin" 50 | }, 51 | { 52 | "id": "local", 53 | "name": "Local" 54 | }, 55 | { 56 | "id": "news", 57 | "name": "News" 58 | }, 59 | { 60 | "id": "nonstop", 61 | "name": "Non-stop" 62 | }, 63 | { 64 | "id": "oldies", 65 | "name": "Oldies" 66 | }, 67 | { 68 | "id": "pop", 69 | "name": "Pop" 70 | }, 71 | { 72 | "id": "rap", 73 | "name": "Rap" 74 | }, 75 | { 76 | "id": "rnb", 77 | "name": "R&B" 78 | }, 79 | { 80 | "id": "rock", 81 | "name": "Rock" 82 | }, 83 | { 84 | "id": "soul", 85 | "name": "Soul" 86 | }, 87 | { 88 | "id": "talk", 89 | "name": "Talk" 90 | }, 91 | { 92 | "id": "reggae", 93 | "name": "Reggae" 94 | }, 95 | { 96 | "id": "comedy", 97 | "name": "Comedy" 98 | }, 99 | { 100 | "id": "drama", 101 | "name": "Drama" 102 | }, 103 | { 104 | "id": "history", 105 | "name": "History" 106 | }, 107 | { 108 | "id": "metal", 109 | "name": "Metal" 110 | }, 111 | { 112 | "id": "metalcore", 113 | "name": "Metalcore" 114 | }, 115 | { 116 | "id": "hardrock", 117 | "name": "Hard rock" 118 | }, 119 | { 120 | "id": "hardcore", 121 | "name": "Hardcore" 122 | }, 123 | { 124 | "id": "punk", 125 | "name": "Punk" 126 | }, 127 | { 128 | "id": "world", 129 | "name": "World" 130 | }, 131 | { 132 | "id": "beat", 133 | "name": "Beat" 134 | }, 135 | { 136 | "id": "kids", 137 | "name": "Kids" 138 | }, 139 | { 140 | "id": "indie", 141 | "name": "Indie" 142 | }, 143 | { 144 | "id": "americana", 145 | "name": "Americana" 146 | }, 147 | { 148 | "id": "chanson", 149 | "name": "Chanson" 150 | }, 151 | { 152 | "id": "filmmusic", 153 | "name": "Film music" 154 | }, 155 | { 156 | "id": "sports", 157 | "name": "Sports" 158 | } 159 | ] 160 | } 161 | -------------------------------------------------------------------------------- /src/assets/icons/tiktok.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/components/appShell/toast/toast.tsx: -------------------------------------------------------------------------------- 1 | // src/components/ui/toast/Toast.tsx 2 | import { X } from 'lucide-preact'; 3 | import { cn } from '~/lib/utils'; 4 | import { dismissToast } from '~/store/signals/ui'; 5 | import { ToastProps } from './types'; 6 | 7 | const toastVariants = { 8 | default: 'bg-white dark:bg-stone-800 text-stone-950 dark:text-stone-50', 9 | success: 'bg-green-100 dark:bg-green-900 text-green-950 dark:text-green-50', 10 | error: 'bg-red-100 dark:bg-red-900 text-red-950 dark:text-red-50', 11 | }; 12 | 13 | export function Toast({ id, title, description, variant = 'default', onClose }: ToastProps) { 14 | return ( 15 |
21 |
22 | {title &&
{title}
} 23 | {description &&
{description}
} 24 |
25 | 35 |
36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /src/components/appShell/toast/toaster.tsx: -------------------------------------------------------------------------------- 1 | import { For } from '@preact/signals/utils'; 2 | import { cn } from '~/lib/utils'; 3 | import { toasts } from '~/store/signals/ui'; 4 | import { Toast } from './toast'; 5 | 6 | type Props = { 7 | isPlayerOpen?: boolean; 8 | isPlayerMaximized?: boolean; 9 | }; 10 | 11 | export function Toaster({ isPlayerOpen, isPlayerMaximized }: Props) { 12 | return ( 13 |
22 | {(toast) => } 23 |
24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /src/components/appShell/toast/types.ts: -------------------------------------------------------------------------------- 1 | export type ToastProps = { 2 | id: string; 3 | title?: string; 4 | description?: string; 5 | variant?: 'default' | 'success' | 'error'; 6 | duration?: number; 7 | onClose?: () => void; 8 | }; 9 | -------------------------------------------------------------------------------- /src/components/appShell/useAppShell.ts: -------------------------------------------------------------------------------- 1 | import { useLocation } from 'preact-iso'; 2 | import { useEffect, useMemo, useRef } from 'preact/hooks'; 3 | import { playerState } from '~/store/signals/player'; 4 | import { uiIsScrolled } from '~/store/signals/ui'; 5 | 6 | export const useAppShell = () => { 7 | const headerSentinelRef = useRef(null); 8 | const { path } = useLocation(); 9 | 10 | useEffect(() => { 11 | if (!headerSentinelRef.current) { 12 | uiIsScrolled.value = false; 13 | return; 14 | } 15 | 16 | const observer = new IntersectionObserver( 17 | ([entry]) => { 18 | uiIsScrolled.value = !entry.isIntersecting; 19 | }, 20 | { 21 | threshold: 1.0, 22 | rootMargin: '100px 0px 0px 0px', // Adjust the margin as needed 23 | }, 24 | ); 25 | 26 | // We observe a small empty div at the top of the page 27 | observer.observe(headerSentinelRef.current); 28 | 29 | return () => { 30 | observer.disconnect(); 31 | }; 32 | }, []); 33 | 34 | const handleBackClick = () => { 35 | history.back(); 36 | }; 37 | 38 | const isActive = (checkPath: string) => { 39 | return checkPath === '/' ? path === checkPath : path.startsWith(checkPath); 40 | }; 41 | 42 | const isMainRoute = useMemo(() => { 43 | return ['/', '/radio-stations', '/podcasts', '/settings'].includes(path); 44 | }, [path]); 45 | 46 | return { 47 | headerSentinelRef, 48 | isActive, 49 | isMainRoute, 50 | isScrolled: uiIsScrolled.value, 51 | handleBackClick, 52 | isPlayerOpen: !!playerState.value?.streams?.length, 53 | }; 54 | }; 55 | -------------------------------------------------------------------------------- /src/components/content-section.tsx: -------------------------------------------------------------------------------- 1 | import { Search } from 'lucide-preact'; 2 | import { JSX } from 'preact'; 3 | import { useCallback } from 'preact/hooks'; 4 | import { cn } from '~/lib/utils'; 5 | import { Button } from './ui/button'; 6 | 7 | interface Props { 8 | title?: string; 9 | children: JSX.Element; 10 | moreLink?: string; 11 | isScrollable?: boolean; 12 | hasNoPadding?: boolean; 13 | className?: string; 14 | insetClassName?: string; 15 | hasSearchButton?: boolean; 16 | } 17 | 18 | export const ContentSection = ({ 19 | title, 20 | moreLink, 21 | children, 22 | isScrollable, 23 | hasNoPadding, 24 | className, 25 | insetClassName, 26 | hasSearchButton = false, 27 | }: Props) => { 28 | const MoreLink = useCallback( 29 | ({ isSearch }: { isSearch?: boolean }) => { 30 | const link = isSearch ? `${moreLink}?focus-search=true` : moreLink; 31 | return ( 32 | 37 | ); 38 | }, 39 | [moreLink], 40 | ); 41 | 42 | const padding = hasNoPadding ? '' : insetClassName ? insetClassName : 'px-4 md:px-6'; 43 | 44 | return ( 45 |
46 |
47 | {!!title && ( 48 |

49 | {title} {moreLink && } 50 |

51 | )} 52 |
{hasSearchButton && moreLink && }
53 |
54 |
{children}
55 |
56 | ); 57 | }; 58 | -------------------------------------------------------------------------------- /src/components/day-timeline.tsx: -------------------------------------------------------------------------------- 1 | import { HTMLAttributes } from 'preact/compat'; 2 | 3 | export const DayTimeline = (props: HTMLAttributes) => { 4 | return ( 5 | 18 | ); 19 | }; 20 | -------------------------------------------------------------------------------- /src/components/image-background.tsx: -------------------------------------------------------------------------------- 1 | export const ImageBackground = ({ imgSrc }: { imgSrc: string | undefined }) => { 2 | if (!imgSrc) { 3 | return null; 4 | } 5 | 6 | return ( 7 |
8 | {[...new Array(10)].map((_, index) => ( 9 |
14 | {index} 15 |
16 | ))} 17 |
18 | ); 19 | }; 20 | -------------------------------------------------------------------------------- /src/components/loader.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'preact/hooks'; 2 | 3 | export const Loader = () => { 4 | const needleRef = useRef(null); 5 | 6 | useEffect(() => { 7 | const needle = needleRef.current; 8 | if (!needle) return; 9 | 10 | const animate = () => { 11 | const randomPosition = Math.random() * 100; 12 | needle.style.left = `${randomPosition}%`; 13 | 14 | const duration = 500 + Math.random() * 1000; // Random duration between 0.5s and 1.5s 15 | needle.style.transition = `left ${duration}ms ease-in-out`; 16 | 17 | setTimeout(animate, duration); 18 | }; 19 | 20 | animate(); 21 | 22 | return () => { 23 | if (needle) { 24 | needle.style.transition = 'none'; 25 | } 26 | }; 27 | }, []); 28 | 29 | return ( 30 |
31 |
32 |
38 |
39 |
40 | ); 41 | }; 42 | -------------------------------------------------------------------------------- /src/components/podcast-card.tsx: -------------------------------------------------------------------------------- 1 | import { getPodcastUrlID, normalizedUrlWithoutScheme, slugify, stripHtml } from '~/lib/utils'; 2 | import { Podcast } from '~/store/types'; 3 | import { Card, CardContent } from './ui/card'; 4 | 5 | interface Props { 6 | podcast: Podcast; 7 | size?: 'default' | 'large'; 8 | } 9 | 10 | export const PodcastCard = ({ podcast, size }: Props) => { 11 | if (size === 'large') { 12 | return ( 13 | 14 | 15 | 16 | {podcast.title} 17 |
18 |

19 | {podcast.title} 20 |

21 |

{stripHtml(podcast.description)}

22 |
23 |
24 |
25 |
26 | ); 27 | } 28 | return ( 29 | 34 |
35 |
44 |
45 | {`${podcast.title} 46 |
47 | 48 | {podcast.title} 49 | 50 |
51 |
52 |
53 |
54 | ); 55 | }; 56 | -------------------------------------------------------------------------------- /src/components/radio-station-card.tsx: -------------------------------------------------------------------------------- 1 | import { Bookmark, Play, Trash2 } from 'lucide-preact'; 2 | import { Badge } from '~/components/ui/badge'; 3 | import { useRadioBrowser } from '~/hooks/useRadioBrowser'; 4 | import { cn } from '~/lib/utils'; 5 | import { playerState } from '~/store/signals/player'; 6 | import { 7 | addRadioBrowserStation, 8 | addRecentlyVisitedRadioStation, 9 | deleteBrowserStation, 10 | followedRadioStationIDs, 11 | getRadioStationLanguage, 12 | RADIO_BROWSER_PARAM_PREFIX, 13 | } from '~/store/signals/radio'; 14 | import { RadioStation } from '~/store/types'; 15 | import { Button } from './ui/button'; 16 | 17 | interface Props { 18 | station: RadioStation; 19 | size?: 'default' | 'large'; 20 | hasDeleteHidden?: boolean; 21 | } 22 | 23 | export const RadioStationCard = ({ station, size = 'default', hasDeleteHidden = false }: Props) => { 24 | const { setStationClick } = useRadioBrowser(); 25 | 26 | const onClickPlay = (e: { preventDefault: () => void; stopPropagation: () => void }) => { 27 | e.preventDefault(); 28 | e.stopPropagation(); 29 | addRadioBrowserStation(station); 30 | addRecentlyVisitedRadioStation(station.id); 31 | playerState.value = { 32 | playType: 'radio', 33 | isPlaying: true, 34 | contentID: station.id, 35 | title: station.name, 36 | description: '', 37 | imageUrl: station.logosource, 38 | streams: station.streams, 39 | pageLocation: `/radio-station/${station.id}`, 40 | }; 41 | if (station.stationuuid) { 42 | setStationClick(station.stationuuid); 43 | } 44 | }; 45 | 46 | const handleDeleteStation = (e: { preventDefault: () => void; stopPropagation: () => void }) => { 47 | e.preventDefault(); 48 | e.stopPropagation(); 49 | if ( 50 | station.id.startsWith(RADIO_BROWSER_PARAM_PREFIX) && 51 | confirm( 52 | `This is a locally saved station (from RadioBrowser).\n\nAre you sure you want to delete "${station.name}"?`, 53 | ) 54 | ) { 55 | deleteBrowserStation(station.id); 56 | } 57 | }; 58 | 59 | const RadioFlag = () => { 60 | const stationLanguage = getRadioStationLanguage(station); 61 | if (!stationLanguage?.flag) return null; 62 | 63 | return ( 64 | 65 | {stationLanguage.flag} 66 | 67 | ); 68 | }; 69 | 70 | const isFollowing = followedRadioStationIDs.value.includes(station.id); 71 | 72 | if (size === 'large') { 73 | return ( 74 | 79 |
80 |
81 |
90 |
91 | {`${station.name} 92 |
93 | 100 |
101 |
102 |

110 | {station.name} 111 | {!hasDeleteHidden && station.id.startsWith(RADIO_BROWSER_PARAM_PREFIX) && ( 112 | 121 | )} 122 |

123 |
124 | 125 | {station.genres?.map((genre) => ( 126 | 127 | {genre} 128 | 129 | ))} 130 |
131 |
132 | {followedRadioStationIDs.value.includes(station.id) && ( 133 | 137 | )} 138 |
139 |
140 | ); 141 | } 142 | 143 | return ( 144 | 145 |
146 |
155 |
156 | {`${station.name} 157 | 164 |
165 | 166 | {station.name} 167 | 168 |
169 |
170 |
171 |
172 | ); 173 | }; 174 | -------------------------------------------------------------------------------- /src/components/share-button.tsx: -------------------------------------------------------------------------------- 1 | import { Share2 } from 'lucide-preact'; 2 | import { useCallback } from 'preact/hooks'; 3 | import { cn } from '~/lib/utils'; 4 | import { uiState } from '~/store/signals/ui'; 5 | 6 | type Props = { 7 | hasDarkBackground: boolean; 8 | shareUrl?: string; 9 | className?: string; 10 | }; 11 | 12 | export function ShareButton({ hasDarkBackground, shareUrl, className }: Props) { 13 | const handleShare = useCallback(async () => { 14 | if (!navigator.share) return; 15 | try { 16 | await navigator.share({ 17 | title: uiState.value.headerTitle || '1tuner.com | listen to radio & podcasts', 18 | url: shareUrl || window?.location.href, 19 | }); 20 | } catch (error) { 21 | console.error('Error sharing:', error); 22 | } 23 | }, [shareUrl, uiState.value.headerTitle]); 24 | 25 | return ( 26 | 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /src/components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import { cva, type VariantProps } from 'class-variance-authority'; 2 | import { HTMLAttributes } from 'preact/compat'; 3 | import { cn } from '~/lib/utils'; 4 | 5 | const badgeVariants = cva( 6 | 'inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2', 7 | { 8 | variants: { 9 | variant: { 10 | default: 'border-transparent bg-primary text-primary-foreground shadow-sm hover:bg-primary/80', 11 | secondary: 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80', 12 | destructive: 'border-transparent bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/80', 13 | outline: 'text-foreground', 14 | }, 15 | }, 16 | defaultVariants: { 17 | variant: 'default', 18 | }, 19 | }, 20 | ); 21 | 22 | export interface BadgeProps extends HTMLAttributes, VariantProps {} 23 | 24 | function Badge({ variant, ...props }: BadgeProps) { 25 | return
; 26 | } 27 | 28 | export { Badge, badgeVariants }; 29 | -------------------------------------------------------------------------------- /src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import { cva, type VariantProps } from 'class-variance-authority'; 2 | import { ComponentChildren } from 'preact'; 3 | import { ButtonHTMLAttributes, forwardRef } from 'preact/compat'; 4 | import { cn } from '~/lib/utils'; 5 | 6 | const buttonVariants = cva( 7 | cn( 8 | 'inline-flex items-center justify-center whitespace-nowrap text-sm font-medium transition-colors cursor-pointer', 9 | 'focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none', 10 | 'disabled:opacity-50', 11 | ), 12 | { 13 | variants: { 14 | variant: { 15 | default: 'bg-primary text-primary-foreground shadow-sm hover:bg-primary/90', 16 | destructive: 'bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90', 17 | outline: 'border border-current/33 bg-background shadow-xs hover:bg-accent hover:text-accent-foreground', 18 | secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80', 19 | ghost: 'border border-white/10 text-white/70 hover:border-white/30 hover:text-white/90', 20 | link: 'text-primary underline-offset-4 hover:underline', 21 | }, 22 | styleSize: { 23 | default: 'h-9 px-4 py-2 rounded-lg', 24 | sm: 'h-8 rounded-lg px-3 text-xs', 25 | lg: 'h-10 rounded-lg px-8', 26 | icon: 'h-12 w-12 rounded-full', 27 | }, 28 | }, 29 | defaultVariants: { 30 | variant: 'default', 31 | styleSize: 'default', 32 | }, 33 | }, 34 | ); 35 | 36 | export interface ButtonProps extends ButtonHTMLAttributes, VariantProps { 37 | asChild?: boolean; 38 | children?: ComponentChildren; 39 | } 40 | 41 | export const Button = forwardRef( 42 | ({ variant, styleSize, asChild = false, children, ...props }, ref) => { 43 | const classes = buttonVariants({ variant, styleSize, class: props.class }); 44 | 45 | if (asChild && children && typeof children === 'object' && 'type' in children) { 46 | const Child = children.type; 47 | // @ts-expect-error - Passing refs through might need additional typing 48 | return ; 49 | } 50 | 51 | return ( 52 | 55 | ); 56 | }, 57 | ); 58 | -------------------------------------------------------------------------------- /src/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import { forwardRef, HTMLAttributes } from 'preact/compat'; 2 | import { cn } from '~/lib/utils'; 3 | 4 | const Card = forwardRef>(({ ...props }, ref) => ( 5 |
6 | )); 7 | Card.displayName = 'Card'; 8 | 9 | const CardHeader = forwardRef>(({ ...props }, ref) => ( 10 |
11 | )); 12 | CardHeader.displayName = 'CardHeader'; 13 | 14 | const CardTitle = forwardRef>(({ ...props }, ref) => ( 15 |

16 | )); 17 | CardTitle.displayName = 'CardTitle'; 18 | 19 | const CardDescription = forwardRef>(({ ...props }, ref) => ( 20 |

21 | )); 22 | CardDescription.displayName = 'CardDescription'; 23 | 24 | const CardContent = forwardRef>(({ ...props }, ref) => ( 25 |

26 | )); 27 | CardContent.displayName = 'CardContent'; 28 | 29 | const CardFooter = forwardRef>(({ ...props }, ref) => ( 30 |
31 | )); 32 | CardFooter.displayName = 'CardFooter'; 33 | 34 | export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }; 35 | -------------------------------------------------------------------------------- /src/components/ui/dropdown-list.tsx: -------------------------------------------------------------------------------- 1 | import { ChevronDown } from 'lucide-preact'; 2 | import { JSX } from 'preact'; 3 | import { HTMLAttributes, useRef, useState } from 'preact/compat'; 4 | import { cn } from '~/lib/utils'; 5 | 6 | export interface DropdownOption { 7 | value: string; 8 | label: JSX.Element | string; 9 | searchableLabel?: string; 10 | } 11 | 12 | interface DropdownListProps extends HTMLAttributes { 13 | options: DropdownOption[]; 14 | value?: string; 15 | onChangeOption: (value: string) => void; 16 | placeholder?: string; 17 | align?: 'left' | 'right'; 18 | useNativePopover?: boolean; 19 | id?: string; 20 | trigger?: React.ReactNode; 21 | } 22 | 23 | export function DropdownList({ 24 | options, 25 | value, 26 | onChangeOption, 27 | placeholder = 'Select...', 28 | align = 'left', 29 | useNativePopover = false, 30 | trigger, 31 | ...props 32 | }: DropdownListProps) { 33 | const [isOpen, setIsOpen] = useState(false); 34 | const [searchTerm, setSearchTerm] = useState(''); 35 | const [highlightedIndex, setHighlightedIndex] = useState(0); 36 | const popoverRef = useRef(null); 37 | const searchInputRef = useRef(null); 38 | 39 | const selectedOption = options.find((o) => o.value === value); 40 | const filteredOptions = options.filter((o) => { 41 | const searchableText = o.searchableLabel || (typeof o.label === 'string' ? o.label : ''); 42 | return searchableText.toLowerCase().includes(searchTerm.toLowerCase()); 43 | }); 44 | 45 | const closePopover = () => { 46 | if (useNativePopover && popoverRef.current) { 47 | popoverRef.current.hidePopover(); 48 | } else { 49 | setIsOpen(false); 50 | } 51 | setSearchTerm(''); 52 | }; 53 | 54 | const handleKeyDown = (e: KeyboardEvent) => { 55 | switch (e.key) { 56 | case 'ArrowDown': 57 | e.preventDefault(); 58 | setHighlightedIndex((prev) => (prev < filteredOptions.length - 1 ? prev + 1 : prev)); 59 | break; 60 | case 'ArrowUp': 61 | e.preventDefault(); 62 | setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : prev)); 63 | break; 64 | case 'Enter': 65 | e.preventDefault(); 66 | if (filteredOptions[highlightedIndex]) { 67 | onChangeOption(filteredOptions[highlightedIndex].value); 68 | closePopover(); 69 | setSearchTerm(''); 70 | } 71 | break; 72 | case 'Escape': 73 | closePopover(); 74 | setSearchTerm(''); 75 | break; 76 | } 77 | }; 78 | 79 | const defaultTrigger = ( 80 | 99 | ); 100 | 101 | const content = ( 102 |
115 |
116 | { 121 | setSearchTerm((e.target as HTMLInputElement).value); 122 | setHighlightedIndex(0); 123 | }} 124 | placeholder="Type to search..." 125 | class="w-full px-3 py-2 border rounded-md focus:outline-hidden focus:ring-2" 126 | /> 127 |
128 |
    129 | {!filteredOptions.length ? ( 130 |
  • No options found
  • 131 | ) : ( 132 | filteredOptions.map((option, index) => ( 133 |
  • { 138 | onChangeOption(option.value); 139 | closePopover(); 140 | }} 141 | class={cn( 142 | 'px-4 py-2 cursor-pointer flex items-center gap-2', 143 | 'hover:bg-accent hover:text-accent-foreground', 144 | index === highlightedIndex && 'bg-accent text-accent-foreground', 145 | )} 146 | > 147 | {option.label} 148 |
  • 149 | )) 150 | )} 151 |
152 |
153 | ); 154 | 155 | return ( 156 |
157 | {trigger || defaultTrigger} 158 | {!useNativePopover && isOpen && content} 159 | {useNativePopover && content} 160 |
161 | ); 162 | } 163 | -------------------------------------------------------------------------------- /src/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import { forwardRef, InputHTMLAttributes } from 'preact/compat'; 2 | import { cn } from '~/lib/utils'; 3 | 4 | const Input = forwardRef>(({ type, ...props }, ref) => { 5 | return ( 6 | 19 | ); 20 | }); 21 | Input.displayName = 'Input'; 22 | 23 | export { Input }; 24 | -------------------------------------------------------------------------------- /src/components/ui/radio-button-list.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'preact/hooks'; 2 | import { RadioButton as RadioButtonListOption, RadioButtonProps } from './radio-button'; 3 | 4 | export type RadioButtonListOption = Pick; 5 | 6 | type RadioButtonListProps = { 7 | name: string; 8 | options: RadioButtonListOption[]; 9 | value: string; 10 | onChange: (value: string) => void; 11 | }; 12 | 13 | export const RadioButtonList = ({ name, options, value, onChange }: RadioButtonListProps) => { 14 | const onRadioButtonChange = useCallback((value: string) => { 15 | onChange(value); 16 | }, [onChange]); 17 | 18 | return ( 19 |
    20 | {options.map((option) => { 21 | return ( 22 |
  • 23 | 31 |
  • 32 | ); 33 | })} 34 |
35 | ); 36 | }; 37 | -------------------------------------------------------------------------------- /src/components/ui/radio-button.tsx: -------------------------------------------------------------------------------- 1 | export type RadioButtonProps = { 2 | name: string; 3 | label: string; 4 | value: string; 5 | checked: boolean; 6 | description?: string; 7 | onChange: (value: string) => void; 8 | }; 9 | 10 | export const RadioButton = ({ name, label, value, checked, description, onChange }: RadioButtonProps) => { 11 | const id = `${name}-${value}`; 12 | 13 | const onClick = (e: MouseEvent) => { 14 | onChange((e.currentTarget as HTMLInputElement).value); 15 | }; 16 | 17 | return ( 18 |
19 | 31 | 37 |
38 | ); 39 | }; 40 | -------------------------------------------------------------------------------- /src/components/ui/switch.tsx: -------------------------------------------------------------------------------- 1 | import type { ComponentProps } from 'preact'; 2 | import { cn } from '~/lib/utils'; 3 | 4 | type Props = ComponentProps<'input'> & { 5 | label?: string; 6 | }; 7 | 8 | export function Switch({ label, ...props }: Props) { 9 | return ( 10 |
11 |