├── .dockerignore ├── .env.example ├── .eslintignore ├── .eslintrc.cjs ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.yaml │ └── feature_request.yaml ├── PULL_REQUEST_TEMPLATE.md └── workflows │ └── test.yaml ├── .gitignore ├── .husky └── pre-commit ├── .lintstagedrc.cjs ├── .npmrc ├── .nycrc.json ├── .prettierignore ├── .prettierrc.cjs ├── .typesafe-i18n.json ├── .vscode ├── default.settings.json └── extensions.json ├── CODE-OF-CONDUCT.md ├── CONTRIBUTING.md ├── DEVLOG.md ├── Dockerfile ├── Dockerfile.test ├── LICENSE ├── MVP_FEATURES.md ├── NOTES.md ├── README.md ├── docker-compose.base.yml ├── docker-compose.dev.yml ├── docker-compose.prod.yml ├── docker-compose.test.watch.yml ├── docker-compose.test.yml ├── package-lock.json ├── package.json ├── playwright.config.ts ├── postcss.config.cjs ├── prisma ├── ERD.md ├── schema.prisma └── seeds │ ├── data │ ├── locales.json │ └── test.json │ ├── index.ts │ ├── locales.ts │ └── testSeed.ts ├── scripts └── create-test-env.sh ├── src ├── app.d.ts ├── app.html ├── app.postcss ├── hooks.server.ts ├── lib │ ├── components │ │ ├── AvatarWithFallback.svelte │ │ ├── ChannelCard.svelte │ │ ├── ListCard.svelte │ │ ├── ViewCount.svelte │ │ ├── YouTubeThumbnailEmbed.svelte │ │ └── YouTubeVideoEmbed.svelte │ ├── config.server.ts │ ├── db.server.ts │ ├── i18n │ │ ├── ar │ │ │ └── index.ts │ │ ├── de │ │ │ └── index.ts │ │ ├── en │ │ │ └── index.ts │ │ ├── es │ │ │ └── index.ts │ │ ├── formatters.ts │ │ ├── i18n-node.ts │ │ ├── i18n-svelte.ts │ │ ├── i18n-types.ts │ │ ├── i18n-util.async.ts │ │ ├── i18n-util.sync.ts │ │ ├── i18n-util.ts │ │ ├── it │ │ │ └── index.ts │ │ ├── ru │ │ │ └── index.ts │ │ └── uk │ │ │ └── index.ts │ ├── parseDescription.test.ts │ ├── parseDescription.ts │ ├── prisma │ │ └── client.ts │ ├── schemas.ts │ ├── server │ │ ├── YouTubeAPI.ts │ │ ├── queries.ts │ │ ├── redis.ts │ │ └── youtube.ts │ ├── stores │ │ ├── SeoStore.ts │ │ ├── UserStore.ts │ │ └── VideoPlayerStore.ts │ └── utils.ts └── routes │ ├── (protected) │ ├── components │ │ ├── ChannelCardActions.svelte │ │ ├── ChannelSearch.svelte │ │ └── ListForm.svelte │ ├── create │ │ ├── +page.server.ts │ │ └── +page.svelte │ ├── list │ │ └── [id] │ │ │ └── edit │ │ │ ├── +page.server.ts │ │ │ └── +page.svelte │ └── onboarding │ │ ├── +page.server.ts │ │ └── +page.svelte │ ├── +layout.server.ts │ ├── +layout.svelte │ ├── +layout.ts │ ├── +page.server.ts │ ├── +page.svelte │ ├── NavTrail.svelte │ ├── SEO.svelte │ └── [username] │ └── [slug] │ ├── +layout.server.ts │ ├── +layout.svelte │ ├── +page.svelte │ └── watch │ └── [videoid] │ └── +page.svelte ├── static └── favicon.png ├── svelte.config.js ├── tailwind.config.ts ├── tests ├── app.test.ts ├── baseFixtures.ts ├── createList.test.ts ├── editList.test.ts ├── oauth.test.ts ├── onboarding.test.ts ├── utils.ts ├── viewList.test.ts └── viewVideo.test.ts ├── tsconfig.json ├── types ├── db.ts └── types.d.ts └── vite.config.ts /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | VITE_COVERAGE=true 2 | AUTH_SECRET=generate-long-random-string-for-this 3 | YOUTUBE_API_KEY=insert-your-api-key-here 4 | GOOGLE_CLIENT_ID=insert-your-client-id-here 5 | GOOGLE_CLIENT_SECRET=insert-your-client-secret-here 6 | REDIS_PORT=6379 7 | REDIS_URL=redis://localhost:${REDIS_PORT} 8 | DB_HOST=localhost 9 | DB_USER=listdapp 10 | DB_PASSWORD=supersecret 11 | DB_NAME=listd 12 | DB_PORT=5432 13 | DATABASE_URL=postgresql://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME} -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /package 6 | .env 7 | .env.* 8 | !.env.example 9 | !*.cjs 10 | docker-data/ 11 | 12 | # Ignore files for PNPM, NPM and YARN 13 | pnpm-lock.yaml 14 | package-lock.json 15 | yarn.lock 16 | /coverage* -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: '@typescript-eslint/parser', 4 | extends: [ 5 | 'eslint:recommended', 6 | 'plugin:@typescript-eslint/recommended', 7 | 'plugin:svelte/recommended', 8 | 'airbnb-base', 9 | 'airbnb-typescript/base', 10 | 'plugin:prettier/recommended', 11 | ], 12 | plugins: ['@typescript-eslint', 'import-no-duplicates-prefix-resolved-path'], 13 | ignorePatterns: ['src/lib/i18n/*.ts'], 14 | overrides: [ 15 | { 16 | files: ['**/*.svelte'], 17 | parser: 'svelte-eslint-parser', 18 | parserOptions: { 19 | parser: '@typescript-eslint/parser', 20 | }, 21 | rules: { 22 | 'import/no-named-as-default': 0, 23 | 'import/no-named-as-default-member': 0, 24 | }, 25 | }, 26 | ], 27 | parserOptions: { 28 | sourceType: 'module', 29 | ecmaVersion: 2020, 30 | extraFileExtensions: ['.svelte'], 31 | project: './tsconfig.json', 32 | }, 33 | env: { 34 | browser: true, 35 | es2017: true, 36 | node: true, 37 | }, 38 | globals: { 39 | NodeJS: true, 40 | }, 41 | settings: { 42 | 'import/parsers': { 43 | '@typescript-eslint/parser': ['.cjs', '.js', '.ts'], 44 | }, 45 | 'import/resolver': { 46 | typescript: { 47 | alwaysTryTypes: true, 48 | }, 49 | }, 50 | }, 51 | rules: { 52 | 'arrow-body-style': ['error', 'as-needed'], 53 | 'prefer-arrow-callback': ['error', { allowNamedFunctions: false, allowUnboundThis: true }], 54 | 'import/prefer-default-export': 0, 55 | 'no-param-reassign': 0, 56 | 'import/extensions': 0, 57 | 'import/no-extraneous-dependencies': 0, 58 | 'import/no-mutable-exports': 0, 59 | 'import/no-duplicates': 0, 60 | 'import-no-duplicates-prefix-resolved-path/no-duplicates': [ 61 | 'error', 62 | { 63 | prefixResolvedPathWithImportName: true, 64 | }, 65 | ], 66 | 'no-restricted-imports': ['error', { paths: ['$env/static/private'] }], 67 | 'no-self-assign': 0, 68 | '@typescript-eslint/no-throw-literal': 0, 69 | }, 70 | }; 71 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yaml: -------------------------------------------------------------------------------- 1 | name: "\U0001F41E Bug report" 2 | description: Report an issue 3 | body: 4 | - type: markdown 5 | attributes: 6 | value: | 7 | First thing first, thanks for reporting! 8 | - type: textarea 9 | id: bug-description 10 | attributes: 11 | label: Describe the bug 12 | description: A clear and concise description of what the bug is. If you intend to submit a PR for this issue, tell us in the description. Thanks! 13 | placeholder: Bug description 14 | validations: 15 | required: true 16 | - type: textarea 17 | id: screenshots 18 | attributes: 19 | label: Screenshots 20 | description: If you have any screenshot that you need to share in order to better prove your point here's where you can do it. 21 | - type: textarea 22 | id: reproduction 23 | attributes: 24 | label: Reproduction 25 | description: Please provide a link to a repo or better a stackblitz/replit that can reproduce the problem you ran into. This will speed up the fixing. 26 | placeholder: Reproduction 27 | validations: 28 | required: true 29 | - type: textarea 30 | id: logs 31 | attributes: 32 | label: Logs 33 | description: 'Please include browser console and server logs around the time this bug occurred. Optional if provided reproduction. Please try not to insert an image but copy paste the log text.' 34 | render: shell 35 | - type: checkboxes 36 | id: searched 37 | attributes: 38 | label: Have you checked if this issue has already been raised? 39 | options: 40 | - label: I did not find any similar issues 41 | required: true 42 | - type: checkboxes 43 | id: CoC 44 | attributes: 45 | label: Code of Conduct 46 | description: By submitting this issue, you agree to follow our [Code of Conduct](https://github.com/CodingGarden/listd/blob/main/CODE-OF-CONDUCT.md) 47 | options: 48 | - label: I agree to follow this project's Code of Conduct 49 | required: true 50 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yaml: -------------------------------------------------------------------------------- 1 | name: "\U0001F4A1 Feature Request" 2 | description: Request a new feature 3 | body: 4 | - type: markdown 5 | attributes: 6 | value: | 7 | Thank you for taking the time to propose a new idea 8 | - type: textarea 9 | id: problem 10 | attributes: 11 | label: Describe the problem 12 | description: Please provide a clear and concise description the problem this feature would solve. The more information you can provide here, the better. 13 | placeholder: I would like to... 14 | validations: 15 | required: true 16 | - type: textarea 17 | id: solution 18 | attributes: 19 | label: Describe the proposed solution 20 | description: Try to provide a description or a design of what you would like to be implemented 21 | placeholder: I would like to see... 22 | validations: 23 | required: true 24 | - type: checkboxes 25 | id: searched 26 | attributes: 27 | label: Have you checked if this issue has already been raised? 28 | options: 29 | - label: I did not find any similar issues 30 | required: true 31 | - type: checkboxes 32 | id: CoC 33 | attributes: 34 | label: Code of Conduct 35 | description: By submitting this issue, you agree to follow our [Code of Conduct](https://github.com/CodingGarden/listd/blob/main/CODE-OF-CONDUCT.md) 36 | options: 37 | - label: I agree to follow this project's Code of Conduct 38 | required: true 39 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## What type of Pull Request is this? 4 | 5 | **Delete all options except for the one that applies** 6 | 7 | - Bug fix 8 | - Feature 9 | - Code style update (formatting, renaming) 10 | - Refactoring (no functional changes, no API changes) 11 | - Build related changes 12 | - Other (please describe): 13 | 14 | ## What is the current behavior? 15 | 16 | 17 | 18 | Issue Number: N/A 19 | 20 | ## What is the new behavior? 21 | 22 | 23 | 24 | - 25 | - 26 | - 27 | 28 | ## Other information 29 | 30 | 31 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Run Tests 2 | on: 3 | pull_request: 4 | branches: 5 | - main 6 | 7 | jobs: 8 | test: 9 | runs-on: ubuntu-latest 10 | environment: test 11 | env: 12 | AUTH_SECRET: ${{ secrets.AUTH_SECRET }} 13 | DB_PASSWORD: ${{ secrets.DB_PASSWORD }} 14 | GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} 15 | GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }} 16 | YOUTUBE_API_KEY: ${{ secrets.YOUTUBE_API_KEY }} 17 | VITE_COVERAGE: ${{ vars.VITE_COVERAGE }} 18 | NODE_ENV: ${{ vars.NODE_ENV }} 19 | REDIS_PORT: ${{ vars.REDIS_PORT }} 20 | REDIS_HOST: ${{ vars.REDIS_HOST }} 21 | DB_HOST: ${{ vars.DB_HOST }} 22 | DB_USER: ${{ vars.DB_USER }} 23 | DB_NAME: ${{ vars.DB_NAME }} 24 | DB_PORT: ${{ vars.DB_PORT }} 25 | steps: 26 | - uses: actions/checkout@v3 27 | - uses: actions/setup-node@v3 28 | with: 29 | node-version: '18' 30 | - run: npm ci 31 | - run: ./scripts/create-test-env.sh 32 | - run: cp .env.test .env 33 | - run: npx svelte-kit sync 34 | - run: npm run check 35 | - run: npm run lint 36 | # - run: npx playwright install 37 | - run: npm run test:unit 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /package 6 | .env 7 | .env.test 8 | .env.* 9 | !.env.example 10 | vite.config.js.timestamp-* 11 | vite.config.ts.timestamp-* 12 | /prisma/dev.db 13 | /docker-data 14 | settings.json 15 | auth.json 16 | .nyc_output 17 | coverage 18 | scripts/exportDB.ts 19 | /coverage* -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx svelte-kit sync 5 | npx lint-staged 6 | npm run test:unit 7 | -------------------------------------------------------------------------------- /.lintstagedrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | // TODO: only run svelte-check for changed files... 3 | '**/*.{js,ts,cjs,svelte,tsx}': [() => 'npm run check', 'eslint'], 4 | }; 5 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | -------------------------------------------------------------------------------- /.nycrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "all": true, 3 | "reporter": "html", 4 | "report-dir": "./coverage", 5 | "temp-dir": ".nyc_output", 6 | "extension": [".ts", ".js", ".svelte"], 7 | "include-all-sources": true, 8 | "include": ["src/**"], 9 | "exclude": [ 10 | "**/*/+layout.ts", 11 | "**/*/*.server.ts", 12 | "src/lib/server/**/*", 13 | "src/lib/i18n/**/*", 14 | "**/*.test.js", 15 | "**/*.test.ts" 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /package 6 | .env 7 | .env.* 8 | !.env.example 9 | !*.cjs 10 | src/lib/i18n/*.ts 11 | docker-data/ 12 | 13 | # Ignore files for PNPM, NPM and YARN 14 | pnpm-lock.yaml 15 | package-lock.json 16 | yarn.lock 17 | -------------------------------------------------------------------------------- /.prettierrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | useTabs: true, 3 | singleQuote: true, 4 | semi: true, 5 | trailingComma: 'es5', 6 | tabWidth: 2, 7 | printWidth: 100, 8 | bracketSameLine: true, 9 | plugins: ['prettier-plugin-svelte', import('prettier-plugin-tailwindcss')], 10 | tailwindConfig: './tailwind.config.ts', 11 | overrides: [{ files: '*.svelte', options: { parser: 'svelte' } }], 12 | }; 13 | -------------------------------------------------------------------------------- /.typesafe-i18n.json: -------------------------------------------------------------------------------- 1 | { 2 | "adapter": "svelte", 3 | "outputPath": "./src/lib/i18n/", 4 | "$schema": "https://unpkg.com/typesafe-i18n@5.26.2/schema/typesafe-i18n.json" 5 | } 6 | -------------------------------------------------------------------------------- /.vscode/default.settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.validate": ["javascript", "typescript", "svelte"], 3 | "typescript.tsdk": "node_modules/typescript/lib" 4 | } 5 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "dbaeumer.vscode-eslint", 4 | "esbenp.prettier-vscode", 5 | "svelte.svelte-vscode", 6 | "bradlc.vscode-tailwindcss", 7 | "Prisma.prisma" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /CODE-OF-CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | - Using welcoming and inclusive language 12 | - Being respectful of differing viewpoints and experiences 13 | - Gracefully accepting constructive criticism 14 | - Attempting collaboration before conflict 15 | - Focusing on what is best for the community 16 | - Showing empathy towards other community members 17 | 18 | Examples of unacceptable behavior by participants include: 19 | 20 | - Violence, threats of violence, or inciting others to commit self-harm 21 | - The use of sexualized language or imagery and unwelcome sexual attention or advances 22 | - Trolling, intentionally spreading misinformation, insulting/derogatory comments, and personal or political attacks 23 | - Public or private harassment 24 | - Publishing others' private information, such as a physical or electronic address, without explicit permission 25 | - Abuse of the reporting process to intentionally harass or exclude others 26 | - Advocating for, or encouraging, any of the above behavior 27 | - Other conduct which could reasonably be considered inappropriate in a professional setting 28 | 29 | ## Our Responsibilities 30 | 31 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 32 | 33 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 34 | 35 | ## Scope 36 | 37 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 38 | 39 | ## Enforcement 40 | 41 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting us through: 42 | 43 | - [DM on Twitter](https://twitter.com/coding_garden) 44 | 45 | - DM on Discord - w3cj#7953 46 | 47 | 48 | All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. 49 | 50 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 51 | 52 | If you are unsure whether an incident is a violation, or whether the space where the incident took place is covered by our Code of Conduct, **we encourage you to still report it**. We would prefer to have a few extra reports where we decide to take no action, than to leave an incident go unnoticed and unresolved that may result in an individual or group to feel like they can no longer participate in the community. Reports deemed as not a violation will also allow us to improve our Code of Conduct and processes surrounding it. If you witness a dangerous situation or someone in distress, we encourage you to report even if you are only an observer. 53 | 54 | [This Code of Conduct was adapted from Auth0](https://github.com/auth0/open-source-template/blob/master/CODE-OF-CONDUCT.md) 55 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution Guidelines 2 | 3 | When contributing to `listd`, whether on GitHub or in other community spaces: 4 | 5 | - Be respectful, civil, and open-minded. 6 | - Before opening a new pull request, try searching through the [issue tracker](https://github.com/CodingGarden/listd/issues) for known issues or fixes. 7 | - If you want to make code changes based on your personal opinion(s), make sure you open an issue first describing the changes you want to make, and open a pull request only when your suggestions get approved by maintainers. 8 | 9 | ## How to Contribute 10 | 11 | ### Prerequisites 12 | 13 | In an effort to respect your time, if you wanted to implement a change that has already been declined, or is generally not needed, start by [opening an issue](https://github.com/CodingGarden/listd/issues/new) describing the problem you would like to solve. 14 | 15 | ### Setup your environment 16 | 17 | Fork the [Listd repository](https://github.com/CodingGarden/listd) to your own GitHub account and then clone it to your local device. 18 | 19 | ```bash 20 | git clone git@github.com:YOUR_USER_NAME/listd.git 21 | ``` 22 | 23 | Then, install the project's dependencies: 24 | 25 | ```bash 26 | npm install 27 | ``` 28 | 29 | ### Set up the database 30 | 31 | This project uses [PostgreSQL](https://www.postgresql.org/) as its database. 32 | 33 | The project has a `docker-compose.dev.yml` file ready to use if you have [Docker](https://www.docker.com/) installed. 34 | 35 | You can also install Postgres on your local machine [directly](https://www.prisma.io/dataguide/postgresql/setting-up-a-local-postgresql-database) or use a cloud service. 36 | 37 | copy `.env.example` to `.env` 38 | 39 | ```bash 40 | cp .env.example .env 41 | ``` 42 | 43 | #### `.env` variables for PostgreSQL 44 | 45 | ```bash 46 | DB_HOST=your_database_host 47 | DB_PORT=your_database_port 48 | DB_USER=your_database_user 49 | DB_PASSWORD=your_database_password 50 | DB_NAME=your_database_name 51 | DATABASE_URL=postgresql://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME} 52 | ``` 53 | 54 | If you are using Docker, you can use the following values: 55 | 56 | ```bash 57 | DB_HOST=localhost 58 | DB_USER=listdapp 59 | DB_PASSWORD=supersecret 60 | DB_NAME=listd 61 | DB_PORT=5432 62 | DATABASE_URL=postgresql://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME} 63 | ``` 64 | 65 | - DATABASE_URL: The full database connection URL. This is required and is used by prisma. 66 | 67 | #### Set up PostgreSQL by Docker Compose 68 | 69 | If you have [Docker](https://www.docker.com/) installed, you can use the following command to start a PostgreSQL container: 70 | 71 | ```bash 72 | docker-compose -f docker-compose.dev.yml up -d --wait 73 | ``` 74 | 75 | #### Prisma Setup 76 | 77 | Use the following command to generate the Prisma client: 78 | 79 | ```bash 80 | npx prisma migrate dev 81 | ``` 82 | 83 | View the database diagram [here](./prisma/ERD.md). 84 | 85 | #### Playwright Setup 86 | 87 | Ensure you have the Playwright executables installed to run tests: 88 | 89 | ```bash 90 | npx playwright install 91 | ``` 92 | 93 | ### Getting Google OAuth API Credentials 94 | 95 | 1. Visit the [Google Cloud Console](https://console.developers.google.com/apis/credentials) 96 | 2. Go to the OAuth consent screen tab, fill first step leaving the rest blank and click Save. This will create a project for you 97 | 3. Now Publish your OAuth consent screen App. 98 | 4. Go to the [Credentials tab](https://console.cloud.google.com/apis/credentials) and click Create Credentials -> OAuth Client ID 99 | - Choose Web Application 100 | - Add `http://localhost:5173` to the Authorized JavaScript origins 101 | - Add `http://localhost:5173/auth/callback/google` to the Authorized redirect URIs. 102 | - Click Create. 103 | 5. Copy the Client ID and Client Secret and paste them into the `.env` file. 104 | 105 | ```bash 106 | AUTH_SECRET=your_secret 107 | GOOGLE_CLIENT_ID=your_client_id 108 | GOOGLE_CLIENT_SECRET=your_client_secret 109 | ``` 110 | 111 | ### Run the project 112 | 113 | To run the project in development mode, run the following command: 114 | 115 | ```bash 116 | npm run dev 117 | ``` 118 | 119 | ### Make changes 120 | 121 | Before making any changes, make sure you create a new branch: 122 | 123 | ```bash 124 | git checkout -b your-branch-name 125 | ``` 126 | 127 | When you're done making changes, commit them and push them to your fork: 128 | 129 | ```bash 130 | git add . 131 | git commit -m "your commit message" 132 | git push 133 | ``` 134 | 135 | Then, [create a pull request](https://github.com/CodingGarden/listd/pulls) 136 | from your fork to the `main` branch of the `listd` repository. 137 | 138 | ## Server Environment Variables 139 | 140 | Server environment variables should only be accessed from [`'$lib/config.server.ts'`](./src/lib/config.server.ts) (do not import directly from `'$env/static/private'`). 141 | 142 | If you need access to a new environment variable, add it to the schema in [`'$lib/config.server.ts'`](./src/lib/config.server.ts). 143 | 144 | ## Code Style Guidelines 145 | 146 | In order to maintain consistent and readable code, this project adheres to certain code style guidelines. Please follow these guidelines when contributing to the project. 147 | 148 | ### Linter 149 | 150 | This project uses `ESLint` as our linter tool. To configure your VSCode workspace to show lint warnings, you can find a suggested configuration file, named `default.settings.json`, in the `.vscode` directory. Copy and rename this file to `settings.json` to enable the linter warnings in VSCode. Please do not alter the original `default.settings.json`. 151 | 152 | To further improve your development experience while working on `listd`, this project also includes a list of suggested VSCode extensions in the file `.vscode/extensions.json`. 153 | 154 | ### Formatter 155 | 156 | This project uses [Prettier](https://prettier.io/) to format the code. You can run `npm run format:fix` to format the code before committing. 157 | 158 | 159 | 160 | 161 | 162 | ## License 163 | 164 | By contributing, you agree that your contributions will be licensed under its MIT License. 165 | -------------------------------------------------------------------------------- /DEVLOG.md: -------------------------------------------------------------------------------- 1 | 2 | # Today 3 | 4 | - [x] Update dependencies 5 | - [x] Fix breaking changes... 6 | - [ ] Plan / Categorize / Prioritize features 7 | - [ ] Work on a feature 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | # Last Time 22 | 23 | - [x] Review PRs 24 | - [x] update dependencies 25 | - [x] YouTube Embed API 26 | - [x] parse timestamps in description, link changes timestamp in video 27 | - [ ] List create UX 28 | - [ ] Only show channel avatar 29 | - [ ] Small remove button 30 | - [ ] Tooltip with channel name 31 | - [ ] Desktop styles - 2 column 32 | - [ ] Channel adder is on the right 33 | 34 | 35 | ## Later... 36 | 37 | - [ ] .env.test does not exist on CI server... 38 | - [ ] Remove test ids in Prod build 39 | - [ ] Update contributing guide... 40 | - [ ] Running tests in watch mode... 41 | - [ ] Debugging tests in watch mode... 42 | - [ ] Exporting data to be seeded for tests 43 | 44 | # Last Time 45 | 46 | - [x] Home Page List 47 | - [x] List actions 48 | - [x] View 49 | - [x] Edit 50 | - [x] Fix scroll position on page change 51 | - [x] Video view page 52 | - [x] expandable description box 53 | - [x] Render description with markdown 54 | - [x] Containerize tests 55 | - [ ] More integration tests 56 | - [x] Run tests / containers while dev containers are still running 57 | - [x] Seed DB with unique users for each type of test 58 | - [x] TODO: make a watch specific config 59 | - [x] Watch for only test updates 60 | - [ ] Tests: 61 | - [x] Search for YT channels 62 | - [x] Add YT channel to List 63 | - [x] Create List 64 | - [x] View List 65 | - [x] View individual List 66 | - [x] Coverage Reports 67 | - [x] Generate outside container (volume) 68 | - [x] Navigating Lists 69 | - [x] Edit List 70 | - [x] Make sure $LL is being run in tests... 71 | - [x] Svelte stores highlighted red?? 72 | - [ ] .env.test does not exist on CI server... 73 | - [ ] Remove test ids in Prod build 74 | - [ ] Update contributing guide... 75 | - [ ] Running tests in watch mode... 76 | - [ ] Debugging tests in watch mode... 77 | - [ ] Exporting data to be seeded for tests 78 | 79 | # Upcoming 80 | 81 | - [ ] List edit / update page 82 | - [x] Update readme with tech stack 83 | - [ ] List create UX 84 | - [ ] Only show channel avatar 85 | - [ ] Click to expand 86 | - [ ] Reveals channel name 87 | - [ ] Reveals remove button 88 | - [ ] Show name on hover 89 | - [ ] 2 column layout on desktop 90 | 91 | ## TBD 92 | 93 | - [ ] Deploy??? 94 | - [ ] Database??? 95 | - Free Tier 96 | - Serverless / Edge Function Support... 97 | - Connection Pooling 98 | - Fly.io 99 | - Railway 100 | - Supabase 101 | - Render 102 | - ElephantSQL 103 | - Paid 104 | - Heroku 105 | - AWS RDS 106 | - [ ] App?? - Vercel 107 | 108 | # Past 109 | 110 | - [x] Merge PRs 111 | - [x] Update dependencies 112 | - [x] Testing todos: 113 | - [x] Fix tests on CI 114 | - [x] Docker / prisma test setup 115 | - [x] Configure e2e tests 116 | - [x] Seed DB 117 | - [x] Generate user token during test (do not do oauth flow during test...) 118 | - Anything else that comes up... 119 | - [x] Update dependencies 120 | - [x] Minor / Patch bump 121 | - [x] Major bump 122 | - [x] Review PRs and issues 123 | - [x] Create page form styles 124 | - [x] Channel card styles 125 | - [x] Update the List page styles 126 | - [x] Update DB schema 127 | - Last updated 128 | - More metadata 129 | - Include Channel Image 130 | - Include Channel handle 131 | - Number of subscribers 132 | - Verified badges 133 | - [x] More robust YT API Response 134 | - [x] Lazy load embedded video and thumbnails 135 | - [x] Cache YT API Response 136 | - [x] Get ALL videos of each channel 137 | - [x] Get ALL video info of each video 138 | - [x] View Count 139 | - [x] Length / Duration 140 | - [x] Like Count 141 | - [x] Fix YT Video order (latest by default...) 142 | - [x] List Page Styles 143 | - [x] Fix video titles 144 | - [x] Single Video Page 145 | 146 | ## 2023-03-10 147 | 148 | - [x] Upgrade Auth.js 149 | - [x] Upgrade Skeleton UI 150 | - [x] Wireframe create list page 151 | - [x] Create list page 152 | - [x] Set title 153 | - [x] Set Descriptio 154 | 155 | ## 2023-02-03 156 | 157 | - [x] Review PRs 158 | - [x] Eslint / Airbnb / Prettier Setup... 159 | 160 | ## 2023-01-27 161 | 162 | - [x] Review PRs 163 | - [x] Tailwind bug fix 164 | - [x] Contributing Guide 165 | - [x] Setup i18n 166 | - [x] Research options 167 | - [x] Install / setup 168 | - [x] Use dictionaries on landing page 169 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18-alpine AS builder 2 | 3 | WORKDIR /app 4 | 5 | COPY package*.json /app/ 6 | 7 | RUN npm ci 8 | 9 | COPY . . 10 | 11 | ENV NODE_BUILD=true 12 | 13 | RUN npm run build 14 | 15 | CMD node build 16 | 17 | FROM node:18-alpine as production 18 | 19 | COPY --from=builder ./build . 20 | 21 | ENV NODE_ENV=production 22 | 23 | EXPOSE 3000 24 | 25 | CMD node ./build 26 | -------------------------------------------------------------------------------- /Dockerfile.test: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/playwright:v1.41.2-jammy 2 | 3 | WORKDIR /app 4 | 5 | COPY package*.json /app/ 6 | 7 | RUN npm ci 8 | 9 | RUN npm install -g dotenv-cli 10 | 11 | COPY . . 12 | 13 | ENV NODE_BUILD=true 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License Copyright (c) 2023 Coding Garden 2 | 3 | Permission is hereby granted, free of 4 | charge, to any person obtaining a copy of this software and associated 5 | documentation files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, copy, modify, merge, 7 | publish, distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to the 9 | following conditions: 10 | 11 | The above copyright notice and this permission notice 12 | (including the next paragraph) shall be included in all copies or substantial 13 | portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 16 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 17 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO 18 | EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR 19 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 20 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /MVP_FEATURES.md: -------------------------------------------------------------------------------- 1 | # MVP 2 | 3 | ## Design 4 | 5 | - [ ] [Add Logo](https://github.com/CodingGarden/listd/issues/5#issuecomment-1744993170) 6 | 7 | ## Features 8 | 9 | - [x] Page Transitions with @pablopang 10 | - [x] Add Icon Library 11 | - [x] Put Icons on all Action Buttons 12 | - [x] List Create / Edit Page 13 | - [x] detect changes in form 14 | - [x] Prevent navigation if unsaved changes 15 | - [x] Disable update / save button if no changes 16 | - [x] List permissions / auth 17 | - [x] Private lists can only be viewed by the creator 18 | - [ ] List Page 19 | - [x] Access list via slug 20 | - [ ] Share list / copy URL to clipboard 21 | - [ ] Previous / Next Buttons 22 | - [ ] Infinite scroll / pagination / lazy loading 23 | - [ ] On video end, play next video 24 | - [ ] Loop back to beginning if we reach the end 25 | - [ ] List Sort / Filters 26 | * [ ] Views 27 | * [ ] Newest / Oldest 28 | * [ ] Likes 29 | * [ ] Random / Feeling Lucky 30 | * [ ] Toggle 31 | * [ ] Shorts 32 | * [ ] Videos 33 | * [ ] Live-streams 34 | * [ ] Duration 35 | * [ ] Min 36 | * [ ] Max 37 | * [ ] Toggle Channels in List 38 | * [ ] Show 1 video from each channel at a time... 39 | - [ ] Channel cache refresh button 40 | - [ ] Show last updated time / cache time for each channel 41 | - [ ] Landing Page / Features 42 | - [ ] Limits 43 | - [ ] Max number of lists per user 44 | - [ ] Max number of channels per list 45 | - [ ] Users can favorite a list 46 | - [ ] Show favorited lists on user dashboard 47 | - [ ] Fork an existing list 48 | - [ ] Public user profile page 49 | - [ ] Show public lists 50 | - [ ] User Preferences 51 | - [ ] Volume 52 | - [ ] Video Speed 53 | - [ ] Autoplay 54 | - [ ] Message Queue 55 | - [ ] Seperate Service that receives requests for Cacheing 56 | - [ ] Calls YT API, updates cache 57 | - [ ] SvelteKit app -> put messages into the queue requesting caching 58 | 59 | ## Architecture 60 | 61 | - [ ] Message Queues 62 | * [ ] Explore: 63 | - Redis 64 | - pub / sub 65 | - flags 66 | 67 | ## Metrics 68 | 69 | - [ ] Analytics Service 70 | - [ ] Error Reporting 71 | 72 | ## Legal 73 | 74 | - [ ] Terms of Service 75 | - [ ] GDPR 76 | - [ ] Privacy Policy 77 | 78 | ## Bugs 79 | 80 | - [ ] Firefox tab navigation 81 | - [ ] Redirect to homepage on logout -------------------------------------------------------------------------------- /NOTES.md: -------------------------------------------------------------------------------- 1 | Right now we only support YouTube channels. Eventually, we will support other types of items in the feed. For example: 2 | 3 | - YouTube - Channel 4 | - Instagram - Profile / Page 5 | - TikTok - Profile 6 | - Twitter - User 7 | - Reddit - Subreddit 8 | - Reddit User - Reddit User 9 | - Blog - Author 10 | 11 | # UI Design 12 | 13 | - Wireframing 14 | - UX 15 | - User Experience 16 | - Mockup 17 | - Higher Fidelity 18 | - Prototype 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # LISTD Media 2 | 3 | An app that will allow users to **create**, _share_ and watch 👀 lists of YouTube channels. 4 | 5 | # Tech Stack 6 | 7 | ## Backend 8 | 9 | - [SvelteKit](https://kit.svelte.dev/) 10 | - [Auth.js](https://authjs.dev/) 11 | - Databases 12 | - [Postgres](https://www.postgresql.org/) 13 | - [Prisma](https://www.prisma.io/) 14 | - [Redis](https://redis.io/) 15 | - [node-redis](https://www.npmjs.com/package/redis) 16 | 17 | ## Frontend 18 | 19 | - [Svelte](https://svelte.dev/) 20 | - [tailwind](https://tailwindcss.com/) 21 | - [Skeleton UI](https://www.skeleton.dev/) 22 | 23 | ## Libraries 24 | 25 | - [typesafe-i18n](https://github.com/ivanhofer/typesafe-i18n/) 26 | - [zod](https://www.npmjs.com/package/zod) 27 | - [@googleapis/youtube](https://www.npmjs.com/package/@googleapis/youtube) 28 | - [luxon](https://www.npmjs.com/package/luxon) 29 | 30 | ## Code Quality 31 | 32 | - [eslint](https://www.npmjs.com/package/eslint) 33 | - [eslint-config-airbnb-typescript](https://www.npmjs.com/package/eslint-config-airbnb-typescript) 34 | - [prettier](https://www.npmjs.com/package/prettier) 35 | - [lint-staged](https://www.npmjs.com/package/lint-staged) 36 | - [husky](https://www.npmjs.com/package/husky) 37 | 38 | ## Testing 39 | 40 | - E2E / Integration 41 | - [Playwright](https://playwright.dev/) 42 | - Coverage 43 | - [Istanbul](https://www.npmjs.com/package/nyc) 44 | 45 | ## CI / CD 46 | 47 | - [Docker](https://www.docker.com/) 48 | - [Github Actions](https://github.com/features/actions) 49 | -------------------------------------------------------------------------------- /docker-compose.base.yml: -------------------------------------------------------------------------------- 1 | version: '3.1' 2 | 3 | services: 4 | db: 5 | image: postgres:15 6 | restart: always 7 | environment: 8 | POSTGRES_PASSWORD: ${DB_PASSWORD} 9 | POSTGRES_USER: ${DB_USER} 10 | POSTGRES_DB: ${DB_NAME} 11 | healthcheck: 12 | test: ['CMD-SHELL', 'pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB'] 13 | interval: 1s 14 | timeout: 3s 15 | retries: 30 16 | redis: 17 | image: redis:7 18 | restart: always 19 | healthcheck: 20 | test: ['CMD', 'redis-cli', 'ping'] 21 | interval: 1s 22 | timeout: 3s 23 | retries: 30 24 | -------------------------------------------------------------------------------- /docker-compose.dev.yml: -------------------------------------------------------------------------------- 1 | version: '3.1' 2 | 3 | services: 4 | db-dev: 5 | extends: 6 | file: docker-compose.base.yml 7 | service: db 8 | ports: 9 | - ${DB_PORT}:5432 10 | volumes: 11 | - ./docker-data/db:/var/lib/postgresql/data 12 | redis-dev: 13 | ports: 14 | - ${REDIS_PORT}:6379 15 | extends: 16 | file: docker-compose.base.yml 17 | service: redis 18 | volumes: 19 | - ./docker-data/redis:/data 20 | -------------------------------------------------------------------------------- /docker-compose.prod.yml: -------------------------------------------------------------------------------- 1 | version: '3.1' 2 | services: 3 | app: 4 | build: 5 | dockerfile: Dockerfile 6 | target: production 7 | restart: always 8 | ports: 9 | - 5173:5173 10 | environment: 11 | - AUTH_SECRET=${AUTH_SECRET} 12 | - GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID} 13 | - GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET} 14 | - DB_HOST=${DB_HOST} 15 | - DB_USER=${DB_USER} 16 | - DB_PASSWORD=${DB_PASSWORD} 17 | - DB_NAME=${DB_NAME} 18 | - DB_PORT=${DB_PORT} 19 | - DATABASE_URL=${DATABASE_URL} 20 | -------------------------------------------------------------------------------- /docker-compose.test.watch.yml: -------------------------------------------------------------------------------- 1 | version: '3.1' 2 | name: listd-test-watch 3 | 4 | services: 5 | db-test: 6 | extends: 7 | file: docker-compose.base.yml 8 | service: db 9 | redis-test: 10 | extends: 11 | file: docker-compose.base.yml 12 | service: redis 13 | app-test: 14 | extends: 15 | file: docker-compose.test.yml 16 | service: app-test 17 | volumes: 18 | - ./:/app 19 | - /app/node_modules 20 | ports: 21 | - 4173:4173 22 | environment: 23 | - PWTEST_WATCH=1 24 | command: > 25 | sh -c "dotenv -e .env.test -- npm run migrate:init && \ 26 | dotenv -e .env.test -- npx prisma db seed && \ 27 | dotenv -e .env.test -- npx prisma generate && \ 28 | dotenv -e .env.test -- npm run build && \ 29 | dotenv -e .env.test -- npx playwright test" 30 | -------------------------------------------------------------------------------- /docker-compose.test.yml: -------------------------------------------------------------------------------- 1 | version: '3.1' 2 | name: listd-test 3 | 4 | services: 5 | db-test: 6 | extends: 7 | file: docker-compose.base.yml 8 | service: db 9 | redis-test: 10 | extends: 11 | file: docker-compose.base.yml 12 | service: redis 13 | app-test: 14 | build: 15 | context: . 16 | dockerfile: Dockerfile.test 17 | volumes: 18 | - ./coverage:/app/coverage 19 | depends_on: 20 | db-test: 21 | condition: service_healthy 22 | redis-test: 23 | condition: service_healthy 24 | command: > 25 | sh -c "dotenv -e .env.test -- npm run migrate:init && \ 26 | dotenv -e .env.test -- npx prisma db seed && \ 27 | dotenv -e .env.test -- npx prisma generate && \ 28 | dotenv -e .env.test -- npm run build && \ 29 | dotenv -e .env.test -- npx nyc playwright test" 30 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "listd", 3 | "version": "0.0.1", 4 | "private": true, 5 | "scripts": { 6 | "prepare": "husky install", 7 | "dev": "concurrently \"vite dev\" typesafe-i18n", 8 | "build": "vite build", 9 | "preview": "vite preview --host 0.0.0.0", 10 | "test": "npm run test:unit && npm run test:e2e", 11 | "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", 12 | "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", 13 | "test:unit": "vitest run", 14 | "test:e2e": "npm run docker-down:test && npm run docker-up:test", 15 | "test:e2e:watch": "npm run docker-down:test:watch && npm run docker-up:test:watch", 16 | "lint": "eslint --ext .js,.cjs,.ts,.svelte .", 17 | "typecheck": "npm run check", 18 | "format": "prettier --check .", 19 | "lint:fix": "eslint --ext .js,.cjs,.ts,.svelte . --fix", 20 | "format:fix": "prettier --write .", 21 | "migrate:init": "prisma migrate dev --name init", 22 | "migrate:deploy": "prisma migrate deploy", 23 | "docker-up:test": "docker compose -f docker-compose.test.yml up --force-recreate --build --abort-on-container-exit --exit-code-from app-test", 24 | "docker-down:test": "docker compose -f docker-compose.test.yml down", 25 | "docker-up:test:watch": "docker compose -f docker-compose.test.watch.yml up --build", 26 | "docker-down:test:watch": "docker compose -f docker-compose.test.watch.yml down", 27 | "docker-up:dev": "docker compose -f docker-compose.dev.yml up -d --wait", 28 | "docker-down:dev": "docker compose -f docker-compose.dev.yml down", 29 | "update:interactive": "npx npm-check-updates -i" 30 | }, 31 | "prisma": { 32 | "seed": "tsx ./prisma/seeds/index.ts" 33 | }, 34 | "devDependencies": { 35 | "@playwright/test": "^1.41.2", 36 | "@skeletonlabs/skeleton": "^2.8.0", 37 | "@skeletonlabs/tw-plugin": "^0.3.1", 38 | "@sveltejs/adapter-auto": "^3.1.1", 39 | "@sveltejs/adapter-node": "^4.0.1", 40 | "@sveltejs/kit": "^2.5.0", 41 | "@sveltejs/vite-plugin-svelte": "^3.0.2", 42 | "@tailwindcss/forms": "^0.5.7", 43 | "@types/node": "^20.11.16", 44 | "@typescript-eslint/eslint-plugin": "^6.21.0", 45 | "@typescript-eslint/parser": "^6.21.0", 46 | "autoprefixer": "^10.4.17", 47 | "concurrently": "^8.2.2", 48 | "cross-env": "^7.0.3", 49 | "dotenv-cli": "^7.3.0", 50 | "eslint": "^8.56.0", 51 | "eslint-config-airbnb-base": "^15.0.0", 52 | "eslint-config-airbnb-typescript": "^17.1.0", 53 | "eslint-config-prettier": "^9.1.0", 54 | "eslint-import-resolver-typescript": "^3.6.1", 55 | "eslint-plugin-import": "^2.29.1", 56 | "eslint-plugin-import-no-duplicates-prefix-resolved-path": "^2.0.0", 57 | "eslint-plugin-prettier": "^5.1.3", 58 | "eslint-plugin-svelte": "^2.35.1", 59 | "husky": "^9.0.10", 60 | "lint-staged": "^15.2.2", 61 | "nyc": "^15.1.0", 62 | "postcss": "^8.4.34", 63 | "postcss-load-config": "^5.0.2", 64 | "prettier": "^3.2.5", 65 | "prettier-plugin-svelte": "^3.1.2", 66 | "prettier-plugin-tailwindcss": "^0.5.11", 67 | "prisma": "^5.9.1", 68 | "prisma-erd-generator-markdown": "^1.3.1", 69 | "svelte": "^4.2.10", 70 | "svelte-check": "^3.6.3", 71 | "svelte-preprocess": "^5.1.3", 72 | "sveltekit-superforms": "^1.13.4", 73 | "sveltekit-view-transition": "^0.5.3", 74 | "tailwindcss": "^3.4.1", 75 | "tslib": "^2.6.2", 76 | "tsx": "^4.7.0", 77 | "typesafe-i18n": "^5.26.2", 78 | "typescript": "^5.3.3", 79 | "typescript-svelte-plugin": "^0.3.37", 80 | "vite": "^5.0.12", 81 | "vite-plugin-istanbul": "^5.0.0", 82 | "vitest": "^1.2.2" 83 | }, 84 | "type": "module", 85 | "dependencies": { 86 | "@auth/core": "0.19.0", 87 | "@auth/sveltekit": "0.5.0", 88 | "@googleapis/youtube": "^13.1.0", 89 | "@next-auth/prisma-adapter": "^1.0.7", 90 | "@prisma/client": "^5.9.1", 91 | "@types/luxon": "^3.4.2", 92 | "@types/youtube-player": "^5.5.11", 93 | "anchorme": "^3.0.5", 94 | "env-cmd": "^10.1.0", 95 | "lucide-svelte": "^0.323.0", 96 | "luxon": "^3.4.4", 97 | "redis": "^4.6.13", 98 | "slugify": "^1.6.6", 99 | "youtube-player": "^5.6.0", 100 | "zod": "^3.22.4" 101 | }, 102 | "license": "MIT", 103 | "engines": { 104 | "npm": ">= 7.0.0", 105 | "yarn": "Please use npm instead of yarn.", 106 | "pnpm": "Please use npm instead of pnpm." 107 | }, 108 | "bugs": { 109 | "url": "https://github.com/CodingGarden/listd/issues" 110 | }, 111 | "keywords": [ 112 | "listd", 113 | "svelte", 114 | "typescript", 115 | "tailwindcss" 116 | ], 117 | "author": "Coding Garden ('https://coding.garden/')", 118 | "repository": { 119 | "type": "git", 120 | "url": "git@github.com:CodingGarden/listd.git" 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import type { PlaywrightTestConfig } from '@playwright/test'; 2 | 3 | const config: PlaywrightTestConfig = { 4 | use: { 5 | locale: 'en-US', 6 | // headless: false, 7 | }, 8 | webServer: { 9 | command: `dotenv -e .env.test -- npm run preview`, 10 | port: 4173, 11 | }, 12 | testDir: 'tests', 13 | }; 14 | 15 | export default config; 16 | -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | 3 | const tailwindcss = require('tailwindcss'); 4 | const autoprefixer = require('autoprefixer'); 5 | 6 | const config = { 7 | plugins: [ 8 | // Some plugins, like tailwindcss/nesting, need to run before Tailwind, 9 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 10 | // @ts-ignore 11 | tailwindcss(), 12 | // But others, like autoprefixer, need to run after, 13 | autoprefixer, 14 | ], 15 | }; 16 | 17 | module.exports = config; 18 | -------------------------------------------------------------------------------- /prisma/ERD.md: -------------------------------------------------------------------------------- 1 | ```mermaid 2 | erDiagram 3 | ColorScheme { 4 | value System 5 | value Dark 6 | value Light 7 | } 8 | Visibility { 9 | value Public 10 | value Unlisted 11 | value Private 12 | } 13 | ListItemType { 14 | value YouTubeChannel 15 | } 16 | User { 17 | String id PK "dbgenerated(gen_random_uuid())" 18 | DateTime createdAt "now()" 19 | DateTime updatedAt 20 | String name "nullable" 21 | String email "nullable" 22 | DateTime emailVerified "nullable" 23 | String image "nullable" 24 | } 25 | UserSettings { 26 | String id PK "dbgenerated(gen_random_uuid())" 27 | Boolean onboarded 28 | ColorScheme colorScheme "System" 29 | String userId FK 30 | String localeId FK 31 | } 32 | Locale { 33 | String id 34 | String languageCode 35 | String countryCode "nullable" 36 | String script "nullable" 37 | String formalName 38 | String nativeName 39 | String commonName "nullable" 40 | } 41 | Account { 42 | String id PK "dbgenerated(gen_random_uuid())" 43 | String userId FK 44 | String type 45 | String provider 46 | String providerAccountId 47 | String refreshToken "nullable" 48 | String accessToken "nullable" 49 | Int expiresAt "nullable" 50 | String tokenType "nullable" 51 | String scope "nullable" 52 | String idToken "nullable" 53 | String sessionState "nullable" 54 | String username "nullable" 55 | } 56 | Session { 57 | String id PK "dbgenerated(gen_random_uuid())" 58 | String sessionToken 59 | String userId FK 60 | DateTime expires 61 | } 62 | VerificationToken { 63 | String identifier 64 | String token 65 | DateTime expires 66 | } 67 | List { 68 | String id PK "dbgenerated(gen_random_uuid())" 69 | String slug 70 | String title 71 | String description "nullable" 72 | Visibility visibility 73 | String userId FK 74 | DateTime createdAt "now()" 75 | DateTime updatedAt 76 | } 77 | ListItem { 78 | Int id PK "autoincrement()" 79 | String name 80 | String description "nullable" 81 | String listId FK 82 | String listItemMetaId FK 83 | DateTime createdAt "now()" 84 | DateTime updatedAt 85 | } 86 | ListItemMeta { 87 | String id PK "dbgenerated(gen_random_uuid())" 88 | String name 89 | String originId 90 | String imageUrl 91 | ListItemType type 92 | DateTime createdAt "now()" 93 | DateTime updatedAt 94 | String youTubeMetaOriginId FK "nullable" 95 | } 96 | YouTubeMeta { 97 | String originId 98 | String name 99 | String description 100 | Int subscriberCount 101 | String avatarUrl 102 | String bannerUrl "nullable" 103 | String customUrl 104 | Boolean isVerified 105 | DateTime createdAt "now()" 106 | DateTime updatedAt 107 | } 108 | User }|--|{ UserSettings : settings 109 | UserSettings }|--|{ User : user 110 | UserSettings }o--|| Locale : locale 111 | UserSettings }o--|| ColorScheme : "enum:colorScheme" 112 | Account }o--|| User : user 113 | Session }o--|| User : user 114 | List }o--|| User : creator 115 | List }o--|| Visibility : "enum:visibility" 116 | ListItem }o--|| ListItemMeta : meta 117 | ListItem }o--|| List : list 118 | ListItemMeta }o--|| YouTubeMeta : youtubeMeta 119 | ListItemMeta }o--|| ListItemType : "enum:type" 120 | 121 | ``` 122 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | generator client { 2 | provider = "prisma-client-js" 3 | } 4 | 5 | datasource db { 6 | provider = "postgresql" 7 | url = env("DATABASE_URL") 8 | } 9 | 10 | generator erd { 11 | provider = "prisma-erd-generator-markdown" 12 | output = "./ERD.md" 13 | } 14 | 15 | model User { 16 | id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid 17 | createdAt DateTime @default(now()) 18 | updatedAt DateTime @updatedAt 19 | name String? 20 | email String? @unique 21 | emailVerified DateTime? 22 | image String? 23 | accounts Account[] 24 | sessions Session[] 25 | lists List[] 26 | settings UserSettings? 27 | } 28 | 29 | enum ColorScheme { 30 | System 31 | Dark 32 | Light 33 | } 34 | 35 | model UserSettings { 36 | id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid 37 | onboarded Boolean @default(false) 38 | colorScheme ColorScheme @default(System) 39 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 40 | userId String @unique @db.Uuid 41 | locale Locale @relation(fields: [localeId], references: [id], onDelete: Cascade) 42 | localeId String @db.VarChar(15) 43 | } 44 | 45 | model Locale { 46 | id String @unique @db.VarChar(15) 47 | languageCode String 48 | countryCode String? 49 | script String? 50 | formalName String 51 | nativeName String 52 | commonName String? 53 | UserSettings UserSettings[] 54 | } 55 | 56 | model Account { 57 | id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid 58 | userId String @db.Uuid 59 | type String 60 | provider String 61 | providerAccountId String 62 | refresh_token String? @map("refreshToken") @db.Text 63 | access_token String? @map("accessToken") @db.Text 64 | expires_at Int? @map("expiresAt") 65 | token_type String? @map("tokenType") 66 | scope String? 67 | id_token String? @map("idToken") @db.Text 68 | session_state String? @map("sessionState") 69 | username String? @db.Text 70 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 71 | 72 | @@unique([provider, providerAccountId]) 73 | } 74 | 75 | model Session { 76 | id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid 77 | sessionToken String @unique 78 | userId String @db.Uuid 79 | expires DateTime 80 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 81 | } 82 | 83 | model VerificationToken { 84 | identifier String 85 | token String @unique 86 | expires DateTime 87 | 88 | @@unique([identifier, token]) 89 | } 90 | 91 | enum Visibility { 92 | Public 93 | Unlisted 94 | Private 95 | } 96 | 97 | model List { 98 | id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid 99 | slug String 100 | title String 101 | description String? 102 | visibility Visibility 103 | creator User @relation(fields: [userId], references: [id]) 104 | userId String @db.Uuid 105 | items ListItem[] 106 | createdAt DateTime @default(now()) 107 | updatedAt DateTime @updatedAt 108 | 109 | @@unique([slug, userId]) 110 | } 111 | 112 | enum ListItemType { 113 | YouTubeChannel 114 | } 115 | 116 | model ListItem { 117 | id Int @id @default(autoincrement()) 118 | name String 119 | description String? 120 | meta ListItemMeta @relation(fields: [listItemMetaId], references: [id]) 121 | list List @relation(fields: [listId], references: [id]) 122 | listId String @db.Uuid 123 | listItemMetaId String @db.Uuid 124 | createdAt DateTime @default(now()) 125 | updatedAt DateTime @updatedAt 126 | } 127 | 128 | model ListItemMeta { 129 | id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid 130 | name String 131 | originId String 132 | imageUrl String 133 | type ListItemType 134 | youtubeMeta YouTubeMeta? @relation(fields: [youTubeMetaOriginId], references: [originId]) 135 | listItem ListItem[] 136 | createdAt DateTime @default(now()) 137 | updatedAt DateTime @updatedAt 138 | youTubeMetaOriginId String? 139 | 140 | @@unique([originId, type]) 141 | } 142 | 143 | model YouTubeMeta { 144 | originId String @unique 145 | name String 146 | description String 147 | subscriberCount Int 148 | avatarUrl String 149 | bannerUrl String? 150 | customUrl String 151 | isVerified Boolean @default(false) 152 | createdAt DateTime @default(now()) 153 | updatedAt DateTime @updatedAt 154 | ListItemMeta ListItemMeta[] 155 | } 156 | -------------------------------------------------------------------------------- /prisma/seeds/data/test.json: -------------------------------------------------------------------------------- 1 | { 2 | "users": [ 3 | { 4 | "user": { 5 | "id": "225a4f47-3400-48d9-940a-8f783df4b269", 6 | "name": "Coding Garden", 7 | "email": "coding.garden.with.cj@gmail.com", 8 | "image": "https://lh3.googleusercontent.com/a/AGNmyxaVWgarpgX3nvDQGdsLrvg0u4EWwxVdwcfIV7D8=s96-c" 9 | }, 10 | "userSettings": { 11 | "id": "5c2eb837-bb51-4175-b054-c37d1cb40ad6", 12 | "onboarded": false, 13 | "userId": "225a4f47-3400-48d9-940a-8f783df4b269", 14 | "localeId": "en-US" 15 | }, 16 | "account": { 17 | "id": "5c292cb2-281d-43b6-89b2-f9b61dcd2432", 18 | "userId": "225a4f47-3400-48d9-940a-8f783df4b269", 19 | "type": "oidc", 20 | "provider": "google", 21 | "providerAccountId": "117131308771673354735", 22 | "refresh_token": null, 23 | "access_token": null, 24 | "expires_at": 0, 25 | "token_type": "bearer", 26 | "scope": "openid https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email", 27 | "id_token": null, 28 | "session_state": null, 29 | "username": "@CodingGarden" 30 | }, 31 | "session": { 32 | "id": "9b03816b-275c-4fbf-b77e-104ab41d87ca", 33 | "sessionToken": "23f4fc33-b70a-44df-8db1-f1e44c2b24fd", 34 | "userId": "225a4f47-3400-48d9-940a-8f783df4b269", 35 | "expires": "2030-06-11T21:08:54.125Z" 36 | } 37 | }, 38 | { 39 | "user": { 40 | "id": "fc57d839-cfba-4076-9dd5-1def7964b037", 41 | "name": "Coding Garden", 42 | "email": "coding.garden.with.cj2@gmail.com", 43 | "image": "https://lh3.googleusercontent.com/a/AGNmyxaVWgarpgX3nvDQGdsLrvg0u4EWwxVdwcfIV7D8=s96-c" 44 | }, 45 | "userSettings": { 46 | "id": "83810d2a-8014-46c8-a482-b8d8f91b842c", 47 | "onboarded": true, 48 | "userId": "fc57d839-cfba-4076-9dd5-1def7964b037", 49 | "localeId": "en-US" 50 | }, 51 | "account": { 52 | "id": "0c39442f-105a-4e20-9e73-5da3636e4180", 53 | "userId": "fc57d839-cfba-4076-9dd5-1def7964b037", 54 | "type": "oidc", 55 | "provider": "google", 56 | "providerAccountId": "117131308771673354736", 57 | "refresh_token": null, 58 | "access_token": null, 59 | "expires_at": 0, 60 | "token_type": "bearer", 61 | "scope": "openid https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email", 62 | "id_token": null, 63 | "session_state": null, 64 | "username": "@CodingGarden" 65 | }, 66 | "session": { 67 | "id": "d95a2ca5-7b78-4193-a74b-549dd65bd876", 68 | "sessionToken": "4454f8d8-6296-4860-8330-9591f17bc67a", 69 | "userId": "fc57d839-cfba-4076-9dd5-1def7964b037", 70 | "expires": "2030-06-11T21:08:54.125Z" 71 | } 72 | }, 73 | { 74 | "user": { 75 | "id": "2cf9ff84-57fd-470b-9c92-f3265d70d88f", 76 | "name": "Coding Garden", 77 | "email": "coding.garden.with.cj3@gmail.com", 78 | "image": "https://lh3.googleusercontent.com/a/AGNmyxaVWgarpgX3nvDQGdsLrvg0u4EWwxVdwcfIV7D8=s96-c" 79 | }, 80 | "userSettings": { 81 | "id": "abe846f1-7c99-4ce9-bb1d-84cf5594b6fa", 82 | "onboarded": true, 83 | "userId": "2cf9ff84-57fd-470b-9c92-f3265d70d88f", 84 | "localeId": "en-US" 85 | }, 86 | "account": { 87 | "id": "a6cc8379-8e01-4010-a99b-b9ef7c15e0be", 88 | "userId": "2cf9ff84-57fd-470b-9c92-f3265d70d88f", 89 | "type": "oidc", 90 | "provider": "google", 91 | "providerAccountId": "117131308771673354737", 92 | "refresh_token": null, 93 | "access_token": null, 94 | "expires_at": 0, 95 | "token_type": "bearer", 96 | "scope": "openid https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email", 97 | "id_token": null, 98 | "session_state": null, 99 | "username": "@CodingGarden" 100 | }, 101 | "session": { 102 | "id": "b57661b6-ef95-4bf7-8e8c-ef6cc4cb3983", 103 | "sessionToken": "c9f043b6-6b1b-451c-85a7-547f18c08ecd", 104 | "userId": "2cf9ff84-57fd-470b-9c92-f3265d70d88f", 105 | "expires": "2030-06-11T21:08:54.125Z" 106 | }, 107 | "list": [ 108 | { 109 | "id": "644974bd-2fe2-461a-b1bd-4eb8c85032e2", 110 | "title": "Coding Garden Universe", 111 | "slug": "coding-garden-universe", 112 | "description": "All Coding Garden videos.", 113 | "visibility": "Unlisted", 114 | "userId": "2cf9ff84-57fd-470b-9c92-f3265d70d88f", 115 | "createdAt": "2023-03-31T21:14:56.427Z", 116 | "updatedAt": "2023-06-09T21:17:58.639Z", 117 | "items": [ 118 | { 119 | "id": 56, 120 | "name": "Coding Garden", 121 | "description": null, 122 | "listId": "644974bd-2fe2-461a-b1bd-4eb8c85032e2", 123 | "listItemMetaId": "67f77b57-5d57-4937-8e15-6ec6c42f43a3", 124 | "createdAt": "2023-05-05T21:23:54.982Z", 125 | "updatedAt": "2023-05-05T21:23:54.982Z", 126 | "meta": { 127 | "id": "67f77b57-5d57-4937-8e15-6ec6c42f43a3", 128 | "name": "Coding Garden", 129 | "originId": "UCLNgu_OupwoeESgtab33CCw", 130 | "imageUrl": "https://yt3.ggpht.com/ytc/AL5GRJWfwAQ7ueFbb7CeMogiJSn4dluagZ3KdvY1jLmqcQ=s88-c-k-c0x00ffffff-no-rj", 131 | "type": "YouTubeChannel", 132 | "createdAt": "2023-03-31T21:14:56.661Z", 133 | "updatedAt": "2023-03-31T21:14:56.661Z", 134 | "youTubeMetaOriginId": "UCLNgu_OupwoeESgtab33CCw", 135 | "youtubeMeta": { 136 | "originId": "UCLNgu_OupwoeESgtab33CCw", 137 | "name": "Coding Garden", 138 | "description": "Hosted by CJ, Coding Garden is an open, interactive and engaging community where any coder, from beginner to veteran, can learn and grow together. Whether it's a tutorial, Q&A session, algorithmic problem solving or full application design and build, there's always something new to learn!\n\nWatch live on twitch: https://www.twitch.tv/codinggarden\n", 139 | "subscriberCount": 130000, 140 | "avatarUrl": "https://yt3.ggpht.com/ytc/AL5GRJWfwAQ7ueFbb7CeMogiJSn4dluagZ3KdvY1jLmqcQ=s88-c-k-c0x00ffffff-no-rj", 141 | "bannerUrl": null, 142 | "customUrl": "@codinggarden", 143 | "isVerified": false, 144 | "createdAt": "2023-03-31T21:14:56.661Z", 145 | "updatedAt": "2023-03-31T21:14:56.661Z" 146 | } 147 | } 148 | }, 149 | { 150 | "id": 57, 151 | "name": "Coding Garden Archive", 152 | "description": null, 153 | "listId": "644974bd-2fe2-461a-b1bd-4eb8c85032e2", 154 | "listItemMetaId": "b187f88e-64da-489e-81e3-33bd379fda43", 155 | "createdAt": "2023-05-05T21:23:54.981Z", 156 | "updatedAt": "2023-05-05T21:23:54.981Z", 157 | "meta": { 158 | "id": "b187f88e-64da-489e-81e3-33bd379fda43", 159 | "name": "Coding Garden Archive", 160 | "originId": "UCIAW44-a_W8dGAhZDar7OmA", 161 | "imageUrl": "https://yt3.ggpht.com/KQUkzZqg3sY2sxSBxQTYGNt9szA3cMGdkTXMgbLT1x8GGUjVV60Y9tzyT1q6digRNhjnSpGDNtI=s88-c-k-c0x00ffffff-no-rj", 162 | "type": "YouTubeChannel", 163 | "createdAt": "2023-03-31T21:14:56.595Z", 164 | "updatedAt": "2023-03-31T21:14:56.595Z", 165 | "youTubeMetaOriginId": "UCIAW44-a_W8dGAhZDar7OmA", 166 | "youtubeMeta": { 167 | "originId": "UCIAW44-a_W8dGAhZDar7OmA", 168 | "name": "Coding Garden Archive", 169 | "description": "This channel archives all Coding Garden streams.", 170 | "subscriberCount": 1680, 171 | "avatarUrl": "https://yt3.ggpht.com/KQUkzZqg3sY2sxSBxQTYGNt9szA3cMGdkTXMgbLT1x8GGUjVV60Y9tzyT1q6digRNhjnSpGDNtI=s88-c-k-c0x00ffffff-no-rj", 172 | "bannerUrl": null, 173 | "customUrl": "@codinggardenarchive", 174 | "isVerified": false, 175 | "createdAt": "2023-03-31T21:14:56.595Z", 176 | "updatedAt": "2023-03-31T21:14:56.595Z" 177 | } 178 | } 179 | } 180 | ] 181 | } 182 | ] 183 | } 184 | ] 185 | } 186 | -------------------------------------------------------------------------------- /prisma/seeds/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import { PrismaClient } from '@prisma/client'; 3 | import { seed as localeSeed } from './locales'; 4 | import { seed as testSeed } from './testSeed'; 5 | 6 | const prismaClient = new PrismaClient(); 7 | 8 | async function main() { 9 | let exitStatus = 0; 10 | try { 11 | await localeSeed(prismaClient); 12 | if (process.env.NODE_ENV?.toLowerCase() === 'test') { 13 | await testSeed(prismaClient); 14 | } 15 | } catch (error) { 16 | // TODO: use logging library 17 | console.error(error); 18 | exitStatus = 1; 19 | } finally { 20 | await prismaClient.$disconnect(); 21 | process.exit(exitStatus); 22 | } 23 | } 24 | 25 | main(); 26 | -------------------------------------------------------------------------------- /prisma/seeds/locales.ts: -------------------------------------------------------------------------------- 1 | import type { Locale, PrismaClient } from '@prisma/client'; 2 | // Source: https://www.localeplanet.com/icu/ 3 | // Code: https://gist.github.com/w3cj/a8215fe403164a248492f2ec63beb9a3 4 | // manually removed POSIX en 5 | // manually removed 001, 150 and 419 locales 6 | import data from './data/locales.json' assert { type: 'json' }; 7 | 8 | export async function seed(prismaClient: PrismaClient) { 9 | await prismaClient.$executeRawUnsafe(`TRUNCATE TABLE "Locale" CASCADE;`); 10 | await prismaClient.locale.createMany({ 11 | data: data.map((locale) => { 12 | const [languageCode, ...parts] = locale.id.split('_'); 13 | let countryCode = parts[0] || null; 14 | let script: string | null = null; 15 | if (countryCode && countryCode.length > 2) { 16 | countryCode = null; 17 | [script] = parts; 18 | } 19 | if (parts.length === 2) { 20 | [script, countryCode] = parts; 21 | } 22 | return { 23 | id: locale.id.replaceAll('_', '-'), 24 | formalName: locale.formalName, 25 | commonName: locale.commonName || null, 26 | nativeName: locale.nativeName, 27 | languageCode, 28 | countryCode, 29 | script, 30 | }; 31 | }), 32 | }); 33 | } 34 | -------------------------------------------------------------------------------- /prisma/seeds/testSeed.ts: -------------------------------------------------------------------------------- 1 | import { Visibility, type PrismaClient, ListItemType } from '@prisma/client'; 2 | import data from './data/test.json' assert { type: 'json' }; 3 | 4 | export async function seed(prismaClient: PrismaClient) { 5 | await prismaClient.user.createMany({ 6 | data: data.users.map((user) => user.user), 7 | }); 8 | await prismaClient.userSettings.createMany({ 9 | data: data.users.map((user) => user.userSettings), 10 | }); 11 | await prismaClient.account.createMany({ 12 | data: data.users.map((user) => user.account), 13 | }); 14 | await prismaClient.session.createMany({ 15 | data: data.users.map((user) => user.session), 16 | }); 17 | 18 | await Promise.all( 19 | data.users.map(async (user) => { 20 | if (user.list) { 21 | await Promise.all( 22 | user.list.map(async (list) => { 23 | const { items, ...createList } = list; 24 | await prismaClient.list.create({ 25 | data: { 26 | ...createList, 27 | visibility: Visibility[createList.visibility as Visibility], 28 | }, 29 | }); 30 | await Promise.all( 31 | list.items.map(async (item) => { 32 | const { meta, ...createListItem } = item; 33 | const { youtubeMeta, ...createListItemMeta } = meta; 34 | await prismaClient.youTubeMeta.create({ 35 | data: youtubeMeta, 36 | }); 37 | await prismaClient.listItemMeta.create({ 38 | data: { 39 | ...createListItemMeta, 40 | type: ListItemType[createListItemMeta.type as ListItemType], 41 | }, 42 | }); 43 | await prismaClient.listItem.create({ 44 | data: createListItem, 45 | }); 46 | }) 47 | ); 48 | }) 49 | ); 50 | } 51 | }) 52 | ); 53 | } 54 | -------------------------------------------------------------------------------- /scripts/create-test-env.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | 3 | cat <<< "VITE_COVERAGE=${VITE_COVERAGE} 4 | NODE_ENV=${NODE_ENV} 5 | REDIS_PORT=${REDIS_PORT} 6 | REDIS_HOST=${REDIS_HOST} 7 | REDIS_URL=redis://${REDIS_HOST}:${REDIS_PORT} 8 | AUTH_SECRET=${AUTH_SECRET} 9 | YOUTUBE_API_KEY=${YOUTUBE_API_KEY} 10 | GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID} 11 | GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET} 12 | DB_HOST=${DB_HOST} 13 | DB_USER=${DB_USER} 14 | DB_PASSWORD=${DB_PASSWORD} 15 | DB_NAME=${DB_NAME} 16 | DB_PORT=${DB_PORT} 17 | DATABASE_URL=postgresql://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}" > .env.test 18 | -------------------------------------------------------------------------------- /src/app.d.ts: -------------------------------------------------------------------------------- 1 | // See https://kit.svelte.dev/docs/types#app 2 | // for information about these interfaces 3 | 4 | import type { Session } from '@auth/core/types'; 5 | 6 | declare global { 7 | namespace App { 8 | // interface Error {} 9 | interface Locals { 10 | session: Session | undefined; 11 | locale: import('$lib/i18n/i18n-types.js').Locales; 12 | } 13 | // interface PageData {} 14 | // interface Platform {} 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | %sveltekit.head% 8 | 9 | 13 |
%sveltekit.body%
14 | 15 | 16 | -------------------------------------------------------------------------------- /src/app.postcss: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | @tailwind variants; 5 | 6 | :root [data-theme='crimson'] { 7 | --theme-rounded-container: theme(borderRadius.sm); 8 | --theme-rounded-base: theme(borderRadius.sm); 9 | } 10 | 11 | @layer components { 12 | .video-grid { 13 | @apply grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5; 14 | } 15 | } 16 | 17 | @media (prefers-reduced-motion) { 18 | * { 19 | animation-duration: 1ms; 20 | transition-duration: 1ms; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/hooks.server.ts: -------------------------------------------------------------------------------- 1 | import { redirect, type Handle } from '@sveltejs/kit'; 2 | import { sequence } from '@sveltejs/kit/hooks'; 3 | import { initAcceptLanguageHeaderDetector } from 'typesafe-i18n/detectors'; 4 | import { detectLocale } from '$lib/i18n/i18n-util'; 5 | import { SvelteKitAuth } from '@auth/sveltekit'; 6 | import Google from '@auth/core/providers/google'; 7 | import PrismaAdapter from '$lib/prisma/client'; 8 | import { config } from '$/lib/config.server'; 9 | import prismaClient from '$/lib/db.server'; 10 | import { updateAccountUsername } from '$/lib/server/youtube'; 11 | 12 | const handleDetectLocale = (async ({ event, resolve }) => { 13 | // TODO: get lang from cookie / user setting 14 | const acceptLanguageHeaderDetector = initAcceptLanguageHeaderDetector(event.request); 15 | const locale = detectLocale(acceptLanguageHeaderDetector); 16 | event.locals.locale = locale; 17 | 18 | return resolve(event, { transformPageChunk: ({ html }) => html.replace('%lang%', locale) }); 19 | }) satisfies Handle; 20 | 21 | const handleAuth = (async (...args) => { 22 | const [{ event }] = args; 23 | return SvelteKitAuth({ 24 | trustHost: true, 25 | adapter: PrismaAdapter(prismaClient), 26 | providers: [ 27 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 28 | // @ts-ignore 29 | Google({ 30 | id: 'google', 31 | name: 'Google', 32 | clientId: config.GOOGLE_CLIENT_ID, 33 | clientSecret: config.GOOGLE_CLIENT_SECRET, 34 | authorization: { 35 | params: { 36 | scope: 37 | 'https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile openid https://www.googleapis.com/auth/youtube.readonly', 38 | }, 39 | }, 40 | }), 41 | ], 42 | callbacks: { 43 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 44 | // @ts-ignore 45 | async session({ session, user }) { 46 | session.user = { 47 | id: user.id, 48 | name: user.name, 49 | email: user.email, 50 | image: user.image, 51 | settings: user.settings, 52 | username: user.username, 53 | }; 54 | event.locals.session = session; 55 | return session; 56 | }, 57 | }, 58 | events: { 59 | async signIn(message) { 60 | if (message.account && message.account.provider === 'google') { 61 | const username = await updateAccountUsername(message.account); 62 | if (event.locals.session?.user) { 63 | event.locals.session.user.username = username || '@Unknown'; 64 | } 65 | } 66 | }, 67 | async createUser(message) { 68 | if (!message.user.settings) { 69 | const locale = await prismaClient.locale.findFirst({ 70 | where: { 71 | id: event.locals.locale, 72 | }, 73 | }); 74 | const settings = await prismaClient.userSettings.create({ 75 | data: { 76 | localeId: locale?.id ?? 'en-US', 77 | userId: message.user.id!, 78 | }, 79 | }); 80 | message.user.settings = settings; 81 | } 82 | }, 83 | }, 84 | })(...args); 85 | }) satisfies Handle; 86 | 87 | const protectedHandle = (async ({ event, resolve }) => { 88 | await event.locals.getSession(); 89 | if (!event.locals.session && event.route.id?.includes('(protected)')) { 90 | redirect(302, '/'); 91 | } 92 | return resolve(event); 93 | }) satisfies Handle; 94 | 95 | export const handle = sequence(handleDetectLocale, handleAuth, protectedHandle); 96 | -------------------------------------------------------------------------------- /src/lib/components/AvatarWithFallback.svelte: -------------------------------------------------------------------------------- 1 | 26 | 27 | {#if !showFallback} 28 | { 58 | showFallback = true; 59 | }} 60 | alt={altText} /> 61 | {:else} 62 | {fallbackText} 64 | {/if} 65 | -------------------------------------------------------------------------------- /src/lib/components/ChannelCard.svelte: -------------------------------------------------------------------------------- 1 | 27 | 28 |
33 |
34 |
35 | {#if compact} 36 | {channel.name} 41 |
{channel.name}
42 | {:else} 43 | 48 |
49 |
{channel.name}
50 |
{channel.customUrl}
51 |
52 | {subscriberFormatter.format(Number(channel.subscriberCount))} subscribers 53 |
54 |
55 | {/if} 56 |
57 |
58 | {#if $$slots.default} 59 |
60 | 61 |
62 | {/if} 63 |
64 | -------------------------------------------------------------------------------- /src/lib/components/ListCard.svelte: -------------------------------------------------------------------------------- 1 | 17 | 18 |
19 |
20 | {list.title} 32 | {#if list.items} 33 |
34 | {#each truncatedItems as item} 35 | 40 | {/each} 41 | {#if hiddenItems > 0} 42 | +{hiddenItems} 45 | {/if} 46 |
47 | {/if} 48 |

{list.description}

49 |
50 |
51 | 52 | 57 | 58 | 59 | 64 | 65 | 66 |
67 |
68 | -------------------------------------------------------------------------------- /src/lib/components/ViewCount.svelte: -------------------------------------------------------------------------------- 1 | 8 | 9 | {$LL.labels.views(formatNumberCompact(viewCount, locale))} 10 | -------------------------------------------------------------------------------- /src/lib/components/YouTubeThumbnailEmbed.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 | 17 |
18 | {video.title} 23 | 24 |

26 | {formatNumberCompact(video.likes, locale)} 27 | 28 |

29 |

30 | {formatDuration(video.duration)} 31 |

32 |
33 |
34 |

{video.channelTitle}

35 |
39 | 40 | {formatRelativeDate(video.publishedAt, locale)} 41 |
42 | 43 |

{video.title}

44 |
45 |
46 | -------------------------------------------------------------------------------- /src/lib/components/YouTubeVideoEmbed.svelte: -------------------------------------------------------------------------------- 1 | 41 | 42 |
43 |
44 |
45 | -------------------------------------------------------------------------------- /src/lib/config.server.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-redeclare */ 2 | import { z } from 'zod'; 3 | // eslint-disable-next-line no-restricted-imports 4 | import * as environment from '$env/static/private'; 5 | 6 | export const ServerConfigSchema = z.object({ 7 | AUTH_SECRET: z.string().trim().min(32), 8 | GOOGLE_CLIENT_ID: z.string().trim().min(1), 9 | GOOGLE_CLIENT_SECRET: z.string().trim().min(1), 10 | DB_HOST: z.string().trim().min(1), 11 | DB_USER: z.string().trim().min(1), 12 | DB_PASSWORD: z.string().trim().min(1), 13 | DB_NAME: z.string().trim().min(1), 14 | DB_PORT: z.coerce.number().default(5432), 15 | DATABASE_URL: z.string().trim().min(1).url(), 16 | REDIS_URL: z.string().trim().min(1).url(), 17 | YOUTUBE_API_KEY: z.string().trim().min(1), 18 | }); 19 | 20 | export type ServerConfigSchema = z.infer; 21 | 22 | export const config = ServerConfigSchema.parse(environment); 23 | -------------------------------------------------------------------------------- /src/lib/db.server.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '@prisma/client'; 2 | 3 | export default new PrismaClient(); 4 | -------------------------------------------------------------------------------- /src/lib/i18n/ar/index.ts: -------------------------------------------------------------------------------- 1 | import type { Translation } from '../i18n-types'; 2 | import en from '../en'; 3 | 4 | const ar: Translation = { 5 | ...(en as Translation), 6 | message: 'مرحبا بالعالم', 7 | }; 8 | 9 | export default ar; 10 | -------------------------------------------------------------------------------- /src/lib/i18n/de/index.ts: -------------------------------------------------------------------------------- 1 | import type { Translation } from '../i18n-types'; 2 | import en from '../en'; 3 | 4 | const de: Translation = { 5 | ...(en as Translation), 6 | message: 'Hallo Welt', 7 | }; 8 | 9 | export default de; 10 | -------------------------------------------------------------------------------- /src/lib/i18n/en/index.ts: -------------------------------------------------------------------------------- 1 | import type { Visibility } from '@prisma/client'; 2 | import type { BaseTranslation } from '../i18n-types'; 3 | 4 | type VisibilityTranslation = { 5 | [key in Visibility]: string; 6 | }; 7 | 8 | const en: BaseTranslation = { 9 | message: 'Hello World', 10 | buttons: { 11 | create: 'Create', 12 | view: 'View', 13 | edit: 'Edit', 14 | update: 'Update', 15 | logOut: 'Logout', 16 | loginYouTube: 'Login with YouTube', 17 | remove: 'Remove', 18 | add: 'Add', 19 | }, 20 | labels: { 21 | title: 'Title', 22 | slug: 'Slug', 23 | description: 'Description', 24 | visibility: 'Visibility', 25 | views: '{0} views', 26 | filter: 'Filter', 27 | }, 28 | enums: { 29 | visibility: { 30 | Public: 'Public', 31 | Unlisted: 'Unlisted', 32 | Private: 'Private', 33 | } satisfies VisibilityTranslation, 34 | }, 35 | errors: { 36 | titleRequired: 'Title cannot be empty.', 37 | slugRequired: 'Slug cannot be empty.', 38 | slugSpecialCharacters: 'Slug cannot contain special characters', 39 | descriptionRequired: 'Description cannot be empty.', 40 | notFound: 'Not found.', 41 | listMinLength: 'A list must have at least 1 channel.', 42 | }, 43 | messages: { 44 | pleaseWait: 'Please wait...', 45 | }, 46 | pages: { 47 | root: { 48 | loggedIn: { 49 | messages: { 50 | createList: 'Click Create to get started.', 51 | }, 52 | }, 53 | messages: { 54 | tagline: 55 | "Presenting the ultimate YouTube experience. Whether you're looking for new content to watch or want to share your own curated list with friends, our app has got you covered.", 56 | }, 57 | }, 58 | onboarding: { 59 | buttons: { 60 | letsGo: 'Lets Go!', 61 | }, 62 | labels: { 63 | username: 'Username', 64 | uploadFile: 'Upload File', 65 | }, 66 | messages: { 67 | main: "Welcome to listd! Let's setup your profile.", 68 | avatar: 'Upload your avatar.', 69 | final: "That's all! Let's get started!", 70 | }, 71 | }, 72 | create: { 73 | labels: { 74 | channelSearch: 'Channel Search', 75 | }, 76 | messages: { 77 | channelSearch: 'Search for a channel...', 78 | }, 79 | }, 80 | }, 81 | }; 82 | 83 | export default en; 84 | -------------------------------------------------------------------------------- /src/lib/i18n/es/index.ts: -------------------------------------------------------------------------------- 1 | import type { Translation } from '../i18n-types'; 2 | import en from '../en'; 3 | 4 | const es: Translation = { 5 | ...(en as Translation), 6 | message: 'Hola Mundo', 7 | }; 8 | 9 | export default es; 10 | -------------------------------------------------------------------------------- /src/lib/i18n/formatters.ts: -------------------------------------------------------------------------------- 1 | import type { FormattersInitializer } from 'typesafe-i18n'; 2 | import type { Locales, Formatters } from './i18n-types'; 3 | 4 | export const initFormatters: FormattersInitializer = (locale: Locales) => { 5 | const formatters: Formatters = { 6 | // add your formatter functions here 7 | }; 8 | 9 | return formatters; 10 | }; 11 | -------------------------------------------------------------------------------- /src/lib/i18n/i18n-node.ts: -------------------------------------------------------------------------------- 1 | // This is only used by playwright tests. Do not import anywhere else 2 | 3 | import type { LocaleTranslationFunctions } from 'typesafe-i18n' 4 | import { i18n, loadedFormatters, loadedLocales, locales } from './i18n-util' 5 | import type { Locales, Translations, TranslationFunctions } from './i18n-types' 6 | 7 | import { initFormatters } from './formatters'; 8 | import en from './en/index'; 9 | 10 | loadedLocales['en'] = en as unknown as Translations 11 | loadedFormatters['en'] = initFormatters('en') 12 | 13 | export const L: LocaleTranslationFunctions = i18n() 14 | 15 | export default L -------------------------------------------------------------------------------- /src/lib/i18n/i18n-svelte.ts: -------------------------------------------------------------------------------- 1 | // This file was auto-generated by 'typesafe-i18n'. Any manual changes will be overwritten. 2 | /* eslint-disable */ 3 | 4 | import { initI18nSvelte } from 'typesafe-i18n/svelte' 5 | import type { Formatters, Locales, TranslationFunctions, Translations } from './i18n-types' 6 | import { loadedFormatters, loadedLocales } from './i18n-util' 7 | 8 | const { locale, LL, setLocale } = initI18nSvelte(loadedLocales, loadedFormatters) 9 | 10 | export { locale, LL, setLocale } 11 | 12 | export default LL 13 | -------------------------------------------------------------------------------- /src/lib/i18n/i18n-types.ts: -------------------------------------------------------------------------------- 1 | // This file was auto-generated by 'typesafe-i18n'. Any manual changes will be overwritten. 2 | /* eslint-disable */ 3 | import type { BaseTranslation as BaseTranslationType, LocalizedString, RequiredParams } from 'typesafe-i18n' 4 | 5 | export type BaseTranslation = BaseTranslationType 6 | export type BaseLocale = 'en' 7 | 8 | export type Locales = 9 | | 'ar' 10 | | 'de' 11 | | 'en' 12 | | 'es' 13 | | 'it' 14 | | 'ru' 15 | | 'uk' 16 | 17 | export type Translation = RootTranslation 18 | 19 | export type Translations = RootTranslation 20 | 21 | type RootTranslation = { 22 | /** 23 | * H​e​l​l​o​ ​W​o​r​l​d 24 | */ 25 | message: string 26 | buttons: { 27 | /** 28 | * C​r​e​a​t​e 29 | */ 30 | create: string 31 | /** 32 | * V​i​e​w 33 | */ 34 | view: string 35 | /** 36 | * E​d​i​t 37 | */ 38 | edit: string 39 | /** 40 | * U​p​d​a​t​e 41 | */ 42 | update: string 43 | /** 44 | * L​o​g​o​u​t 45 | */ 46 | logOut: string 47 | /** 48 | * L​o​g​i​n​ ​w​i​t​h​ ​Y​o​u​T​u​b​e 49 | */ 50 | loginYouTube: string 51 | /** 52 | * R​e​m​o​v​e 53 | */ 54 | remove: string 55 | /** 56 | * A​d​d 57 | */ 58 | add: string 59 | } 60 | labels: { 61 | /** 62 | * T​i​t​l​e 63 | */ 64 | title: string 65 | /** 66 | * S​l​u​g 67 | */ 68 | slug: string 69 | /** 70 | * D​e​s​c​r​i​p​t​i​o​n 71 | */ 72 | description: string 73 | /** 74 | * V​i​s​i​b​i​l​i​t​y 75 | */ 76 | visibility: string 77 | /** 78 | * {​0​}​ ​v​i​e​w​s 79 | * @param {unknown} 0 80 | */ 81 | views: RequiredParams<'0'> 82 | /** 83 | * F​i​l​t​e​r 84 | */ 85 | filter: string 86 | } 87 | enums: { 88 | visibility: { 89 | /** 90 | * P​u​b​l​i​c 91 | */ 92 | Public: string 93 | /** 94 | * U​n​l​i​s​t​e​d 95 | */ 96 | Unlisted: string 97 | /** 98 | * P​r​i​v​a​t​e 99 | */ 100 | Private: string 101 | } 102 | } 103 | errors: { 104 | /** 105 | * T​i​t​l​e​ ​c​a​n​n​o​t​ ​b​e​ ​e​m​p​t​y​. 106 | */ 107 | titleRequired: string 108 | /** 109 | * S​l​u​g​ ​c​a​n​n​o​t​ ​b​e​ ​e​m​p​t​y​. 110 | */ 111 | slugRequired: string 112 | /** 113 | * S​l​u​g​ ​c​a​n​n​o​t​ ​c​o​n​t​a​i​n​ ​s​p​e​c​i​a​l​ ​c​h​a​r​a​c​t​e​r​s 114 | */ 115 | slugSpecialCharacters: string 116 | /** 117 | * D​e​s​c​r​i​p​t​i​o​n​ ​c​a​n​n​o​t​ ​b​e​ ​e​m​p​t​y​. 118 | */ 119 | descriptionRequired: string 120 | /** 121 | * N​o​t​ ​f​o​u​n​d​. 122 | */ 123 | notFound: string 124 | /** 125 | * A​ ​l​i​s​t​ ​m​u​s​t​ ​h​a​v​e​ ​a​t​ ​l​e​a​s​t​ ​1​ ​c​h​a​n​n​e​l​. 126 | */ 127 | listMinLength: string 128 | } 129 | messages: { 130 | /** 131 | * P​l​e​a​s​e​ ​w​a​i​t​.​.​. 132 | */ 133 | pleaseWait: string 134 | } 135 | pages: { 136 | root: { 137 | loggedIn: { 138 | messages: { 139 | /** 140 | * C​l​i​c​k​ ​C​r​e​a​t​e​ ​t​o​ ​g​e​t​ ​s​t​a​r​t​e​d​. 141 | */ 142 | createList: string 143 | } 144 | } 145 | messages: { 146 | /** 147 | * P​r​e​s​e​n​t​i​n​g​ ​t​h​e​ ​u​l​t​i​m​a​t​e​ ​Y​o​u​T​u​b​e​ ​e​x​p​e​r​i​e​n​c​e​.​ ​W​h​e​t​h​e​r​ ​y​o​u​'​r​e​ ​l​o​o​k​i​n​g​ ​f​o​r​ ​n​e​w​ ​c​o​n​t​e​n​t​ ​t​o​ ​w​a​t​c​h​ ​o​r​ ​w​a​n​t​ ​t​o​ ​s​h​a​r​e​ ​y​o​u​r​ ​o​w​n​ ​c​u​r​a​t​e​d​ ​l​i​s​t​ ​w​i​t​h​ ​f​r​i​e​n​d​s​,​ ​o​u​r​ ​a​p​p​ ​h​a​s​ ​g​o​t​ ​y​o​u​ ​c​o​v​e​r​e​d​. 148 | */ 149 | tagline: string 150 | } 151 | } 152 | onboarding: { 153 | buttons: { 154 | /** 155 | * L​e​t​s​ ​G​o​! 156 | */ 157 | letsGo: string 158 | } 159 | labels: { 160 | /** 161 | * U​s​e​r​n​a​m​e 162 | */ 163 | username: string 164 | /** 165 | * U​p​l​o​a​d​ ​F​i​l​e 166 | */ 167 | uploadFile: string 168 | } 169 | messages: { 170 | /** 171 | * W​e​l​c​o​m​e​ ​t​o​ ​l​i​s​t​d​!​ ​L​e​t​'​s​ ​s​e​t​u​p​ ​y​o​u​r​ ​p​r​o​f​i​l​e​. 172 | */ 173 | main: string 174 | /** 175 | * U​p​l​o​a​d​ ​y​o​u​r​ ​a​v​a​t​a​r​. 176 | */ 177 | avatar: string 178 | /** 179 | * T​h​a​t​'​s​ ​a​l​l​!​ ​L​e​t​'​s​ ​g​e​t​ ​s​t​a​r​t​e​d​! 180 | */ 181 | final: string 182 | } 183 | } 184 | create: { 185 | labels: { 186 | /** 187 | * C​h​a​n​n​e​l​ ​S​e​a​r​c​h 188 | */ 189 | channelSearch: string 190 | } 191 | messages: { 192 | /** 193 | * S​e​a​r​c​h​ ​f​o​r​ ​a​ ​c​h​a​n​n​e​l​.​.​. 194 | */ 195 | channelSearch: string 196 | } 197 | } 198 | } 199 | } 200 | 201 | export type TranslationFunctions = { 202 | /** 203 | * Hello World 204 | */ 205 | message: () => LocalizedString 206 | buttons: { 207 | /** 208 | * Create 209 | */ 210 | create: () => LocalizedString 211 | /** 212 | * View 213 | */ 214 | view: () => LocalizedString 215 | /** 216 | * Edit 217 | */ 218 | edit: () => LocalizedString 219 | /** 220 | * Update 221 | */ 222 | update: () => LocalizedString 223 | /** 224 | * Logout 225 | */ 226 | logOut: () => LocalizedString 227 | /** 228 | * Login with YouTube 229 | */ 230 | loginYouTube: () => LocalizedString 231 | /** 232 | * Remove 233 | */ 234 | remove: () => LocalizedString 235 | /** 236 | * Add 237 | */ 238 | add: () => LocalizedString 239 | } 240 | labels: { 241 | /** 242 | * Title 243 | */ 244 | title: () => LocalizedString 245 | /** 246 | * Slug 247 | */ 248 | slug: () => LocalizedString 249 | /** 250 | * Description 251 | */ 252 | description: () => LocalizedString 253 | /** 254 | * Visibility 255 | */ 256 | visibility: () => LocalizedString 257 | /** 258 | * {0} views 259 | */ 260 | views: (arg0: unknown) => LocalizedString 261 | /** 262 | * Filter 263 | */ 264 | filter: () => LocalizedString 265 | } 266 | enums: { 267 | visibility: { 268 | /** 269 | * Public 270 | */ 271 | Public: () => LocalizedString 272 | /** 273 | * Unlisted 274 | */ 275 | Unlisted: () => LocalizedString 276 | /** 277 | * Private 278 | */ 279 | Private: () => LocalizedString 280 | } 281 | } 282 | errors: { 283 | /** 284 | * Title cannot be empty. 285 | */ 286 | titleRequired: () => LocalizedString 287 | /** 288 | * Slug cannot be empty. 289 | */ 290 | slugRequired: () => LocalizedString 291 | /** 292 | * Slug cannot contain special characters 293 | */ 294 | slugSpecialCharacters: () => LocalizedString 295 | /** 296 | * Description cannot be empty. 297 | */ 298 | descriptionRequired: () => LocalizedString 299 | /** 300 | * Not found. 301 | */ 302 | notFound: () => LocalizedString 303 | /** 304 | * A list must have at least 1 channel. 305 | */ 306 | listMinLength: () => LocalizedString 307 | } 308 | messages: { 309 | /** 310 | * Please wait... 311 | */ 312 | pleaseWait: () => LocalizedString 313 | } 314 | pages: { 315 | root: { 316 | loggedIn: { 317 | messages: { 318 | /** 319 | * Click Create to get started. 320 | */ 321 | createList: () => LocalizedString 322 | } 323 | } 324 | messages: { 325 | /** 326 | * Presenting the ultimate YouTube experience. Whether you're looking for new content to watch or want to share your own curated list with friends, our app has got you covered. 327 | */ 328 | tagline: () => LocalizedString 329 | } 330 | } 331 | onboarding: { 332 | buttons: { 333 | /** 334 | * Lets Go! 335 | */ 336 | letsGo: () => LocalizedString 337 | } 338 | labels: { 339 | /** 340 | * Username 341 | */ 342 | username: () => LocalizedString 343 | /** 344 | * Upload File 345 | */ 346 | uploadFile: () => LocalizedString 347 | } 348 | messages: { 349 | /** 350 | * Welcome to listd! Let's setup your profile. 351 | */ 352 | main: () => LocalizedString 353 | /** 354 | * Upload your avatar. 355 | */ 356 | avatar: () => LocalizedString 357 | /** 358 | * That's all! Let's get started! 359 | */ 360 | final: () => LocalizedString 361 | } 362 | } 363 | create: { 364 | labels: { 365 | /** 366 | * Channel Search 367 | */ 368 | channelSearch: () => LocalizedString 369 | } 370 | messages: { 371 | /** 372 | * Search for a channel... 373 | */ 374 | channelSearch: () => LocalizedString 375 | } 376 | } 377 | } 378 | } 379 | 380 | export type Formatters = {} 381 | -------------------------------------------------------------------------------- /src/lib/i18n/i18n-util.async.ts: -------------------------------------------------------------------------------- 1 | // This file was auto-generated by 'typesafe-i18n'. Any manual changes will be overwritten. 2 | /* eslint-disable */ 3 | 4 | import { initFormatters } from './formatters' 5 | import type { Locales, Translations } from './i18n-types' 6 | import { loadedFormatters, loadedLocales, locales } from './i18n-util' 7 | 8 | const localeTranslationLoaders = { 9 | ar: () => import('./ar'), 10 | de: () => import('./de'), 11 | en: () => import('./en'), 12 | es: () => import('./es'), 13 | it: () => import('./it'), 14 | ru: () => import('./ru'), 15 | uk: () => import('./uk'), 16 | } 17 | 18 | const updateDictionary = (locale: Locales, dictionary: Partial): Translations => 19 | loadedLocales[locale] = { ...loadedLocales[locale], ...dictionary } 20 | 21 | export const importLocaleAsync = async (locale: Locales): Promise => 22 | (await localeTranslationLoaders[locale]()).default as unknown as Translations 23 | 24 | export const loadLocaleAsync = async (locale: Locales): Promise => { 25 | updateDictionary(locale, await importLocaleAsync(locale)) 26 | loadFormatters(locale) 27 | } 28 | 29 | export const loadAllLocalesAsync = (): Promise => Promise.all(locales.map(loadLocaleAsync)) 30 | 31 | export const loadFormatters = (locale: Locales): void => 32 | void (loadedFormatters[locale] = initFormatters(locale)) 33 | -------------------------------------------------------------------------------- /src/lib/i18n/i18n-util.sync.ts: -------------------------------------------------------------------------------- 1 | // This file was auto-generated by 'typesafe-i18n'. Any manual changes will be overwritten. 2 | /* eslint-disable */ 3 | 4 | import { initFormatters } from './formatters' 5 | import type { Locales, Translations } from './i18n-types' 6 | import { loadedFormatters, loadedLocales, locales } from './i18n-util' 7 | 8 | import ar from './ar' 9 | import de from './de' 10 | import en from './en' 11 | import es from './es' 12 | import it from './it' 13 | import ru from './ru' 14 | import uk from './uk' 15 | 16 | const localeTranslations = { 17 | ar, 18 | de, 19 | en, 20 | es, 21 | it, 22 | ru, 23 | uk, 24 | } 25 | 26 | export const loadLocale = (locale: Locales): void => { 27 | if (loadedLocales[locale]) return 28 | 29 | loadedLocales[locale] = localeTranslations[locale] as unknown as Translations 30 | loadFormatters(locale) 31 | } 32 | 33 | export const loadAllLocales = (): void => locales.forEach(loadLocale) 34 | 35 | export const loadFormatters = (locale: Locales): void => 36 | void (loadedFormatters[locale] = initFormatters(locale)) 37 | -------------------------------------------------------------------------------- /src/lib/i18n/i18n-util.ts: -------------------------------------------------------------------------------- 1 | // This file was auto-generated by 'typesafe-i18n'. Any manual changes will be overwritten. 2 | /* eslint-disable */ 3 | 4 | import { i18n as initI18n, i18nObject as initI18nObject, i18nString as initI18nString } from 'typesafe-i18n' 5 | import type { LocaleDetector } from 'typesafe-i18n/detectors' 6 | import type { LocaleTranslationFunctions, TranslateByString } from 'typesafe-i18n' 7 | import { detectLocale as detectLocaleFn } from 'typesafe-i18n/detectors' 8 | import { initExtendDictionary } from 'typesafe-i18n/utils' 9 | import type { Formatters, Locales, Translations, TranslationFunctions } from './i18n-types' 10 | 11 | export const baseLocale: Locales = 'en' 12 | 13 | export const locales: Locales[] = [ 14 | 'ar', 15 | 'de', 16 | 'en', 17 | 'es', 18 | 'it', 19 | 'ru', 20 | 'uk' 21 | ] 22 | 23 | export const isLocale = (locale: string): locale is Locales => locales.includes(locale as Locales) 24 | 25 | export const loadedLocales: Record = {} as Record 26 | 27 | export const loadedFormatters: Record = {} as Record 28 | 29 | export const extendDictionary = initExtendDictionary() 30 | 31 | export const i18nString = (locale: Locales): TranslateByString => initI18nString(locale, loadedFormatters[locale]) 32 | 33 | export const i18nObject = (locale: Locales): TranslationFunctions => 34 | initI18nObject( 35 | locale, 36 | loadedLocales[locale], 37 | loadedFormatters[locale] 38 | ) 39 | 40 | export const i18n = (): LocaleTranslationFunctions => 41 | initI18n(loadedLocales, loadedFormatters) 42 | 43 | export const detectLocale = (...detectors: LocaleDetector[]): Locales => detectLocaleFn(baseLocale, locales, ...detectors) 44 | -------------------------------------------------------------------------------- /src/lib/i18n/it/index.ts: -------------------------------------------------------------------------------- 1 | import type { Translation } from '../i18n-types'; 2 | import en from '../en'; 3 | 4 | const it: Translation = { 5 | ...(en as Translation), 6 | message: 'Ciao Mondo', 7 | }; 8 | 9 | export default it; 10 | -------------------------------------------------------------------------------- /src/lib/i18n/ru/index.ts: -------------------------------------------------------------------------------- 1 | import type { Translation } from '../i18n-types'; 2 | import en from '../en'; 3 | 4 | const ru: Translation = { 5 | ...(en as Translation), 6 | message: 'Привет Мир', 7 | }; 8 | 9 | export default ru; 10 | -------------------------------------------------------------------------------- /src/lib/i18n/uk/index.ts: -------------------------------------------------------------------------------- 1 | import type { Translation } from '../i18n-types'; 2 | import en from '../en'; 3 | 4 | const uk: Translation = { 5 | ...(en as Translation), 6 | message: 'Привіт світ', 7 | }; 8 | 9 | export default uk; 10 | -------------------------------------------------------------------------------- /src/lib/parseDescription.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import parseDescription from '$/lib/parseDescription'; 3 | 4 | describe('parse description test', () => { 5 | it('parses time stamps into anchor tags', () => { 6 | expect( 7 | parseDescription( 8 | ` 9 | 1:23 10 | 2:23:46 11 | 12:34 12 | 1:23:45 13 | 00:00:00`, 14 | '' 15 | ) 16 | ).toBe(` 17 | 1:23 18 | 2:23:46 19 | 12:34 20 | 1:23:45 21 | 00:00:00`); 22 | }); 23 | 24 | it('parses time stamps with hours greater than 59', () => { 25 | expect(parseDescription(`100:48:48`, '')).toBe( 26 | `100:48:48` 27 | ); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /src/lib/parseDescription.ts: -------------------------------------------------------------------------------- 1 | export default function parseDescription(description: string, classNames: string): string { 2 | // regexp from: https://stackoverflow.com/questions/11067572/fetching-timestamps-hhmmss-from-youtube-comments-with-youtube-api-using-rege 3 | return description.replace( 4 | /(?:(\d*):)?([0-5]?[0-9]):([0-5][0-9])/gi, 5 | (match, hours, minutes, seconds) => { 6 | const totalSeconds = 7 | Number(hours || 0) * 60 * 60 + Number(minutes || 0) * 60 + Number(seconds || 0); 8 | return `${match}`; 9 | } 10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /src/lib/prisma/client.ts: -------------------------------------------------------------------------------- 1 | import { PrismaAdapter } from '@next-auth/prisma-adapter'; 2 | import type { PrismaClient, User, UserSettings } from '@prisma/client'; 3 | import type { Adapter, AdapterAccount, AdapterSession } from 'next-auth/adapters'; 4 | 5 | type OGAdapter = Omit< 6 | Adapter, 7 | 'getUser' | 'getUserByEmail' | 'getUserByAccount' | 'getSessionAndUser' 8 | >; 9 | 10 | type AnnotatedUser = User & { settings: UserSettings | null; username: string | null }; 11 | 12 | async function getAnnotatedUser( 13 | client: PrismaClient, 14 | user: User | null 15 | ): Promise { 16 | if (!user) return null; 17 | const account = await client.account.findFirst({ 18 | where: { 19 | userId: user.id, 20 | provider: 'google', 21 | }, 22 | }); 23 | 24 | const userWithUsername = user as AnnotatedUser; 25 | if (account) { 26 | userWithUsername.username = account.username; 27 | } 28 | 29 | return userWithUsername; 30 | } 31 | 32 | export interface CustomAdapter extends OGAdapter { 33 | getUser(id: string): Promise; 34 | getUserByEmail(email: string): Promise; 35 | getUserByAccount( 36 | providerAccountId: Pick 37 | ): Promise; 38 | getSessionAndUser(sessionToken: string): Promise<{ 39 | session: AdapterSession; 40 | user: AnnotatedUser; 41 | } | null>; 42 | } 43 | 44 | export default function CustomPrismaAdapter(client: PrismaClient): CustomAdapter { 45 | return { 46 | ...PrismaAdapter(client), 47 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 48 | // @ts-ignore 49 | createUser({ id, ...data }) { 50 | return client.user.create({ data }); 51 | }, 52 | async getUser(id: string) { 53 | const user = await client.user.findUnique({ 54 | where: { id }, 55 | include: { settings: true }, 56 | }); 57 | 58 | return getAnnotatedUser(client, user); 59 | }, 60 | async getUserByEmail(email: string) { 61 | const user = await client.user.findUnique({ 62 | where: { email }, 63 | include: { settings: true }, 64 | }); 65 | return getAnnotatedUser(client, user); 66 | }, 67 | async getUserByAccount( 68 | providerAccountId: Pick 69 | ) { 70 | const account = await client.account.findUnique({ 71 | where: { provider_providerAccountId: providerAccountId }, 72 | include: { 73 | user: { 74 | include: { 75 | settings: true, 76 | }, 77 | }, 78 | }, 79 | }); 80 | const userWithUsername = account?.user as AnnotatedUser; 81 | if (account && userWithUsername) { 82 | userWithUsername.username = account.username; 83 | } 84 | return userWithUsername ?? null; 85 | }, 86 | async getSessionAndUser(sessionToken: string) { 87 | const userAndSession = await client.session.findUnique({ 88 | where: { sessionToken }, 89 | include: { 90 | user: { 91 | include: { 92 | settings: true, 93 | }, 94 | }, 95 | }, 96 | }); 97 | 98 | if (!userAndSession) return null; 99 | 100 | const { user, ...session } = userAndSession; 101 | const annotatedUser = await getAnnotatedUser(client, user); 102 | if (!annotatedUser) return null; 103 | return { user: annotatedUser, session }; 104 | }, 105 | }; 106 | } 107 | -------------------------------------------------------------------------------- /src/lib/schemas.ts: -------------------------------------------------------------------------------- 1 | import { Visibility } from '@prisma/client'; 2 | import { z } from 'zod'; 3 | import type { TranslationFunctions } from '$/lib/i18n/i18n-types.js'; 4 | 5 | export const createListSchema = ($LL: TranslationFunctions) => 6 | z.object({ 7 | id: z.string().uuid().optional(), 8 | title: z.string().trim().min(1, $LL.errors.titleRequired()), 9 | slug: z 10 | .string() 11 | .trim() 12 | .min(1, $LL.errors.slugRequired()) 13 | .max(2048) 14 | .regex(/([0-9a-z]|-)+/g, $LL.errors.slugSpecialCharacters()) 15 | .refine((slug) => slug.toLowerCase()), 16 | description: z.string().trim().min(1, $LL.errors.descriptionRequired()).optional(), 17 | visibility: z.nativeEnum(Visibility).default(Visibility.Unlisted), 18 | channelIds: z.array(z.string().trim().min(1)).min(1, $LL.errors.listMinLength()), 19 | }); 20 | 21 | export type ListSchema = ReturnType; 22 | -------------------------------------------------------------------------------- /src/lib/server/YouTubeAPI.ts: -------------------------------------------------------------------------------- 1 | import { youtube, type youtube_v3 } from '@googleapis/youtube'; 2 | import { config } from '$/lib/config.server'; 3 | import type { YouTubeMeta } from '@prisma/client'; 4 | import redisClient from './redis'; 5 | 6 | const ytClient = youtube({ 7 | version: 'v3', 8 | auth: config.YOUTUBE_API_KEY, 9 | }); 10 | 11 | const channelParts = ['id', 'snippet', 'statistics', 'brandingSettings']; 12 | export type YouTubeChannelMetaAPIResponse = Omit; 13 | 14 | export type YouTubeVideoAPIResponse = { 15 | thumbnails: { 16 | high: string | null; 17 | low: string | null; 18 | }; 19 | title: string; 20 | description: string; 21 | videoId: string; 22 | channelTitle: string; 23 | channelId: string; 24 | publishedAt: number; 25 | viewCount: number; 26 | likes: number; 27 | duration: string; 28 | upcoming: boolean; 29 | livestream: { 30 | viewers: number; 31 | liveChatId: string; 32 | actualStartAt: number; 33 | scheduledStartAt: number; 34 | } | null; 35 | }; 36 | 37 | export function parseYTDate(date: string | null | undefined) { 38 | return date ? new Date(date).getTime() : Date.now(); 39 | } 40 | 41 | export function parseYTNumber(number: string | null | undefined) { 42 | return Number(number || 0); 43 | } 44 | 45 | export function createYouTubeMetaAPIResponse(originId: string, channel: youtube_v3.Schema$Channel) { 46 | const subscriberCountNumber = Number(channel.statistics?.subscriberCount); 47 | const subscriberCount = Number.isNaN(subscriberCountNumber) ? 0 : subscriberCountNumber; 48 | // TODO: i18n 49 | const name = channel.snippet?.title || 'No Title'; 50 | // TODO: use our own avatar service 51 | const avatarUrl = 52 | channel.snippet?.thumbnails?.default?.url || `https://ui-avatars.com/api/?name=${name}`; 53 | return { 54 | name, 55 | originId, 56 | description: channel.snippet?.description || 'No description set.', 57 | subscriberCount, 58 | avatarUrl, 59 | bannerUrl: channel.brandingSettings?.image?.bannerImageUrl || null, 60 | customUrl: channel.snippet?.customUrl || '@notfound', 61 | isVerified: false, 62 | }; 63 | } 64 | 65 | export async function getUserChannel(access_token: string) { 66 | const { data } = await ytClient.channels.list({ 67 | access_token, 68 | part: channelParts, 69 | mine: true, 70 | maxResults: 1, 71 | }); 72 | const ytChannel = data.items?.pop(); 73 | if (ytChannel) { 74 | return ytChannel; 75 | } 76 | return null; 77 | } 78 | 79 | export async function getChannel(id: string) { 80 | const { data } = await ytClient.channels.list({ 81 | part: channelParts, 82 | id: [id], 83 | maxResults: 1, 84 | }); 85 | const ytChannel = data.items?.pop(); 86 | if (ytChannel) { 87 | return createYouTubeMetaAPIResponse(id, ytChannel); 88 | } 89 | return null; 90 | } 91 | 92 | async function getAllVideos( 93 | channelId: string, 94 | videos: YouTubeVideoAPIResponse[] = [], 95 | pageToken?: string 96 | ): Promise { 97 | const { data } = await ytClient.search.list({ 98 | part: ['id', 'snippet'], 99 | channelId, 100 | type: ['video'], 101 | order: 'date', 102 | maxResults: 50, 103 | pageToken, 104 | }); 105 | const ids = (data.items || []).reduce((all, item) => { 106 | if (item.id?.videoId) { 107 | all.push(item.id?.videoId); 108 | } 109 | return all; 110 | }, [] as string[]); 111 | 112 | if (ids.length) { 113 | const { data: videoData } = await ytClient.videos.list({ 114 | part: [ 115 | 'id', 116 | 'contentDetails', 117 | 'liveStreamingDetails', 118 | 'localizations', 119 | 'snippet', 120 | 'statistics', 121 | ], 122 | id: ids, 123 | maxResults: 50, 124 | }); 125 | videoData.items?.forEach((video) => { 126 | if (video && video.id) { 127 | const videoResponse = { 128 | thumbnails: { 129 | high: 130 | video.snippet?.thumbnails?.maxres?.url || 131 | video.snippet?.thumbnails?.standard?.url || 132 | video.snippet?.thumbnails?.high?.url || 133 | null, 134 | low: 135 | video.snippet?.thumbnails?.medium?.url || 136 | video.snippet?.thumbnails?.default?.url || 137 | null, 138 | }, 139 | // TODO: i18n 140 | title: video.snippet?.title || 'No Video Title', 141 | description: video.snippet?.description || '', 142 | videoId: video.id, 143 | channelTitle: video.snippet?.channelTitle || 'No Channel Title', 144 | channelId, 145 | publishedAt: video.snippet?.publishedAt 146 | ? new Date(video.snippet?.publishedAt).getTime() 147 | : Date.now(), 148 | viewCount: parseYTNumber(video.statistics?.viewCount), 149 | likes: parseYTNumber(video.statistics?.likeCount), 150 | duration: video.contentDetails?.duration || 'PT0S', 151 | upcoming: video.snippet?.liveBroadcastContent === 'upcoming', 152 | livestream: video.liveStreamingDetails 153 | ? { 154 | live: video.snippet?.liveBroadcastContent === 'live', 155 | viewers: parseYTNumber(video.liveStreamingDetails.concurrentViewers), 156 | liveChatId: video.liveStreamingDetails.activeLiveChatId || '', 157 | actualStartAt: parseYTDate(video.liveStreamingDetails.actualStartTime), 158 | scheduledStartAt: parseYTDate(video.liveStreamingDetails.scheduledStartTime), 159 | } 160 | : null, 161 | }; 162 | videoResponse.thumbnails.high = videoResponse.thumbnails.high?.replace('_live', '') || null; 163 | videoResponse.thumbnails.low = videoResponse.thumbnails.low?.replace('_live', '') || null; 164 | videos.push(videoResponse); 165 | } 166 | }); 167 | } 168 | if (data.nextPageToken) { 169 | return getAllVideos(channelId, videos, data.nextPageToken); 170 | } 171 | return videos; 172 | } 173 | 174 | async function getChannelVideos(channelId: string) { 175 | const cacheKey = `yt:videos:(channelId:${channelId})`; 176 | const cachedVideos = await redisClient.get(cacheKey); 177 | if (cachedVideos) { 178 | return JSON.parse(cachedVideos) as YouTubeVideoAPIResponse[]; 179 | } 180 | const videos = await getAllVideos(channelId); 181 | await redisClient.set(cacheKey, JSON.stringify(videos), { 182 | EX: 60 * 60 * 24, 183 | }); 184 | return videos; 185 | } 186 | 187 | export async function getVideos(channelIds: string[]) { 188 | let videos: YouTubeVideoAPIResponse[] = []; 189 | 190 | await channelIds.reduce(async (promise, channelId) => { 191 | await promise; 192 | const channelVideos = await getChannelVideos(channelId); 193 | videos = videos.concat(channelVideos); 194 | }, Promise.resolve()); 195 | videos.sort((a, b) => b.publishedAt - a.publishedAt); 196 | 197 | return videos; 198 | } 199 | 200 | export async function searchChannels(q: string) { 201 | // TODO: proxy, cache and use an API Key pool... 202 | const { data: searchResults } = await ytClient.search.list({ 203 | part: ['id', 'snippet'], 204 | q, 205 | type: ['channel'], 206 | maxResults: 50, 207 | }); 208 | const ids = (searchResults.items || []).reduce((all, item) => { 209 | if (item.id?.channelId) { 210 | all.push(item.id?.channelId); 211 | } 212 | return all; 213 | }, [] as string[]); 214 | const { data } = await ytClient.channels.list({ 215 | part: channelParts, 216 | id: ids, 217 | maxResults: 50, 218 | }); 219 | const byId = (data.items || []).reduce((all, item) => { 220 | if (item.id) { 221 | all.set(item.id, item); 222 | } 223 | return all; 224 | }, new Map()); 225 | const results = (searchResults.items || []).reduce((all, item) => { 226 | if (item.id?.channelId) { 227 | const channel = byId.get(item.id.channelId); 228 | if (channel) { 229 | const metaResponse = createYouTubeMetaAPIResponse(item.id.channelId, channel); 230 | all.push(metaResponse); 231 | } 232 | } 233 | return all; 234 | }, [] as YouTubeChannelMetaAPIResponse[]); 235 | return results; 236 | } 237 | -------------------------------------------------------------------------------- /src/lib/server/queries.ts: -------------------------------------------------------------------------------- 1 | import prismaClient from '$lib/db.server'; 2 | import { Visibility, type Account } from '@prisma/client'; 3 | 4 | type GetListParams = { 5 | id?: string; 6 | username?: string; 7 | slug?: string; 8 | userId?: string; 9 | }; 10 | 11 | async function findList({ id, slug, userId, account }: GetListParams & { account?: Account }) { 12 | return prismaClient.list.findFirst({ 13 | where: { 14 | ...(account 15 | ? { 16 | userId: account.userId, 17 | slug, 18 | } 19 | : { 20 | id, 21 | }), 22 | OR: [ 23 | { visibility: Visibility.Public }, 24 | { visibility: Visibility.Unlisted }, 25 | userId 26 | ? { 27 | AND: [{ userId }, { visibility: Visibility.Private }], 28 | } 29 | : {}, 30 | ], 31 | }, 32 | include: { 33 | items: { 34 | include: { 35 | meta: { 36 | include: { 37 | youtubeMeta: true, 38 | }, 39 | }, 40 | }, 41 | }, 42 | }, 43 | }); 44 | } 45 | 46 | type ListWithItems = Awaited>>>; 47 | 48 | export async function getList({ id, username, slug, userId }: GetListParams) { 49 | let list: ListWithItems | null = null; 50 | if (username) { 51 | const account = await prismaClient.account.findFirst({ 52 | where: { 53 | provider: 'google', 54 | username, 55 | }, 56 | }); 57 | if (!account) { 58 | return { 59 | list, 60 | channelIds: [], 61 | }; 62 | } 63 | list = await findList({ slug, userId, account }); 64 | } 65 | 66 | if (!list && id) { 67 | list = await findList({ id, userId }); 68 | } 69 | 70 | const channelIds = list?.items.map((item) => item.meta.originId) || []; 71 | return { list, channelIds }; 72 | } 73 | -------------------------------------------------------------------------------- /src/lib/server/redis.ts: -------------------------------------------------------------------------------- 1 | import { createClient } from 'redis'; 2 | import { building } from '$app/environment'; 3 | import { config } from '../config.server'; 4 | 5 | const client = createClient({ 6 | url: config.REDIS_URL, 7 | }); 8 | 9 | // TODO: use logging library... 10 | // eslint-disable-next-line no-console 11 | client.on('error', (err) => console.log('Redis Client Error', err)); 12 | 13 | if (!building) { 14 | await client.connect(); 15 | } 16 | 17 | export default client; 18 | -------------------------------------------------------------------------------- /src/lib/server/youtube.ts: -------------------------------------------------------------------------------- 1 | import { getUserChannel } from '$/lib/server/YouTubeAPI'; 2 | import type { Account } from '@auth/core/types'; 3 | import prismaClient from '$lib/db.server'; 4 | 5 | export async function updateAccountUsername(account: Account) { 6 | if (account.access_token) { 7 | try { 8 | const channel = await getUserChannel(account.access_token); 9 | const username = channel?.snippet?.customUrl; 10 | if (username) { 11 | await prismaClient.account.updateMany({ 12 | where: { 13 | userId: account.userId, 14 | providerAccountId: account.providerAccountId, 15 | }, 16 | data: { 17 | username, 18 | }, 19 | }); 20 | } 21 | return username; 22 | } catch (error) { 23 | // eslint-disable-next-line no-console 24 | console.error(error); 25 | } 26 | } 27 | return '@Unknown'; 28 | } 29 | -------------------------------------------------------------------------------- /src/lib/stores/SeoStore.ts: -------------------------------------------------------------------------------- 1 | import { writable } from 'svelte/store'; 2 | 3 | export const seo = writable({ 4 | title: 'listd', 5 | description: 'listd', 6 | }); 7 | -------------------------------------------------------------------------------- /src/lib/stores/UserStore.ts: -------------------------------------------------------------------------------- 1 | import { writable } from 'svelte/store'; 2 | import type { User } from '@auth/core/types'; 3 | 4 | export const userStore = writable<{ 5 | user: User | undefined; 6 | }>({ 7 | user: undefined, 8 | }); 9 | -------------------------------------------------------------------------------- /src/lib/stores/VideoPlayerStore.ts: -------------------------------------------------------------------------------- 1 | import { writable } from 'svelte/store'; 2 | 3 | interface PlayerInitEvent { 4 | type: 'init'; 5 | value: null; 6 | } 7 | 8 | interface PlayerSeekToEvent { 9 | type: 'seekTo'; 10 | value: number; 11 | } 12 | 13 | interface PlayerSetVideoIdEvent { 14 | type: 'setVideoId'; 15 | value: string; 16 | } 17 | 18 | export default writable({ 19 | type: 'init', 20 | value: null, 21 | }); 22 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { DateTime, Duration } from 'luxon'; 2 | 3 | const formatters = new Map(); 4 | 5 | export const getCompactNumberFormatter = (locale: string): Intl.NumberFormat => { 6 | let formatter = formatters.get(locale); 7 | if (formatter) return formatter; 8 | formatter = new Intl.NumberFormat(locale, { 9 | notation: 'compact', 10 | compactDisplay: 'short', 11 | }); 12 | formatters.set(locale, formatter); 13 | return formatter; 14 | }; 15 | 16 | export const formatNumberCompact = (value: number, locale: string) => { 17 | const formatter = getCompactNumberFormatter(locale); 18 | return formatter.format(value); 19 | }; 20 | 21 | export const formatRelativeDate = (timeStamp: number, locale: string) => 22 | DateTime.fromMillis(timeStamp).toRelative({ locale }); 23 | 24 | export const formatDuration = (duration: string) => { 25 | const interval = Duration.fromISO(duration); 26 | let format = 'm:ss'; 27 | if (interval.days && interval.days > 0) { 28 | format = 'd:hh:mm:ss'; 29 | } else if (interval.hours && interval.hours > 0) { 30 | format = 'h:mm:ss'; 31 | } 32 | return interval.toFormat(format); 33 | }; 34 | -------------------------------------------------------------------------------- /src/routes/(protected)/components/ChannelCardActions.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 | {#if channelIds.has(channel.originId)} 12 | 22 | {:else} 23 | 33 | {/if} 34 | -------------------------------------------------------------------------------- /src/routes/(protected)/components/ChannelSearch.svelte: -------------------------------------------------------------------------------- 1 | 17 | 18 |
{ 22 | loading = true; 23 | hasSearched = true; 24 | return ({ result }) => { 25 | loading = false; 26 | return applyAction(result); 27 | }; 28 | }} 29 | method="post"> 30 | 38 |
39 |
40 |
41 | {#if loading} 42 |
43 | 44 |
45 | {/if} 46 | {#if results} 47 |
48 | {#each results as result} 49 | 50 | 51 | 52 | {/each} 53 |
54 | {:else} 55 | Search for a channel above to add it to the list. 57 | {/if} 58 |
59 |
60 | -------------------------------------------------------------------------------- /src/routes/(protected)/components/ListForm.svelte: -------------------------------------------------------------------------------- 1 | 88 | 89 |
90 | {#if error} 91 | 96 | {/if} 97 |
98 | 109 |
110 | {#if $form.id} 111 | 112 | {/if} 113 | 125 | {#if $errors.title} 126 |
127 |
128 |

{$errors.title}

129 |
130 |
131 | {/if} 132 | 144 | {#if $errors.slug} 145 |
146 |
147 |

{$errors.slug}

148 |
149 |
150 | {/if} 151 |